Skip to content

fix(link): cancel in-flight <Link> prefetch setup on cache invalidation and scope navigation cancellation to its destination - #2737

Open
NathanDrake2406 wants to merge 4 commits into
cloudflare:mainfrom
NathanDrake2406:fix/link-prefetch-setup-registry
Open

fix(link): cancel in-flight <Link> prefetch setup on cache invalidation and scope navigation cancellation to its destination#2737
NathanDrake2406 wants to merge 4 commits into
cloudflare:mainfrom
NathanDrake2406:fix/link-prefetch-setup-registry

Conversation

@NathanDrake2406

Copy link
Copy Markdown
Contributor

Overview

Goal Give <Link> prefetch setup the same cancellation semantics router.prefetch() gained in #2709
Core change Replace linkPrefetchNavigationEpoch with the sticky, destination-scoped pending-setup registry, moved to a shared dependency-free module
Key boundary shims/internal/app-prefetch-setup.ts owns registration and cancellation; navigation.ts owns the policy of when to cancel
Expected impact router.refresh() can no longer be undone by a late <Link> prefetch; navigations no longer drop unrelated pending prefetches

Closes #2718.

Why

A prefetch whose asynchronous setup is still in flight must not commit state from a cache generation that has since been invalidated, and a navigation should only cancel prefetch setup it is about to duplicate. #2709 built both guarantees for router.prefetch() as a registry of sticky per-destination tokens, but <Link>'s prefetchUrl kept its own global counter with neither property:

Area Principle / invariant What this PR changes
Cache invalidation invalidatePrefetchCache() voids the whole generation, so every pending setup must die with it The counter only advanced on navigation start, so a <Link> setup awaiting its lazy module imports resumed after router.refresh() and repopulated a navigation-reusable entry from the pre-refresh generation
Navigation start Cancel only setup for the route the navigation will fetch itself; anything broader turns prefetches into timing lottery Any navigation bumped the single global counter, so navigating to /b silently dropped a pending prefetch for /a that nothing else would ever fetch
Module layering link.tsx loads navigation.ts lazily to keep the navigation runtime off Link's synchronous startup path The registry now lives in a React-free, route-trie-free shims/internal/ module both files import statically, following the layering app-route-prefetch-policy.ts established in #2709

Sticky per-token cancellation is what makes destination scoping sound: a navigation to /a followed by one to /b leaves a pending /a prefetch cancelled, where comparing against a "current destination" value would not.

What changed

