cloudflare / cloudflare/vinext
Unify prefetch-setup cancellation: <Link> misses cache invalidation and is not destination-scoped
- Dominant language
- TypeScript
- Stars
- 8.8k
- Forks
- 406
- Avg merge
- 2d 6h
- Merged PRs (30d)
- 120
Description
Split out of #2709, which fixed the equivalent gaps on the `router.prefetch()` path. Both are pre-existing on `main` and independent of that PR.
## Problem 1 — a `` prefetch survives cache invalidation
`router.refresh()` clears the client caches, but a `` prefetch whose setup is still in flight resumes afterwards and repopulates a navigation-reusable entry built from the pre-refresh cache generation. The refresh is undone for that route, and a subsequent navigation can commit the stale payload.
The invalidation path, none of which cancels in-flight prefetch setup:
```
router.refresh() shims/navigation.ts:2251
-> clearNavigationCaches
-> clearClientNavigationCaches server/app-browser-entry.ts:432
-> clearPrefetchState server/app-browser-entry.ts:425
-> invalidatePrefetchCache server/app-browser-entry.ts:426
```
`prefetchUrl` captures `linkPrefetchNavigationEpoch` (`shims/link.tsx:496`) and re-checks it after its module loads (`shims/link.tsx:567`), but that counter only advances from the navigation-start hook (`shims/link.tsx:178`). A cache invalidation leaves it unchanged, so the late closure sees a current epoch and proceeds to register its entry.
The window is widest when the lazily imported chunks are cold, which is also when a user is most likely to hit refresh.
## Problem 2 — `` cancellation is not destination-scoped
`linkPrefetchNavigationEpoch` is a single global counter that any navigation advances, so a navigation to `/b` abandons a `` prefetch for `/a` that is still in setup. Nothing else is going to fetch `/a`, so no duplicate request is avoided — the prefetch just becomes timing-dependent and a later navigation to `/a` has to fetch again.
## Why it is not just the `router.prefetch()` fix again
#2709 replaced its global counter with a registry of pending-setup tokens: `router.prefetch()` registers a `PendingPrefetchSetup` carrying its normalized destination href before its first `await` and re-checks `cancelled` after. `notifyAppNavigationStart(href)` cancels only tokens matching that destination; `invalidatePrefetchCache()` cancels all of them. Cancellation is sticky per token, which is what makes destination scoping sound — a navigation to `/a` then one to `/b` leaves a pending `/a` prefetch cancelled, where comparing against a "current destination" value would not.
`` keeps its own counter in its own module and benefits from neither half.
The two cannot simply share the registry in `navigation.ts`. `prefetchUrl` registers **synchronously**, but `link.tsx` only loads `navigation.js` lazily through `loadNavigationModule()` — deliberately, to keep `navigation.ts` off Link's synchronous path. Reaching the registry from there would force a static import and undo that.
## Suggested shape
Move `pendingPrefetchSetups`, `beginPrefetchSetup`, `cancelPendingPrefetchSetups`, and `toAppPrefetchDestination` into a dependency-free module under `shims/internal/` that both `link.tsx` and `navigation.ts` import statically, following the layering `shims/internal/app-route-prefetch-policy.ts` established in #2709. Those four have no React and no route-trie dependencies, so a static import is cheap for both. `linkPrefetchNavigationEpoch` then goes away rather than being kept in sync.
Best done after #2709 merges — the registry only exists on that branch.
## Regression tests
`tests/prefetch-cache.test.ts` has all three router-side analogues:
- "does not repopulate the prefetch cache across an invalidation" — starts a prefetch, calls `invalidatePrefetchCache()` before setup completes, asserts no fetch and an empty cache
- "cancels prefetch setup superseded by a navigation to the same route"
- "leaves prefetch setup alone when the navigation goes elsewhere"
The Link versions need the same three shapes driven through `prefetchUrl`. The last two only mean something as a pair — the first passes under a global counter too.
Contributor guide
Research direction
Start with shims/link.tsx, shims/navigation.ts, and the invalidation functions in server/app-browser-entry.ts, then compare the router-side implementation from #2709. Run the three analogous cases in tests/prefetch-cache.test.ts and add Link-driven versions through prefetchUrl. Done means invalidation cancels setup, same-destination navigation cancels it, and navigation elsewhere leaves it intact.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- react, typescript
- Domain
- frontend, testing
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 52/100