Skip to content

fix(cache): match segment cache staleness semantics - #2860

Draft
james-elicx wants to merge 8 commits into
mainfrom
codex/fix-segment-cache-staleness-current
Draft

fix(cache): match segment cache staleness semantics#2860
james-elicx wants to merge 8 commits into
mainfrom
codex/fix-segment-cache-staleness-current

Conversation

@james-elicx

Copy link
Copy Markdown
Member

Summary

  • honor global and per-page dynamic stale times for prefetched and visited App Router responses
  • preserve BFCache traversal reuse after normal dynamic expiry, including parallel-slot identity and restored Link prefetch behavior
  • exclude short-lived Cache Components omitted from prerender from the route prefetch expiry, while preserving cache life/tags and single-execution abort semantics

Upstream coverage

Ports and fixes the current Next.js staleness behavior exercised by:

  • test/e2e/app-dir/segment-cache/staleness/segment-cache-stale-time.test.ts
  • test/e2e/app-dir/segment-cache/staleness/segment-cache-per-page-dynamic-stale-time.test.ts

Validation

  • targeted per-page Next.js E2E: 5/5, three consecutive runs on the final build
  • targeted stale-time Next.js E2E: 4/4
  • focused vinext tests: 1,562/1,562
  • changed-file vp check: 25/25 files clean
  • git diff --check
  • vp run knip
  • vp run vinext#build

Independent cumulative review: no findings.

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@pkg-pr-new

pkg-pr-new Bot commented Aug 10, 2026

Copy link
Copy Markdown

Open in StackBlitz

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

commit: 0a745bc

@github-actions

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

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review: fix(cache): match segment cache staleness semantics

I reviewed all 25 changed files, ran the targeted suites, and type/lint-checked the changed source. No blocking issues found — the change is well-structured and the test coverage is strong (ports the relevant Next.js E2E scenarios and adds focused unit tests for each new helper).

Verification

  • vp test run tests/shims.test.ts tests/slot.test.ts tests/app-page-dispatch.test.ts tests/app-visited-response-cache.test.ts → 1401 passed
  • vp test run tests/link-navigation.test.ts tests/app-page-route-wiring.test.ts → 161 passed
  • vp check on all six substantial source files → clean (format + lint + types)

What I looked at closely

1. app-page-dispatch.ts — warmup isolation via runWithUnifiedStateMutation. Replacing discardAppPageRenderState() (warm in the shared context, then consume/clear afterward) with a scoped child context that resets the reference-typed slices before warm() is a genuine improvement. The reset set (cacheableFetchUrls, currentRequestTags, dynamicFetchUrls, dynamicUsageDetected, invalidDynamicUsageError, renderRequestApiUsage, requestScopedCacheLife, unstableCacheObservations) is a superset of what the old discard consumed, so no observation can leak from warmup into the final render. It also correctly follows the replace-not-mutate contract documented on the ALS helper.

2. cache-runtime.ts — PPR warmup cache result reuse. The dynamic-life branch intentionally suspends without publishing tags/cache-life (excluding short-lived Cache Components holes from the route prefetch expiry), and the non-dynamic branch propagates tags + records cache-life. The isDynamicCacheLife/cacheControlToCacheLife extraction matches the existing inline revalidate === 0 || expire < DYNAMIC_EXPIRE checks. Suspensions are kept outside the decode try/catch so an abort rejection is not mistaken for a corrupt entry. cacheLife() no longer eagerly publishing to the request scope is the correct pairing for this — the wrapper publishes the retained minimum instead.

3. link.tsx / link-prefetch.ts / app-prefetch-fetch-queue.ts — traversal-safe prefetch. The re-checking of destinationIsCurrent() at each post-await boundary (it now recomputes against live window.location.href instead of a captured target) and the epoch-guarded viewport drain correctly avoid a superseded setup fetching the page a traversal just reached. The pendingLinkPrefetchFetches Set is drained via the new onRelease hook in all three lifecycle paths (high-priority release, low-priority complete/error, and cancel), so its lifetime tracks the existing appPrefetchFetchControls WeakMap rather than introducing a new leak.

4. app-browser-entry.ts — provisional visited entry. Publishing an elements-only entry with a zero-length body before await cacheBufferPromise is safe: it passes committedElements (enabling elements-only BFCache reuse during the await window) and seedPrefetchCache=false (so no zero-body snapshot leaks into the prefetch cache), then replaces it with the full snapshot. This closes the window where a back navigation would otherwise find the destination absent from both caches.

5. app-ssr-entry.ts — renderer selection. Dropping the NODE_ENV short-circuit in isReactDevelopmentRuntime() and choosing the renderer from the actual React implementation is the right fix for the "prerender sets NODE_ENV=production after React loaded" case; production React still resolves to the ESM prerender path.

6. app-page-route-wiring.tsx — new ordered tree positions. Adding every route-segment position to orderedTreePositions unconditionally is safe because the BfcacheSegmentBoundary wrapping only occurs for primaryBfcacheSegments, which are only populated when __NEXT_CACHE_COMPONENTS === "true". The default-mode route-wiring suite (80 tests) still passes, confirming no regression in the non-cache-components path.

Minor observation (non-blocking)

In cache-runtime.ts, the non-dynamic warmup-result HIT path (the early readPprFallbackShellWarmupCacheResult branch) calls recordRequestScopedCacheLife(...) but not recordRequestScopedCacheControl(...), so it skips the enclosing-cache-scope parentCtx.lifeConfigs.push that the existing HIT path performs. For a non-dynamic result the NestedDynamicUseCacheError propagation is a no-op regardless, and warmup results are keyed per pprCacheKey, so this is unlikely to matter in practice. Worth a quick sanity check that a nested use cache scope during the final render still bubbles its life config to the outer scope on this path — but I could not construct a failing case and the suite is green.

Overall: LGTM.

@ask-bonk

ask-bonk Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2860. Summary of my findings:

Review outcome: LGTM (no blocking issues)

I reviewed all 25 changed files, ran the targeted test suites, and type/lint-checked the six substantial source files.

Verification:

  • tests/shims.test.ts tests/slot.test.ts tests/app-page-dispatch.test.ts tests/app-visited-response-cache.test.ts → 1401 passed
  • tests/link-navigation.test.ts tests/app-page-route-wiring.test.ts → 161 passed
  • vp check on changed source → clean