Scenario Before After
router.refresh() while a <Link> prefetch awaits its module imports Setup resumes, fetches, and registers a pre-refresh cache entry, undoing the refresh for that route Setup is cancelled; visible links still re-prefetch the fresh generation via the existing invalidation ping
Navigation to the same route as a pending <Link> prefetch Cancelled (global counter) Cancelled (destination match), unchanged behaviour
Navigation elsewhere while a <Link> prefetch is pending Prefetch silently dropped; later navigation pays full fetch cost Prefetch completes and stays reusable
Raw history.pushState (no request issued) Dropped all pending <Link> prefetches Leaves them alone, matching the router-side rationale in notifyAppNavigationStart
router.prefetch() (all #2709 scenarios) Registry in navigation.ts Same behaviour through the shared module; tests/prefetch-cache.test.ts unchanged and green
Maintainer review path
  1. packages/vinext/src/shims/internal/app-prefetch-setup.ts for the moved registry and the layering constraint its doc comment states (why it must stay dependency-free).
  2. packages/vinext/src/shims/navigation.ts for the mechanical switch to imports; cancellation policy call sites (notifyAppNavigationStart, invalidatePrefetchCache) are unchanged.
  3. packages/vinext/src/shims/link.tsx for the substantive change: prefetchUrl registers a token synchronously before its first await, re-checks cancelled after the module-loading race and again after the RSC-URL awaits before any cache write, and unregisters in finally. The navigation-runtime hook reverts to plain notifyLinkNavigationStart since cancellation no longer rides it.
  4. tests/link-navigation.test.ts for the three regression shapes.
Validation
  • Three new tests mirror the router-side shapes from fix(navigation): reuse router.prefetch payloads during navigation #2709, driven through prefetchUrl via the renderIsolatedLink harness:
    • "does not resume a Link prefetch across a cache invalidation" uses a hover-intent prefetch because invalidation deliberately re-pings visible links to re-prefetch fresh data, which would mask the stale resume under test.
    • "cancels a pending Link prefetch superseded by a navigation to the same route" and "keeps a pending Link prefetch when the navigation goes elsewhere" pin the destination scoping as a pair; the first also passes under a global counter.
  • Verified the tests fail on the pre-change implementation for exactly the two defects: the invalidation test and the navigate-elsewhere test fail, the same-route test passes.
  • vp test run green on the three directly affected suites (263 tests) and on the 17 suites importing the navigation or link shims (1,986 tests); vp check clean on all changed files; pre-commit full check and knip passed.
Risk / compatibility
  • No public API change; the registry module is shims/internal/ and the removed counter was module-private.
  • Bundle layering preserved: the new module's only imports (url-utils, utils/external-url) were already on Link's synchronous path.
  • Deliberate behaviour change: navigations and raw history updates no longer cancel <Link> prefetches for other destinations. This trades a possible duplicate request in zero scenarios (nothing else fetches those routes) for prefetch reliability, matching the tradeoff already accepted for router.prefetch() in fix(navigation): reuse router.prefetch payloads during navigation #2709.

References

Reference Why it matters
Issue #2718 Both defects, the layering constraint, and the suggested shape this PR follows
PR #2709 Introduced the sticky destination-scoped registry for router.prefetch(); its doc comment deferred this unification
tests/prefetch-cache.test.ts Router-side analogues of the three regression shapes, unchanged by this PR

…on and scope navigation cancellation to its destination

A <Link> prefetch whose setup was still awaiting its module imports survived
router.refresh(): the invalidation never advanced linkPrefetchNavigationEpoch,
so the resumed closure repopulated a navigation-reusable entry from the
pre-refresh cache generation. The same global counter also cancelled a pending
prefetch for /a when a navigation went to /b, making viewport and intent
prefetches timing-dependent for no duplicate-request benefit.

Replace the counter with the sticky, destination-scoped pending-setup registry
that cloudflare#2709 introduced for router.prefetch(), moved to a dependency-free
shims/internal/app-prefetch-setup.ts so link.tsx can register synchronously
without pulling navigation.ts onto its startup path.

Fixes cloudflare#2718
@NathanDrake2406

Copy link
Copy Markdown
Contributor Author

@codex review

@pkg-pr-new

pkg-pr-new Bot commented Jul 28, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@2737
npm i https://pkg.pr.new/create-vinext-app@2737
npm i https://pkg.pr.new/@vinext/types@2737
npm i https://pkg.pr.new/vinext@2737

commit: b0fd041

@github-actions

github-actions Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared b0fd041 against base 66ce817 using alternating same-runner rounds. Next.js was unchanged and skipped.

0 improved · 0 regressed · 6 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 133.2 KB 133.3 KB ⚫ +0.1%
Client entry size (gzip) vinext 120.8 KB 120.9 KB ⚫ +0.1%
Dev server cold start vinext 2.37 s 2.39 s ⚫ +0.8%
Production build time vinext 2.46 s 2.46 s ⚫ -0.1%
RSC entry closure size (gzip) vinext 105.5 KB 105.6 KB ⚫ +0.0%
Server bundle size (gzip) vinext 180.3 KB 180.2 KB ⚫ -0.0%

View detailed results and traces

🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d4e8e71ba7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/vinext/src/shims/link.tsx Outdated
Comment thread packages/vinext/src/shims/link.tsx Outdated
…ld the setup token through staged shell work

Codex review on cloudflare#2737 found two gaps in the registry wiring:

- The token destination was derived from the basePath-prefixed browser href,
  which toAppPrefetchDestination prefixes again, so under a non-empty basePath
  no navigation key ever matched and same-route cancellation silently stopped
  working. Derive it from the app-relative prefetchHref, matching what
  navigations pass in.

- The outer closure unregistered the token while the fire-and-forget loading
  shell (and the staged shell-first payload) were still awaiting
  createRscRequestUrl, so an invalidation in that window found no token and
  the helper repopulated the invalidated cache. Hold the token until staged
  work settles and re-check cancellation inside the helpers before their
  cache writes.
@NathanDrake2406

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Hooray!

Reviewed commit: 609a0538d4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

… boundary

Replace the module mock that gated createRscRequestUrl with timing derived
from the stubbed fetch itself: a high-priority intent prefetch issues its
full-payload fetch synchronously inside the setup closure, so a microtask
scheduled from that call lands after the closure returns and before the shell
helper's digest-backed createRscRequestUrl resolves. No internal collaborators
are mocked.
@NathanDrake2406

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. You're on a roll.

Reviewed commit: 2ba21e39f0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant