Skip to content

fix(app-router): hand off zero-stale prefetches - #2801

Merged
james-elicx merged 1 commit into
mainfrom
codex/fix-zero-ttl-prefetch-handoff
Aug 3, 2026
Merged

fix(app-router): hand off zero-stale prefetches#2801
james-elicx merged 1 commit into
mainfrom
codex/fix-zero-ttl-prefetch-handoff

Conversation

@james-elicx

Copy link
Copy Markdown
Member

Summary

  • hand an automatic prefetch with staleTimes.dynamic: 0 to the navigation that already claimed it while the request was in flight
  • continue expiring the settled dynamic payload immediately, so later navigations cannot reuse credentialed data
  • retain search-agnostic PPR route shells on the prefetch freshness window without making them navigation-consumable
  • cover successful, superseded, and search-agnostic handoff behavior with focused cache tests

Context

The regressions appeared between the latest scheduled main baseline and the post-merge deploy suite after #2757 stopped flooring dynamic prefetch stale times.

This preserves #2757's security/correctness intent: a completed automatic prefetch whose dynamic stale time is zero is never a cache hit. The exception is an ownership transfer to the navigation already awaiting that exact in-flight request. Search-agnostic PPR shells contain no query-dependent dynamic payload and remain non-consumable by navigation.

Validation

  • vp check — all 2,749 files formatted; no lint/type errors across 1,202 files
  • vp test run tests/prefetch-cache.test.ts — 71/71 passed
  • Next.js v16.2.6 deploy E2E, test/e2e/app-dir/metadata-streaming/metadata-streaming.test.ts — 19/19 passed
  • Next.js v16.2.6 deploy E2E, test/e2e/app-dir/navigation/navigation.test.ts — 49/50 passed; only the pre-existing hash scroll-offset failure remains
  • Next.js v16.2.6 deploy E2E, test/e2e/app-dir/segment-cache/search-params/segment-cache-search-params.test.ts — 3/4 passed; the PPR search-param regression is fixed and only the pre-existing rewritten-search dedupe failure remains
  • The original fix(app-router): stop flooring dynamic prefetch stale times #2757 assertions remain green, including two dynamic pages with different stale times behave independently and reuses dynamic data up to the staleTimes.dynamic threshold; unrelated existing failures remain in those suites

@pkg-pr-new

pkg-pr-new Bot commented Aug 3, 2026

Copy link
Copy Markdown

Open in StackBlitz

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

commit: ba29f0f

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor
Example Preview Production Original
app-router-cloudflare preview production
pages-router-cloudflare preview production
app-router-playground preview production original
realworld-api-rest preview production
nextra-docs-template preview production
benchmarks preview production
hackernews preview production original
workers-cache preview production
web preview production

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared ba29f0f against base 4f7d3fc 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 134.6 KB 134.6 KB ⚫ +0.0%
Client entry size (gzip) vinext 122.1 KB 122.2 KB ⚫ +0.0%
Dev server cold start vinext 2.82 s 2.79 s ⚫ -1.3%
Production build time vinext 3.03 s 3.01 s ⚫ -0.5%
RSC entry closure size (gzip) vinext 113.8 KB 113.8 KB ⚫ -0.0%
Server bundle size (gzip) vinext 191.3 KB 191.3 KB ⚫ +0.0%

View detailed results and traces

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

@ask-bonk

ask-bonk Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Review — fix(app-router): hand off zero-stale prefetches

Reviewed the two changed files. The fix is correct, well-scoped, and preserves the #2757 security intent. No blocking issues.

What the change does (and why it's right)

1. In-flight ownership handoff (consumePrefetchResponseForNavigation)

const allowExpiredInFlightHandoff = entry.pending !== undefined; is captured before the await entry.pending — so eligibility is decided by whether the request was in flight when this navigation began, exactly the ownership invariant described. This correctly threads through to consumeMatchedPrefetchResponse, which bypasses the expiresAt <= Date.now() gate only for the awaiting navigation.

The "exactly once" and "not reusable later" invariants hold:

  • After a successful handoff, consumeMatchedPrefetchResponse deletes the entry (navigation.ts:1528), so a later synchronous consumePrefetchResponse finds nothing.
  • Two concurrent navigations sharing the same in-flight entry are serialized by the cache.get(cacheKey) !== entry identity re-check (navigation.ts:1594) — the second returns null. Verified by the "exactly once" test.
  • A superseded in-flight navigation returns null without deleting the entry (navigation.ts:1596); any later navigation then sees entry.pending === undefinedallowExpiredInFlightHandoff = false → the expired entry is dropped at navigation.ts:1519. Verified by the "superseded" test.

2. Search-agnostic PPR shell retention

behavior.honorDynamicStaleTime !== false && behavior.searchAgnosticShell !== true (navigation.ts:1369) is necessary: without it, a zero dynamic-stale shell resolves expiresAt = timestamp (navigation.ts:517), so hasSearchAgnosticPrefetchShellForRoute's resolvePrefetchCacheEntryExpiresAt(entry) > Date.now() check (navigation.ts:688) is false and the shell is immediately deleted. Flooring it via honorDynamicStaleTime = false keeps the shell reusable for other search strings. Shells carry cacheForNavigation: false, so they remain non-consumable by navigation (short-circuited at navigation.ts:1511) — the shell is not handed off as dynamic payload. This matches the stated PPR semantics.

Verification

  • vp test run tests/prefetch-cache.test.ts — 71/71 pass locally.
  • vp check packages/vinext/src/shims/navigation.ts — no format/lint/type issues.

Minor notes (non-blocking)

  • The new allowExpiredInFlightHandoff parameter defaults to false, so the two other callers of consumeMatchedPrefetchResponse (the synchronous consumePrefetchResponse path at navigation.ts:1498) keep their strict expiry semantics. Good — settled entries stay unavailable to later navigations by default.
  • Dev/prod parity: this logic lives entirely in the client shim (shims/navigation.ts), so there is no separate server-side copy to keep in sync. No parity gap introduced.

LGTM.

@ask-bonk

ask-bonk Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2801. Summary of findings:

No blocking issues. The fix is correct and well-scoped.

In-flight handoffallowExpiredInFlightHandoff is captured before await entry.pending, so eligibility is decided by whether the request was in flight when the navigation began. The three critical invariants all hold:

  • Exactly once: the cache.get(cacheKey) !== entry identity re-check (navigation.ts:1594) serializes concurrent navigations; the entry is deleted on successful consume (navigation.ts:1528).
  • Not reusable later: settled entries default allowExpiredInFlightHandoff to false, so the expiry gate (navigation.ts:1519) drops them for later navigations.
  • Superseded: returns null without deleting, and the next caller sees pending === undefined and drops the expired entry.

Search-agnostic PPR shell — the && behavior.searchAgnosticShell !== true guard (navigation.ts:1369) is necessary: without it a zero dynamic-stale shell would resolve expiresAt = timestamp and be immediately deleted by hasSearchAgnosticPrefetchShellForRoute (navigation.ts:688). Flooring keeps the shell reusable, while cacheForNavigation: false keeps it non-consumable as dynamic payload.

Verification: 71/71 prefetch-cache tests pass; vp check clean. No dev/prod parity gap (logic lives entirely in the client shim).

github run

@james-elicx
james-elicx marked this pull request as ready for review August 3, 2026 21:34
@james-elicx
james-elicx merged commit dfc979c into main Aug 3, 2026
66 checks passed
@james-elicx
james-elicx deleted the codex/fix-zero-ttl-prefetch-handoff branch August 3, 2026 21:34
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.

1 participant