Key areas examined:

  1. app-page-dispatch.ts — the runWithUnifiedStateMutation warmup-isolation replacement is a genuine improvement over discardAppPageRenderState(), and its reset field set is a superset of what the old discard cleared.
  2. cache-runtime.ts — the PPR warmup-result reuse correctly excludes short-lived (dynamic) Cache Components holes from the route prefetch expiry while propagating tags/cache-life for non-dynamic results; suspensions kept outside the decode catch.
  3. link.tsx + prefetch queue — traversal-safe re-checks at each post-await boundary; the new strong Set is drained in all three lifecycle paths via the onRelease hook (no new leak).
  4. app-browser-entry.ts — the provisional zero-body visited entry is safe (elements-only reuse, seedPrefetchCache=false).
  5. app-ssr-entry.ts — the renderer-selection fix correctly handles late NODE_ENV=production.
  6. app-page-route-wiring.tsx — the new unconditional tree positions are gated by cache-components mode for actual boundary wrapping; default-mode suite confirms no regression.

One minor non-blocking observation: the non-dynamic warmup-result HIT path calls recordRequestScopedCacheLife but not recordRequestScopedCacheControl, skipping the enclosing-scope lifeConfigs.push. It's a no-op for non-dynamic results and I couldn't construct a failing case, but flagged it for a sanity check on nested use cache bubbling.

github run

@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared 0a745bc against base d558d62 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 135.2 KB 136.2 KB ⚫ +0.7%
Client entry size (gzip) vinext 122.7 KB 123.2 KB ⚫ +0.4%
Dev server cold start vinext 2.96 s 2.94 s ⚫ -0.5%
Production build time vinext 3.17 s 3.15 s ⚫ -0.7%
RSC entry closure size (gzip) vinext 114.2 KB 115.0 KB ⚫ +0.7%
Server bundle size (gzip) vinext 193.0 KB 194.1 KB ⚫ +0.6%

View detailed results and traces

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

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review: fix(cache): match segment cache staleness semantics

I reviewed all 25 changed files, ran the targeted suites, and type/lint-checked the substantial source files. The change is well-structured and the fixes are individually sound. No blocking issues. One testability gap and a couple of intentional-but-worth-flagging behavioral changes below.

Verification

  • vp test run tests/link-navigation.test.ts tests/app-visited-response-cache.test.ts → 101 passed
  • vp test run tests/shims.test.ts tests/slot.test.ts tests/app-page-dispatch.test.ts tests/app-page-route-wiring.test.ts → 1463 passed
  • vp check on the six substantial source files → clean (format + lint + types)
  • git diff --check → clean

What I verified

  1. app-page-dispatch.tsrunWithUnifiedStateMutation warmup isolation. The reset field set (cacheableFetchUrls, currentRequestTags, dynamicFetchUrls, dynamicUsageDetected, invalidDynamicUsageError, renderRequestApiUsage, requestScopedCacheLife, unstableCacheObservations) is a superset of what the removed discardAppPageRenderState() consumed, and each field is replaced (not mutated in place), correctly honoring the replace-not-mutate contract documented on the ALS helper. No observation leaks from warmup into the final render.

  2. cache-runtime.ts — PPR warmup cache-result reuse. The dynamic-life branch suspends without publishing tags/cache-life (excluding short-lived Cache Components holes from route prefetch expiry), and the non-dynamic branch propagates tags + records cache-life. isDynamicCacheLife/cacheControlToCacheLife match the pre-existing inline revalidate === 0 || expire < DYNAMIC_EXPIRE checks. Suspensions are kept outside the decode try/catch so an abort rejection is not mistaken for a corrupt entry.

    Regarding the prior review's minor observation about recordRequestScopedCacheControl vs recordCacheHitLife: this is resolved — the warmup-result HIT path calls recordCacheHitLife, which itself performs the parentCtx.lifeConfigs.push and the NestedDynamicUseCacheError capture. So nested use cache life bubbling is preserved on that path.

  3. link.tsx / link-prefetch.ts / app-prefetch-fetch-queue.ts — traversal-safe prefetch. destinationIsCurrent() recomputes against live window.location.href at each post-await boundary; the epoch-guarded viewport drain and navigate/popstate cancellation avoid a superseded setup fetching a page a traversal just reached. The new pendingLinkPrefetchFetches Set is drained via onRelease in all three lifecycle paths (high-priority release, low-priority complete/error, cancel). Deleting the current element from the Set during for...of in cancelLinkPrefetchSetups() is spec-safe.

  4. app-browser-entry.ts — provisional zero-body visited entry. Publishing an elements-only entry (committedElements, seedPrefetchCache=false) before await cacheBufferPromise is safe and closes the back-navigation window. hasVisitedResponseForPrefetch is only invoked for priority === "high", so its internal cache-eviction side effect only runs against the traverse (>30 min) freshness bound — a benign eviction of truly-expired entries.

  5. app-page-route-wiring.tsx / app-page-segment-plan.ts — primary bfcache segments. Unconditionally adding route-segment positions to orderedTreePositions is safe because BfcacheSegmentBoundary wrapping only fires for primaryBfcacheSegments, which are gated by __NEXT_CACHE_COMPONENTS === "true" via includePrimarySegment. Default-mode route-wiring suite still green.

  6. slot.tsxisSlotId guard. Skipping non-slot (layout-id) parents in the preservedParentIds identity-flatten loop is correct: primary children-spine boundaries use layout ids as synthetic parents and must not go through slot-flattening there. Covered by slot.test.ts.

Non-blocking findings

1. (Testability) The core new helpers lack direct unit tests. rememberPprFallbackShellWarmupCacheResult / readPprFallbackShellWarmupCacheResult (new in ppr-fallback-shell.ts) and the warmup-result reuse branches in cache-runtime.ts are the heart of this PR, but they have no direct test. tests/ppr-fallback-shell.test.ts was not touched, and the cache-runtime warmup-reuse path (dynamic-suspend vs non-dynamic tag/life propagation, phase-gating of remember/read) is only reachable indirectly. Per AGENTS.md ("unit test the runtime helpers directly"), a focused test on these helpers — phase gating, the dynamic-vs-non-dynamic branch, and tag/cacheLife copying — would guard the exact staleness semantics this PR is fixing. Recommend adding before merge or as an immediate follow-up.

