cloudflare / cloudflare/vinext

Unify prefetch-setup cancellation: <Link> misses cache invalidation and is not destination-scoped

Open
#2,718 1 comment 0 reactions 0 assignees View on GitHub
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.