Skip to content

fix(app-router): preserve active slots across segment-cache refresh - #2873

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

fix(app-router): preserve active slots across segment-cache refresh#2873
james-elicx wants to merge 2 commits into
mainfrom
codex/fix-segment-cache-refresh-current

Conversation

@james-elicx

Copy link
Copy Markdown
Member

Summary

  • preserve mounted parallel-slot active-route identity across refresh and server-action requests
  • include mounted-slot variants in RSC cache-busting without making them ISR-cacheable
  • restore active slots while rebuilding refreshed App Router trees

This is the current-main replacement for conflicted PR #2461.

Validation

  • NEXT_TEST_CONCURRENCY=1 ./scripts/run-targeted-nextjs-e2e.sh test/e2e/app-dir/segment-cache/refresh/segment-cache-refresh.test.ts — 3 passed
  • focused Vitest suites — 628 passed
  • vp check on all 23 changed files
  • independent review: no actionable 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@2873
npm i https://pkg.pr.new/create-vinext-app@2873
npm i https://pkg.pr.new/@vinext/types@2873
npm i https://pkg.pr.new/vinext@2873

commit: 078e995

@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 commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Review: preserve active slots across segment-cache refresh

I reviewed the full diff (23 files, +874/-26), ran the changed test suites, and ran vp check on the core files. No blocking issues found. The change is well-factored, follows the repo's layering guidance (real logic in server/*, thin wiring in entries/*), and has strong test coverage.

What I verified

Security / untrusted-header hardening (good)

  • X-Vinext-Mounted-Slot-Active-Routes is client-controllable, and the code treats it defensively:
    • app-mounted-slot-active-routes-header.ts enforces MAX_RAW_HEADER_LENGTH/MAX_PAIR_LENGTH, validates each pair as a real slot:/route: id via AppElementsWire, and produces a deterministic, bounded, sorted output.
    • The generated resolveRouteById rejects anything that isn't a parseable non-intercepted route (parsed.kind !== "route" || parsed.interceptionContext !== null), requires the slot to actually exist on the matched route with a real __loadPage, and bails if !slot.page. A forged header can at most trigger a bounded, gated route lookup — it can't render an arbitrary module.
    • In buildSlotOverrides, a matched interception (overrides[slotKey]?.pageModule) is never overwritten by a client restoration hint (line 801). Correct — server-owned route state wins.

Cache correctness (good)

  • The new header participates in RSC cache-busting (VINEXT_RSC_VARY_HEADER, createCacheBustingInput) but is intentionally excluded from the ISR/data-cache key path — I confirmed mountedSlotActiveRoutesHeader is not threaded into isr-cache.ts/app-page-cache.ts, and mounted-slot variants remain non-cacheable in app-page-cache-finalizer.ts. Matches the PR claim.
  • Backward compatibility is preserved: computePreActiveRouteHeader* hashes let clients that don't send the new header still validate cache-busting (both sha256 and legacy fnv1a64), and they keep the render-mode header included to match computeRscCacheBustingSearchParam.

Navigation planner reuse law (correct and conservative)

  • resolveDefaultOrUnmatchedSlotPersistenceForLayouts now allows an active target slot to reuse previous content only when both current and target snapshots carry the exact same activeRouteId. Any mismatch (undefined/null id, non-active current, differing id) falls through to "don't preserve" — a safe, conservative default that can only miss a preservation opportunity, never cause incorrect reuse.
  • resolveRouteManifestSlotBindings labels active manifest bindings with snapshot.routeId. For the common non-interception case this aligns with the payload's encodeRouteId(routePath, null). Where it could diverge (a preserved slot from a different route path), the exact-match gate makes it a no-op rather than a hazard.

Wiring invariant (holds)

  • resolveAppPageSlotBindingState returns "active" when override.activeRouteId is set. This is safe because buildSlotOverrides only sets activeRouteId after if (!activeSlot?.slot.page) continue; and always co-sets a pageModule — so an activeRouteId override never occurs without a renderable page.

dynamicOnly revalidation split (deliberate parity fix)

  • Splitting clearClientNavigationCaches into a dynamic-only variant means dynamicOnly server-action revalidations no longer purge the static prefetch cache (clearPrefetchState), while staticAndDynamic still clears everything. shouldClearClientNavigationCachesForServerActionResult now returns false for dynamicOnly. This is consistent with keeping still-mounted dynamic slots intact and is covered by the updated app-browser-entry.test.ts expectation.

Test results

  • tests/app-mounted-slot-active-routes-header.test.ts, app-page-element-builder.test.ts, app-page-route-wiring.test.ts, navigation-planner.test.ts — 198 passed
  • tests/app-browser-entry.test.ts, app-browser-server-action-client.test.ts, app-server-action-execution.test.ts, app-rsc-cache-busting.test.ts, entry-templates.test.ts — 430 passed
  • vp check on the 5 core files — clean (format, lint, types)

Minor / non-blocking observations

  • resolveRouteById is generated (codegen glue) and appropriately thin — it only wires route matching and delegates the real restoration logic to buildSlotOverrides in server/*. Good adherence to the entry-thinness guideline.
  • The header-length truncation in serializePairs deterministically retains a bounded prefix (verified by the reverse-input test), so overflow degrades gracefully rather than dropping the whole header.

LGTM.

@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared 078e995 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 135.5 KB ⚫ +0.3%
Client entry size (gzip) vinext 122.7 KB 123.0 KB ⚫ +0.3%
Dev server cold start vinext 2.95 s 2.96 s ⚫ +0.4%
Production build time vinext 3.08 s 3.12 s ⚫ +1.2%
RSC entry closure size (gzip) vinext 114.2 KB 115.4 KB ⚫ +1.0%
Server bundle size (gzip) vinext 193.0 KB 194.2 KB ⚫ +0.6%

View detailed results and traces

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

@ask-bonk

ask-bonk Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2873.

Summary

I reviewed the full diff (23 files, +874/-26), ran the affected test suites (628 tests passing), and ran vp check on the core files (clean). No blocking issues found.

Key findings:

  • Untrusted-header hardening is solid. The new client-controllable X-Vinext-Mounted-Slot-Active-Routes header is length-bounded, id-validated via AppElementsWire, and the generated resolveRouteById rejects intercepted/non-existent routes. A forged header can't render an arbitrary module, and a server-owned interception is never overwritten by a client restoration hint.
  • Cache correctness matches the PR claim. The header is cache-busted (VARY + hash input) but intentionally excluded from the ISR/data-cache key path, so mounted-slot variants stay non-cacheable. Backward-compat hashes preserve validation for older clients.
  • The navigation-planner reuse law is conservative and correct. Active target slots reuse prior content only on exact activeRouteId match; any mismatch safely falls through to "don't preserve."
  • The wiring invariant holdsactiveRouteId overrides always co-set a renderable pageModule.
  • The dynamicOnly revalidation split correctly stops purging static prefetch caches while keeping full clearing for staticAndDynamic, consistent with preserving still-mounted dynamic slots.

Verdict: LGTM.

github run

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