2. (Intentional, flagging for awareness) app-ssr-entry.ts onError now swallows all errors once pprFallbackShellSignal.aborted. Previously only isPprFallbackShellAbortError(error) was suppressed. Same change applied to app-page-render.ts's onRscRenderError. This is a reasonable "post-abort errors are cascade noise" choice and keeps dev/prod parity, but it is a genuine widening of error suppression during the fallback-shell prerender. Confirm this matches the App Router prerender-abort semantics you ported from (it appears to), since a real error raised in the exact turn after abort would now be dropped silently.

3. (Intentional, flagging) app-ssr-entry.ts loadStaticPrerender no longer falls back to the ESM entry in dev. When isReactDevelopmentRuntime() is true it now only attempts the CJS dev renderer and throws on failure, rather than trying react-dom/static.edge as before. This is the correct fix for the "prerender sets NODE_ENV=production after React loaded" bug (mixing dev React with the production renderer), but a future React dev build that exports prerender() from the ESM entry would route through CJS instead. Fine for current React; noting the removed fallback.

Overall: solid, matches the intended Next.js staleness behavior, and the E2E ports back it up. The only thing I'd want before/right-after merge is direct unit coverage for the two new PPR warmup-cache-result helpers and the cache-runtime reuse branch (finding 1).

@ask-bonk

ask-bonk Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2860.

Review outcome: LGTM (no blocking issues), one testability gap flagged

I reviewed all 25 changed files, ran the targeted suites (1463 + 101 passed), and type/lint-checked the six substantial source files (clean).

Verified sound:

  1. app-page-dispatch.ts — the runWithUnifiedStateMutation warmup-isolation reset set is a correct superset of the removed discardAppPageRenderState() and follows the replace-not-mutate ALS contract.
  2. cache-runtime.ts — warmup-result reuse correctly excludes short-lived dynamic Cache Components holes from route prefetch expiry; suspensions kept outside the decode catch. The prior review's recordCacheHitLife observation is resolved (that path does perform the lifeConfigs.push + NestedDynamicUseCacheError).
  3. link.tsx + prefetch queue — traversal-safe re-checks, epoch-guarded viewport drain, navigate/popstate cancellation; the new Set is drained in all three lifecycle paths (safe Set-delete-during-iteration).
  4. app-browser-entry.ts — provisional zero-body visited entry is safe; hasVisitedResponseForPrefetch only runs for high-priority, so its eviction only touches truly-expired traversal entries.
  5. app-page-route-wiring.tsx / app-page-segment-plan.ts — primary bfcache segments gated by cache-components mode; default suite green.
  6. slot.tsx — the isSlotId guard correctly skips synthetic layout-id parents.

Findings (all non-blocking):

  • Testability gap (main one): the two new PPR warmup-cache-result helpers and the cache-runtime reuse branches — the heart of this PR — have no direct unit test. tests/ppr-fallback-shell.test.ts was untouched. Recommended a focused test on phase gating + dynamic/non-dynamic branch before/right-after merge.
  • Intentional widening: onError now swallows all errors once pprFallbackShellSignal.aborted (not just PPR abort errors), in both app-ssr-entry.ts and app-page-render.ts — flagged for parity confirmation.
  • Intentional: loadStaticPrerender no longer falls back to the ESM entry in dev — correct fix for the dev/prod React mismatch, noted for awareness.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review: fix(cache): match segment cache staleness semantics

Reviewed all 26 changed files, focusing on the substantial source changes. Ran the targeted suites and type/lint-checked the six substantial source files. No blocking issues — LGTM.

The two earlier /bigbonk runs (10:03, 10:24) were against ceb8bdb, before the third commit 3e9ebe2 test(cache): cover fallback shell reuse. That commit resolves the testability gap flagged as finding #1 in the prior review, so this pass focuses on confirming that and on independent correctness checks.

Verification

  • vp test run tests/ppr-fallback-shell.test.ts tests/app-page-dispatch.test.ts tests/app-visited-response-cache.test.ts tests/slot.test.ts → 135 passed
  • vp test run tests/link-navigation.test.ts tests/app-page-route-wiring.test.ts → 162 passed
  • vp check on the six substantial source files → clean (format + lint + types)

Prior testability gap is now resolved

The heart of this PR — the PPR warmup-cache-result reuse — now has direct unit coverage:

  • tests/ppr-fallback-shell.test.ts: rememberPprFallbackShellWarmupCacheResult / readPprFallbackShellWarmupCacheResult phase-gating (warmup-only write, final-only read), the defensive clone (mutating the source cacheLife/tags after remember does not affect the stored value), and shouldIgnorePprFallbackShellRenderError.
  • tests/shims.test.ts "omits short-lived use cache entries from a Cache Components prerender": exercises both cache-runtime.ts reuse branches through registerCachedFunction — the non-dynamic (minutes) path propagates tags (getCollectedFetchTags()["long-lived"]) and records cache-life (_peekRequestScopedCacheLife() → the minutes profile) without re-executing, while the dynamic (seconds) path suspends indefinitely (stays pending, then rejects on abort) and is never re-run.

What I verified independently

  1. cache-runtime.ts warmup reuse — the early warmup-HIT branch (L586) uses recordCacheHitLife, which performs the enclosing-scope parentCtx.lifeConfigs.push + NestedDynamicUseCacheError capture, so the earlier minor observation about recordRequestScopedCacheControl is fully addressed. Suspensions are kept outside the decode try/catch (L617-624) so an abort rejection is not misread as a corrupt entry. cacheControlToCacheLife / isDynamicCacheLife match the pre-existing inline revalidate === 0 || expire < DYNAMIC_EXPIRE checks.

  2. app-page-dispatch.ts runWithUnifiedStateMutation isolation — the reset field set is a superset of what the removed discardAppPageRenderState() consumed, and each field is replaced (not mutated in place), honoring the replace-not-mutate contract on the ALS helper. The child context is discarded after warm(), so warmup observations cannot leak into the final render.

  3. link.tsx / link-prefetch.ts / app-prefetch-fetch-queue.tsdestinationIsCurrent() recomputes against live window.location.href at each post-await boundary; the epoch-guarded viewport drain plus navigate(traverse)/popstate cancellation avoid a superseded setup fetching a page a traversal just reached. The new pendingLinkPrefetchFetches Set is drained via onRelease in all three queue lifecycle paths, so it tracks the existing appPrefetchFetchControls WeakMap lifetime and introduces no leak.

  4. app-browser-entry.ts — the provisional zero-body visited entry (committedElements, seedPrefetchCache=false) is safe and closes the back-navigation window; hasVisitedResponseForPrefetch runs only for priority === "high" and its traverse freshness bound only evicts truly-expired entries.

  5. app-page-segment-plan.ts / app-page-route-wiring.tsx / slot.tsxprimaryBfcacheSegments are gated by __NEXT_CACHE_COMPONENTS === "true" via includePrimarySegment; the unconditional orderedTreePositions additions are inert outside cache-components mode (default route-wiring suite green). The isSlotId guard in mergeElements correctly skips layout-id synthetic parents.

  6. app-rsc-handler.ts / app-page-dispatch.ts — the new kind: "cache-components-prerender" discriminant correctly keeps placeGeneratedMetadataInBody false for a real prerender while fallback-shell still places metadata in the body.

Non-blocking observations

  1. (Pre-existing, not a regression) Shared reference fields not reset by the warmup mutate. runWithUnifiedStateMutation's docstring enumerates all reference-typed slices that a mutate must replace to avoid the parent observing in-place writes. The dispatch mutate resets the observation-relevant subset but not pendingSetCookies, afterContext.callbacks, pendingRevalidatedTags, or serverInsertedHTMLCallbacks. If a fallback-shell warmup render performs a cookies().set(), after(), or revalidateTag() in place, those would bleed into the parent scope. The removed discardAppPageRenderState() had the same gap, so this is not introduced here — but since warmup now runs in a scoped child, resetting those slices too would be strictly safer and match the documented contract. Worth a follow-up if a warmup can reach those APIs.

  2. (Intentional, flagging for awareness) onError now swallows all errors once pprFallbackShellSignal.aborted in both app-ssr-entry.ts and app-page-render.ts (shouldIgnorePprFallbackShellRenderError returns true whenever signal.aborted, not only for abort-shaped errors). This matches Next.js's "post-abort errors are cascade noise" prerender semantics and keeps dev/prod parity, but it is a genuine widening of suppression during the fallback-shell prerender. Now covered by a unit test.

Overall: solid, matches the intended Next.js staleness behavior, the E2E ports back it up, and the third commit closes the earlier coverage gap.

@ask-bonk

ask-bonk Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2860.

Review outcome: LGTM (no blocking issues)

I reviewed all 26 changed files, ran the targeted suites (135 + 162 passed), and vp check'd the six substantial source files (clean).

Key finding vs. prior runs: the two earlier /bigbonk reviews ran against ceb8bdb, before the third commit 3e9ebe2 test(cache): cover fallback shell reuse. That commit resolves the testability gap those reviews flagged — the PPR warmup-cache-result reuse (the heart of the PR) now has direct unit coverage:

  • tests/ppr-fallback-shell.test.ts: phase-gating + defensive-clone of remember/read, plus shouldIgnorePprFallbackShellRenderError.
  • tests/shims.test.ts "omits short-lived use cache entries…": exercises both cache-runtime.ts reuse branches — non-dynamic propagates tags + records cache-life without re-executing; dynamic suspends indefinitely and never re-runs.

Verified independently: the recordCacheHitLife life-bubbling on the warmup-HIT path (earlier concern now moot), suspensions kept outside the decode catch, the runWithUnifiedStateMutation reset being a replace-not-mutate superset of the old discardAppPageRenderState(), traversal-safe prefetch re-checks + Set drain in all three queue paths, the provisional zero-body visited entry, and the cache-components gating of primaryBfcacheSegments.

Two non-blocking observations:

  1. The warmup mutate doesn't reset pendingSetCookies / afterContext.callbacks / pendingRevalidatedTags / serverInsertedHTMLCallbacks — a shared-reference bleed risk if a warmup render reaches those APIs. Pre-existing (the removed discard had the same gap), not a regression, but resetting them in the scoped child would match the documented ALS contract. Follow-up candidate.
  2. The onError widening to swallow all errors once signal.aborted is intentional (matches Next.js prerender-abort cascade semantics) and is now unit-tested.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review: fix(cache): match segment cache staleness semantics

Independent pass focused on the parts the prior /bigbonk runs covered less — the build/prerender/seed pipeline, the new /__vinext/prefetch-policy endpoint and its client consumer, cache-key parity across the KV/seed/runtime paths, and the new bfcache "segment" descriptor. No blocking issues. The core App Router cache-runtime/dispatch/link changes were already covered thoroughly in the three earlier reviews and I independently re-confirmed them.

Verification

  • vitest run tests/ppr-fallback-shell.test.ts tests/app-page-dispatch.test.ts tests/seed-cache.test.ts tests/prerender.test.ts tests/inject-pregenerated-paths.test.ts tests/pregenerated-concrete-paths.test.ts → 234 passed
  • vitest run tests/shims.test.ts tests/link-navigation.test.ts tests/app-visited-response-cache.test.ts tests/slot.test.ts tests/prefetch-cache.test.ts → 1491 passed

Cache-key parity — verified consistent across all three producers

The new prefetch-loading-shell RSC variant is keyed identically everywhere:

  • Runtime readappIsrRscKey(pathname, null, "prefetch-loading-shell")...:rsc:prefetch-loading-shell
  • Seed (Node) — entry's buildAppPageRscKey(pathname, renderMode) = __isrRscKey(pathname, null, renderMode) (same), fallback = baseKey + ":rsc:prefetch-loading-shell"
  • Cloudflare KV populateappIsrCacheKey(cachePathname, "rsc:prefetch-loading-shell", buildId)

All three resolve to the same string, so seeded/populated shells are actually reachable at runtime. Good.

Partial-prerender path normalization — agrees for canonical inputs

Server stores normalizePath(normalizePathnameForRouteMatch(pathname)) (addPartialPrerenderPath); the client hasPartialPrerenderForRoute looks up with only normalizePathnameForRouteMatch(...). normalizePath is a no-op fast-path for already-canonical single-slash pathnames, which is what prerendered route.path/route.route values always are, so the sets agree in practice. Only a pathological (double-slash / dot-segment) prerender path would diverge, and those don't occur here. Fine.

/__vinext/prefetch-policy endpoint — placement is correct

Handled early in handleAppRscRequest on pathname === "/__vinext/prefetch-policy", matching the established /__vinext/prerender/* internal-endpoint pattern (matches on pathname, bypasses middleware/routing — appropriate for an internal manifest endpoint). clearRequestContext() is called before responding. Cache-Control: public, max-age=0, must-revalidate is reasonable for a build-static manifest.

  • Non-blocking nit: the two existing sibling endpoints use exported constants (VINEXT_PRERENDER_STATIC_PARAMS_PATH), whereas this one hard-codes the "/__vinext/prefetch-policy" literal in both the handler and the client fetch (app-route-prefetch-policy.ts). A shared constant would keep the two ends from drifting. Cosmetic only.

New hasPartialPrerenderForRoute await in the Link prefetch path — traversal-safe

The new await hasPartialPrerenderForRoute(...) at link.tsx:547 adds an async boundary before the fetch, but it only selects the renderMode request header; the actual fetch is still gated by scheduleGuardedLinkPrefetchFetchprefetchWasSuperseded() (re-checks navigationEpoch + live destinationIsCurrent()), so a traversal that lands during the await still cancels the fetch. The module-level partialPrerenderPathsPromise memoizes the manifest fetch (one network call) and .catch(() => new Set()) fails open to "no partial prerender", so a policy-endpoint failure degrades to normal prefetch rather than breaking it. Sound.

New bfcache "segment" descriptor — collision-free

deriveBfcacheSegmentIdentity's new "segment" case serializes to ["segment", ownerLayoutGraphId, boundSegmentKey] — a distinct leading kind tag and 3-tuple that cannot alias the other kinds. The primary segment ids are minted via createNestedBfcacheSlotSegmentId(layoutEntry.id, level) with a layout id as the encoded parent, so isNestedBfcacheSlotSegmentIdFor(id, <slotId>) is false for them — which is exactly why the slot.tsx isSlotId(parentId) guard is needed to skip them in the identity-flatten loop. Consistent, and gated behind __NEXT_CACHE_COMPONENTS === "true" via includePrimarySegment (default-mode suites green).

One pre-existing gap re-confirmed (non-blocking, matches prior finding #1)

The warmup mutate in app-page-dispatch.ts (the runWithUnifiedStateMutation child) resets 8 observation slices but not afterContext, pendingSetCookies, serverInsertedHTMLCallbacks, pendingRevalidatedTags, pendingRevalidations, or ssrHeadChildren — all of which the runWithUnifiedStateMutation docstring explicitly enumerates as reference-typed slices a mutate must replace. Because warm() renders the full page element (warmupBuildResult.element), a page/layout that calls after(), cookies().set(), revalidateTag(), or inserts server HTML during warmup would bleed those into the parent scope via the shared reference (e.g. double-run after() callbacks in the final render).

  • Not introduced by this PR — the removed discardAppPageRenderState() had the same gap.
  • Only reachable under VINEXT_PRERENDER === "1" (build-time prerender), never on a live production request.
  • Since warmup now runs in a scoped child anyway, adding those field resets to the mutate would be strictly safer and would match the documented ALS contract. Good follow-up candidate.

Intentional widenings (already flagged in prior reviews, re-confirmed)

  • shouldIgnorePprFallbackShellRenderError(signal, error) returns true whenever signal.aborted (not just for abort-shaped errors), now applied in both app-ssr-entry.ts and app-page-render.ts. Matches Next.js's "post-abort errors are cascade noise" prerender semantics; unit-tested.
  • loadStaticPrerender no longer falls back to the ESM entry when isReactDevelopmentRuntime() — the correct fix for the "prerender sets NODE_ENV=production after React loaded" dev/prod dispatcher mismatch.

Overall: solid, well-tested, and the build→seed→runtime cache-key path lines up. LGTM.

@ask-bonk

ask-bonk Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2860.

Review outcome: LGTM (no blocking issues)

I ran an independent pass focused on the areas the three prior /bigbonk reviews covered less — the build/prerender/seed pipeline, the new /__vinext/prefetch-policy endpoint, cache-key parity, and the new bfcache "segment" descriptor.

Verification:

  • tests/ppr-fallback-shell.test.ts tests/app-page-dispatch.test.ts tests/seed-cache.test.ts tests/prerender.test.ts tests/inject-pregenerated-paths.test.ts tests/pregenerated-concrete-paths.test.ts → 234 passed
  • tests/shims.test.ts tests/link-navigation.test.ts tests/app-visited-response-cache.test.ts tests/slot.test.ts tests/prefetch-cache.test.ts → 1491 passed

Key checks that came back clean:

  1. Cache-key parity — the new prefetch-loading-shell RSC variant resolves to the same key string across the runtime read (appIsrRscKey), the Node seed path, and the Cloudflare KV populate path. Seeded/populated shells are actually reachable at runtime.
  2. Partial-prerender path normalization — server (normalizePath ∘ normalizePathnameForRouteMatch) vs client (normalizePathnameForRouteMatch only) agree for canonical prerender paths since normalizePath is a no-op fast-path there.
  3. /__vinext/prefetch-policy endpoint — placement follows the established internal-endpoint pattern, fails open client-side (.catch(() => new Set())), memoized fetch.
  4. New Link prefetch await — traversal-safe; the fetch is still gated by prefetchWasSuperseded().
  5. New bfcache "segment" descriptor — collision-free serialization, correctly requires the slot.tsx isSlotId guard, gated behind cache-components mode.

Non-blocking findings:

  • Re-confirmed prior finding Virtual module imports break esbuild dependency optimization when vinext is installed from npm #1: the warmup mutate doesn't reset afterContext/pendingSetCookies/serverInsertedHTMLCallbacks/etc. that the ALS contract enumerates. Pre-existing (old discardAppPageRenderState() had the same gap), build-prerender-only, good follow-up candidate.
  • Cosmetic nit: the new endpoint hard-codes "/__vinext/prefetch-policy" in two places instead of a shared constant like its siblings.

github run

@james-elicx
james-elicx marked this pull request as ready for review August 10, 2026 14:01
…he-staleness-current

# Conflicts:
#	packages/vinext/src/shims/internal/app-route-prefetch-policy.ts
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review: fix(cache): match segment cache staleness semantics

Independent pass at 6e70303. I re-derived the correctness of each substantial source change from scratch, verified cache-key/render-mode parity across the build→seed→KV→runtime paths, and ran the targeted suites. No blocking issues — LGTM. The three earlier /bigbonk runs are consistent with what I found; I confirmed their prior concerns are either resolved or accurately characterized as non-blocking.

Verification

  • vitest run tests/ppr-fallback-shell.test.ts tests/shims.test.ts tests/app-page-dispatch.test.ts tests/seed-cache.test.ts tests/prerender.test.ts → 1498 passed
  • vitest run tests/link-navigation.test.ts tests/app-visited-response-cache.test.ts tests/slot.test.ts tests/prefetch-cache.test.ts tests/app-page-route-wiring.test.ts tests/app-rsc-handler.test.ts → 460 passed
  • vp check packages/vinext/src/shims/cache-runtime.ts server/app-page-dispatch.ts shims/link.tsx server/app-page-cache.ts → clean (format + lint + types)

Independently confirmed correct

  1. cache-runtime.ts warmup reuse + life bubbling. The warmup-HIT branch (L586–596) uses recordCacheHitLife, which at L808/L821/L827 does all three of: parentCtx.lifeConfigs.push, the NestedDynamicUseCacheError capture, and _setRequestScopedCacheLife. So the request-scope publish and the enclosing-use cache-scope bubbling are both preserved on the reuse path — the earliest review's recordRequestScopedCacheControl concern is genuinely resolved (I traced it line-by-line rather than taking it on faith). Dynamic holes suspend before publishing tags/life; suspensions stay outside the decode try/catch. cacheControlToCacheLife/isDynamicCacheLife reproduce the pre-existing inline revalidate === 0 || expire < DYNAMIC_EXPIRE check exactly.

  2. cache.tscacheLife() no longer eagerly publishing is the correct pairing for #1: the wrapper now publishes the retained minimum once the value is kept, so a short-lived hole omitted from a Cache Components prerender no longer constrains the enclosing page.

  3. app-page-cache.ts bypassStaleResponse. A fresh (non-stale) prefetch-loading-shell entry is still served on HIT (L409–435); only a stale one is bypassed to force a fresh navigation render (L446–455). Returning null there deliberately skips background regeneration for that request, which is correct — an ordinary navigation must render the missing dynamic data, not replay/refresh the partial shell.

  4. app-page-dispatch.ts isPrerenderPartialCacheRead. Correctly narrowed to RSC + navigation render-mode + whole-route prefetch (Next-Router-Prefetch: 1, no segment header), so only a whole-route prefetch reads the partial artifact; a real navigation renders through.

  5. link.tsx traversal-safety. destinationIsCurrent() recomputes against live window.location.href at every post-await boundary; the new hasPartialPrerenderForRoute await and the RSC-URL hashing are both followed by epoch + current-URL re-checks, and the actual fetch is gated by scheduleGuardedLinkPrefetchFetch → prefetchWasSuperseded(). The new navigate(traverse)/popstate listeners abort in-flight setups via cancelLinkPrefetchSetups. The pendingLinkPrefetchFetches Set is drained via onRelease in all three queue lifecycle paths, tracking the existing appPrefetchFetchControls WeakMap lifetime (no new leak).

  6. Cache-key/render-mode parity across producers. The prefetch-loading-shell RSC variant keys identically at runtime (__isrRscKey(pathname, null, renderMode)), in the Node seed path (buildAppPageRscKey(pathname, renderMode) and its :rsc:<variant> fallback), and in the Cloudflare KV populate path (getRscRenderModeCacheVariantrsc:<variant>). parseAppRscRenderMode/getRscRenderModeCacheVariant are shared, so seeded and populated shells are reachable at runtime. writePrerenderIndex only records partialPrerenderPaths for rendered && app && prefetch-loading-shell && !fallback, and the same predicate seeds addPartialPrerenderPath — the /__vinext/prefetch-policy endpoint therefore advertises exactly the routes that were captured.

  7. app-ssr-entry.ts renderer selection. Dropping the NODE_ENV short-circuit and choosing the renderer from the actual React implementation (Function.prototype.toString(...).includes("getOwner")) is the right fix for "prerender sets NODE_ENV=production after React was loaded"; production React still resolves to the ESM prerender path (the ESM attempt now runs after the dev-CJS branch).

  8. slot.tsx isSlotId guard correctly skips layout-id synthetic parents in the identity-flatten loop; bfcache-identity.ts "segment" serializes to a collision-free 3-tuple; both are gated behind __NEXT_CACHE_COMPONENTS === "true" via includePrimarySegment (default suites green).

Non-blocking observations

  1. (Pre-existing, build-prerender-only) The warmup mutate in app-page-dispatch.ts (L1069) resets 8 observation slices but not the other reference-typed slices the runWithUnifiedStateMutation docstring enumeratesafterContext, pendingSetCookies, serverInsertedHTMLCallbacks, pendingRevalidatedTags, pendingRevalidations, ssrHeadChildren. I re-confirmed the warmup is still strictly gated by process.env.VINEXT_PRERENDER === "1" (L1064), so this is build-time only and never reachable on a live request; the removed discardAppPageRenderState() had the same gap, so it is not a regression. But since warm() now renders in a scoped child, a page/layout that calls after() / cookies().set() / revalidateTag() / inserts server HTML during warmup would bleed those into the parent via the shared reference. Adding those resets to the mutate would match the documented contract and cost nothing. Good follow-up candidate.

  2. (Naming clarity) notifyLinkNavigationStartAndCancelPrefetchSetup only bumps the epoch; it does not abort in-flight fetches — only the new cancelLinkPrefetchSetups (bound to navigate/traverse + popstate) both bumps the epoch and calls cancelAppPrefetchFetch. That asymmetry looks intentional (a forward router.push/click may be navigating to the in-flight prefetch's destination, so aborting it would be wasteful), but the "…AndCancelPrefetchSetup" name now overstates what it does relative to the new sibling. Cosmetic.

  3. (Cosmetic, re-confirming prior finding) /__vinext/prefetch-policy hard-codes the path literal in both the handler (app-rsc-handler.ts) and the client fetch (app-route-prefetch-policy.ts) instead of a shared exported constant like its VINEXT_PRERENDER_STATIC_PARAMS_PATH siblings. A shared constant would prevent the two ends from drifting.

Overall: the change is well-structured, matches the intended Next.js staleness semantics, the E2E ports and new unit tests back it up (ppr-fallback-shell.test.ts + the "omits short-lived use cache entries" shim test cover both cache-runtime reuse branches), and the build→seed→runtime cache-key path lines up. Ship it.

@ask-bonk

ask-bonk Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2860.

Review outcome: LGTM (no blocking issues)

I did an independent pass at 6e70303, re-deriving each substantial source change from scratch rather than relying on the three prior /bigbonk runs. Ran the targeted suites (1498 + 460 passed) and vp check'd the key files (clean).

Independently confirmed correct:

  1. cache-runtime.ts — traced the warmup-HIT life bubbling line-by-line: recordCacheHitLife does parentCtx.lifeConfigs.push, the NestedDynamicUseCacheError capture, and _setRequestScopedCacheLife, so the earliest review's recordRequestScopedCacheControl concern is genuinely resolved. Dynamic holes suspend before publishing; suspensions stay outside the decode catch.
  2. app-page-cache.ts bypassStaleResponse — fresh shells still HIT; only stale ones bypass to force a fresh navigation render (skipping background regen there is correct).
  3. Cache-key/render-mode parity — the prefetch-loading-shell variant keys identically across runtime / Node seed / Cloudflare KV populate via shared parseAppRscRenderMode/getRscRenderModeCacheVariant, and partialPrerenderPaths uses the same predicate the seed path uses.
  4. link.tsx — traversal-safe re-checks at every post-await boundary; Set drained in all three queue lifecycle paths.
  5. app-ssr-entry.ts, slot.tsx, bfcache-identity.ts — renderer selection, isSlotId guard, and collision-free "segment" descriptor all sound; cache-components-gated.

Non-blocking findings:

  1. The warmup mutate (dispatch L1069) resets 8 observation slices but not afterContext/pendingSetCookies/serverInsertedHTMLCallbacks/pendingRevalidatedTags/pendingRevalidations/ssrHeadChildren. I re-confirmed it's still gated to VINEXT_PRERENDER === "1" (build-only, not a regression). Good follow-up.
  2. Naming clarity: notifyLinkNavigationStartAndCancelPrefetchSetup only bumps the epoch — it doesn't abort in-flight fetches (only the new cancelLinkPrefetchSetups does). Looks intentional but the name overstates it.
  3. Cosmetic: /__vinext/prefetch-policy hard-codes the path literal in two places instead of a shared constant.

github run

@james-elicx
james-elicx marked this pull request as draft August 10, 2026 21:03
…he-staleness-current

# Conflicts:
#	packages/vinext/src/shims/cache-runtime.ts
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review: fix(cache): match segment cache staleness semantics

Independent pass at 58cfe92 (post-merge with main). I re-derived each substantial source change from scratch, verified cache-key/render-mode parity across the build→seed→KV→runtime paths, traced the warmup-cache-result key derivation, and ran the targeted suites. No blocking issues — LGTM. My findings match the five prior /bigbonk runs.

Verification

  • vp test run tests/shims.test.ts tests/ppr-fallback-shell.test.ts tests/app-page-dispatch.test.ts tests/link-navigation.test.ts → 1461 passed
  • vp test run tests/app-visited-response-cache.test.ts tests/app-page-route-wiring.test.ts tests/slot.test.ts tests/prefetch-cache.test.ts tests/seed-cache.test.ts tests/prerender.test.ts tests/app-rsc-handler.test.ts → 500 passed

Independently confirmed correct

  1. cache-runtime.ts warmup reuse — key derivation is consistent. The final-phase read uses the early pprCacheKey (cacheVariant\0 + root-param-suffixed cacheKey, L669/L679), while the warmup writes key off resultCacheKey (L794–798). Because warmup runs first and addKnownRootParamNames(id, …) populates knownRootParamsByFunctionId during warmup, the early key in the final phase reconstructs the same suffix — so remembered results are actually found. The dynamic branch (L683) intentionally suspends without publishing tags/cache-life; the non-dynamic branch propagates tags + recordCacheHitLife (which does the parentCtx.lifeConfigs.push + NestedDynamicUseCacheError capture + _setRequestScopedCacheLife), so the earliest review's recordRequestScopedCacheControl concern is genuinely resolved. cacheControlToCacheLife/isDynamicCacheLife reproduce the pre-existing inline revalidate === 0 || expire < DYNAMIC_EXPIRE check exactly. Suspensions are kept outside the decode try/catch.

  2. cache.tscacheLife() no longer eagerly publishing is the correct pairing: the wrapper publishes the retained minimum only after the value is kept, so a short-lived hole omitted from a Cache Components prerender no longer constrains the enclosing page.

  3. app-page-dispatch.ts isPrerenderPartialCacheRead — correctly narrowed to RSC + navigation render-mode + whole-route prefetch (Next-Router-Prefetch: 1, no segment header), and bypassStaleResponse only fires for the loading-shell read path. app-page-cache.ts bypassStaleResponse returns null on a stale partial shell only (fresh shells still HIT), forcing a fresh navigation render — correct.

  4. app-browser-entry.ts provisional zero-body visited entrycreateCachedRscResponseSnapshot(navResponse, new ArrayBuffer(0), …) only reads headers/the passed buffer, never response.body, so it does not disturb the response; extractRscCompletionMetadata on an empty buffer short-circuits (indexOf === -1). Published with seedPrefetchCache=false and elements-only, then replaced with the buffered snapshot. Safe, and it closes the back-navigation window.

  5. link.tsx traversal-safetydestinationIsCurrent() recomputes against live window.location.href at every post-await boundary; the new hasPartialPrerenderForRoute await and RSC-URL hashing are both followed by epoch + current-URL re-checks; the actual fetch is gated by scheduleGuardedLinkPrefetchFetch → prefetchWasSuperseded(). The pendingLinkPrefetchFetches Set drains via onRelease in all three queue lifecycle paths (Set-delete-during-iteration is spec-safe).

  6. navigation.ts router.prefetch — the added hasPartialPrerenderForRoute await sets an independent header (VINEXT_RSC_RENDER_MODE_HEADER) from the reusable-prefetch block; ordering is irrelevant, and setup.cancelled is still re-checked after all awaits before the fetch.

  7. Cache-key/render-mode parity — the prefetch-loading-shell RSC variant keys identically at runtime (__isrRscKey(pathname, null, renderMode)), in the Node seed path, and in the Cloudflare KV populate path (getRscRenderModeCacheVariantrsc:<variant>). writePrerenderIndex records partialPrerenderPaths for rendered && app && prefetch-loading-shell && !fallback, and the same predicate seeds addPartialPrerenderPath, so /__vinext/prefetch-policy advertises exactly the captured routes.

  8. app-ssr-entry.ts renderer selection, slot.tsx isSlotId guard, and bfcache-identity.ts "segment" (collision-free 3-tuple) all sound; the primary-segment path is gated behind __NEXT_CACHE_COMPONENTS === "true" via includePrimarySegment (default suites green).

Non-blocking observations (all consistent with prior runs)

  1. (Pre-existing, build-prerender-only) The warmup mutate (L1069) resets 8 observation slices but not the remaining reference-typed slices the runWithUnifiedStateMutation docstring enumeratesafterContext, pendingSetCookies, serverInsertedHTMLCallbacks, pendingRevalidatedTags, pendingRevalidations, ssrHeadChildren. I confirmed these APIs write in-place (ctx.afterContext.callbacks.push, pendingSetCookies.push, pendingRevalidatedTags.add), so a page/layout that calls after()/cookies().set()/revalidateTag()/inserts server HTML during warmup would bleed into the parent via the shared reference (e.g. double-run after() in the final render). It is strictly gated to process.env.VINEXT_PRERENDER === "1" (L1064) — build-time only, never a live request — and the removed discardAppPageRenderState() had the same gap, so this is not a regression. Since warm() now runs in a scoped child, adding those resets to the mutate would match the documented contract and cost nothing. Good follow-up candidate.

  2. (Intentional) onError now swallows all errors once pprFallbackShellSignal.aborted (shouldIgnorePprFallbackShellRenderError), in both app-ssr-entry.ts and app-page-render.ts. Matches Next.js's "post-abort errors are cascade noise" prerender semantics; now unit-tested.

  3. (Cosmetic) /__vinext/prefetch-policy hard-codes the path literal in both the handler (app-rsc-handler.ts:610) and the client fetch (app-route-prefetch-policy.ts), whereas the sibling VINEXT_PRERENDER_STATIC_PARAMS_PATH endpoints use a shared exported constant. A shared constant would prevent the two ends from drifting.

  4. (Naming clarity) notifyLinkNavigationStartAndCancelPrefetchSetup only bumps the epoch — it does not abort in-flight fetches; only the new cancelLinkPrefetchSetups (bound to navigate/traverse + popstate) both bumps the epoch and calls cancelAppPrefetchFetch. The asymmetry looks intentional but the name overstates it relative to the new sibling.

Overall: well-structured, matches the intended Next.js staleness semantics, the E2E ports and new unit tests (ppr-fallback-shell.test.ts + the "omits short-lived use cache entries" shim test cover both cache-runtime reuse branches) back it up, and the build→seed→runtime cache-key path lines up. Ship it.

@ask-bonk

ask-bonk Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2860.

Review outcome: LGTM (no blocking issues)

I did an independent pass at 58cfe92 (post-merge with main), re-deriving each substantial source change rather than relying on the five prior /bigbonk runs. Ran the targeted suites (1461 + 500 passed) and traced the trickiest paths line-by-line.

Verified correct:

  • cache-runtime.ts warmup reuse — I specifically checked the key-derivation consistency between the warmup write (resultCacheKey-based) and the final-phase read (early pprCacheKey): they reconcile because addKnownRootParamNames populates the id→names map during warmup, so remembered results are reachable. Dynamic holes suspend without publishing; non-dynamic propagates tags + recordCacheHitLife (which does the enclosing-scope lifeConfigs.push).
  • Provisional zero-body visited entry — confirmed createCachedRscResponseSnapshot never touches response.body, and extractRscCompletionMetadata short-circuits on an empty buffer, so publishing it before the buffer settles is safe.
  • Cache-key/render-mode parity across build→seed→KV→runtime for the prefetch-loading-shell variant.
  • link.tsx traversal-safety, navigation.ts prefetch header ordering, app-ssr-entry.ts renderer selection, slot.tsx/bfcache-identity.ts segment descriptor.

Non-blocking findings (all consistent with prior runs):

  1. The build-prerender-only warmup mutate still omits afterContext/pendingSetCookies/serverInsertedHTMLCallbacks/pendingRevalidatedTags/pendingRevalidations/ssrHeadChildren that the ALS contract enumerates. I confirmed those APIs write in-place, so a warmup render reaching them would bleed into the parent — but it's gated to VINEXT_PRERENDER === "1" and the removed discardAppPageRenderState() had the same gap (not a regression). Good follow-up.
  2. Intentional onError widening once signal.aborted (matches Next.js prerender-abort cascade; unit-tested).
  3. Cosmetic: /__vinext/prefetch-policy hard-codes the path literal in two places.
  4. Naming: notifyLinkNavigationStartAndCancelPrefetchSetup only bumps the epoch (doesn't abort).

The PR is well-structured, well-tested (both cache-runtime reuse branches now have direct unit coverage), and matches Next.js staleness semantics.

github run

# Conflicts:
#	packages/vinext/src/build/prerender.ts
#	packages/vinext/src/server/app-rsc-handler.ts
#	packages/vinext/src/server/headers.ts
#	packages/vinext/src/server/prerender-manifest.ts
#	packages/vinext/src/server/seed-cache.ts
#	packages/vinext/src/shims/link.tsx
#	packages/vinext/src/shims/navigation.ts
#	tests/prefetch-cache.test.ts
#	tests/seed-cache.test.ts
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