Skip to content

fix(app-router): preserve recent segment state with Activity BFCache#1739

Open
NathanDrake2406 wants to merge 26 commits into
cloudflare:mainfrom
NathanDrake2406:nathan/issue-1366-bfcache-restore
Open

fix(app-router): preserve recent segment state with Activity BFCache#1739
NathanDrake2406 wants to merge 26 commits into
cloudflare:mainfrom
NathanDrake2406:nathan/issue-1366-bfcache-restore

Conversation

@NathanDrake2406
Copy link
Copy Markdown
Contributor

@NathanDrake2406 NathanDrake2406 commented Jun 4, 2026

Overview

Field Details
Goal Match Next.js App Router BFCache semantics for recent segment state retention.
Core change Add segment state-key maps for slots and keep recent entries mounted with React.Activity when cacheComponents is enabled.
Main boundary Keep render state identity separate from useRouter().bfcacheId history-entry identity.
Primary files packages/vinext/src/shims/slot.tsx, packages/vinext/src/server/app-browser-state.ts, browser and SSR app entries.
Expected impact Fresh dynamic segment navigations no longer leak client state, while browser/link returns restore the recent segment entries Next.js preserves.

Why

App Router correctness needs two different identities to stay separate: the render tree state key for a segment, and the history-entry bfcacheId exposed to userland. Next.js keys retained rendered entries by segment identity/pathname, then uses a separate BFCache state manager to bound inactive entries. Vinext already had the history identity, but not the segment state cache, so fresh navigations could reuse the wrong React tree.

Area Principle / invariant What this PR changes
Segment rendering A dynamic segment value such as /page/1 and /page/2 must have distinct render state. Adds createBfcacheSegmentStateKeyMap() and provides it to slot boundaries.
Cache Components Next.js keeps up to three recent inactive segment entries when cacheComponents is enabled. Renders retained entries through React.Activity with visible/hidden modes and a bounded entry list.
useRouter().bfcacheId Public history-entry identity is not the same as the render state key. Keeps existing BfcacheIdMapContext plumbing and layers state keys separately.
SSR hydration Initial markup and client hydration must agree on keyed slot wrappers. Provides the same state-key map during SSR and browser rendering.

What changed

Scenario Before After
Link from /page/1 to /page/2 Page 2 could inherit page 1 client state. Page 2 gets a distinct keyed tree and starts fresh.
Return to a recent segment Prior state was not retained for the upstream BFCache cases. Recent entries are kept mounted and restored.
More than three cached entries with cacheComponents No bounded Activity cache existed. The three most recent segment entries are retained, matching the upstream suite.
Existing bfcacheId behavior History IDs were already minted and restored. Existing identity behavior remains covered by the adjacent e2e suite.
Maintainer Review Path
  1. packages/vinext/src/server/app-browser-state.ts: verify state keys reuse the existing segment identity helper and pathname metadata without changing history bfcacheId generation.
  2. packages/vinext/src/shims/slot.tsx: verify the entry ordering, bounded retention, React.Activity rendering, and the unkeyed non-cacheComponents active-segment path.
  3. packages/vinext/src/server/app-browser-entry.ts and packages/vinext/src/server/app-ssr-entry.ts: verify provider placement keeps SSR and hydration aligned.
  4. packages/vinext/src/index.ts: verify the Next.js cacheComponents compile-time flag is exposed consistently.
  5. tests/e2e/app-router/nextjs-compat/back-forward-cache.spec.ts: compare the ported regression scenarios against the upstream test.
Validation
  • vp test run --project unit tests/shims.test.ts --testNamePattern "slot BFCache entry state"
  • vp test run --project unit tests/app-browser-entry.test.ts --testNamePattern "app browser entry bfcacheId helpers"
  • PLAYWRIGHT_PROJECT=app-router pnpm run test:e2e -- tests/e2e/app-router/nextjs-compat/back-forward-cache.spec.ts --retries=0
  • PLAYWRIGHT_PROJECT=app-router pnpm run test:e2e -- tests/e2e/app-router/nextjs-compat/use-router-bfcache-id.spec.ts --retries=0
  • vp check
  • vp env exec --node 24 ./scripts/run-nextjs-deploy-suite.sh /Users/nathan/Projects/vinext/.refs/nextjs-v16.2.6 --retries 0 -c 1 --debug test/e2e/app-dir/back-forward-cache/back-forward-cache.test.ts

The final upstream deploy-suite run passed 4 tests with the same 1 upstream skipped case.

Risk / Compatibility
  • Public API: no new public Next.js API surface.
  • Runtime: cacheComponents: true now retains hidden Activity trees, which intentionally creates duplicate hidden DOM for inactive entries. Existing e2e locators that target this area use visible filtering.
  • Router compatibility: this follows Next.js behavior for the referenced BFCache tests and keeps useRouter().bfcacheId separate from segment state keys.
  • Memory: retention is bounded to three entries under cacheComponents; without cacheComponents, the active segment renders in place unkeyed and no inactive Activity entries are retained.
Non-goals
  • This does not implement the skipped upstream search-param BFCache case.
  • This does not change the existing useRouter().bfcacheId userland contract beyond preserving it alongside Activity retention.

References

Reference Why it matters
cloudflare/vinext#1366 Tracks the reported App Router BFCache parity gap.
Next.js back-forward-cache test Defines the upstream behavior ported here.
Next.js BFCache state manager Shows the three-entry retained segment cache when Cache Components are enabled.
Next.js layout router Shows segment stateKey creation and React.Activity visible/hidden rendering.

Closes #1366

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Jun 4, 2026

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@1739
npm i https://pkg.pr.new/vinext@1739

commit: 57176b3

@NathanDrake2406 NathanDrake2406 marked this pull request as ready for review June 4, 2026 16:56
@james-elicx
Copy link
Copy Markdown
Member

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

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

Reviewed the BFCache segment-state work. The core design is sound: separating the render-tree state key (createBfcacheSegmentStateKeyMap / pathname-derived identity) from the userland useRouter().bfcacheId history identity is the right boundary, and it matches the upstream Next.js split between layout-router stateKey and bfcache-state-manager. The updateBfcacheSlotEntries LRU/eviction logic is clean and well unit-tested, and the e2e fixture coverage (browser back/forward, link return, 3-entry bound, nested layout+page) maps closely to the upstream back-forward-cache.test.ts.

I verified a few things that are NOT problems and want to record them so the reviewer doesn't have to re-derive:

  • SSR/hydration convergence is safe. During SSR and the first client render, the useState initializer already produces the correct single-entry order, so haveSameBfcacheSlotEntryOrder returns true and the render-phase setEntries does not fire. No SSR setState warning, no hydration drift.
  • Define parity across environments. process.env.__NEXT_CACHE_COMPONENTS is added to the top-level define map (index.ts:1806), so it is inlined identically in client, SSR, and RSC bundles. There's no risk of one environment wrapping in React.Activity while another doesn't.
  • React.Activity is a stable export in the pinned React 19.2.7, so the bare React.Activity usage is fine.
  • The latestEntriesByStateKey ref does not leak — it is pruned to the current entry keys whenever the order changes, and otherwise only overwrites the active key in place.

Comments below are minor (a doc inconsistency and one behavioral note for reviewer awareness). Nothing blocking from my read.

Comment thread packages/vinext/src/index.ts Outdated
// See: https://github.com/vercel/next.js/pull/93997
defines["process.env.__NEXT_APP_SHELLS"] = JSON.stringify(nextConfig.appShells);
// Cache Components — Next.js gates segment Activity BFCache retention
// on this build-time flag. The active segment is still keyed without
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.

Minor doc inconsistency. This comment says "The active segment is still keyed without the flag", but the corresponding logic in slot.tsx (the !process.env.__NEXT_CACHE_COMPONENTS branch) deliberately renders the active entry unkeyed — the long comment there explains that keying it by the pathname-derived stateKey would remount shared layouts on every URL change. Suggest aligning the wording so the two comments don't contradict each other:

Suggested change
// on this build-time flag. The active segment is still keyed without
// Cache Components — Next.js gates segment Activity BFCache retention
// on this build-time flag. Without the flag the active segment renders
// in place (unkeyed); enabling it turns on the three-entry inactive
// Activity tree cache.
// See: packages/next/src/client/components/layout-router.tsx

Comment thread packages/vinext/src/shims/slot.tsx Outdated

if (!haveSameBfcacheSlotEntryOrder(entries, orderedEntries)) {
pruneLatestBfcacheSlotEntries(latestEntriesByStateKey.current, orderedEntries);
setEntries(orderedEntries);
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.

Behavioral note for reviewers (not a defect): before this PR BfcacheSlotBoundary was a trivial provider. It now performs a render-phase setEntries on any navigation where activeStateKey changes — and this runs for every App Router app that has BfcacheIdMapContext, not just cacheComponents apps. The update converges (the next render produces the same key order, so no second setEntries), and the no-cacheComponents path still renders unkeyed so it shouldn't remount. Worth a quick sanity pass that the existing use-router-bfcache-id / app-router e2e projects stay green, since this widens the blast radius beyond the new fixture.

Comment thread packages/vinext/src/shims/slot.tsx Outdated
segmentId: id,
stateKey: activeStateKey,
stateKeyMap,
});
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.

This ref write happens on every render before the setEntries/order check. For inactive (hidden) entries the boundary only re-renders with the active segment's content, so their captured content/elements stay frozen at last-visit values — which is the intended BFCache snapshot behavior. No change requested; just confirming the snapshot semantics are deliberate, since it's the crux of why returning to a cached segment restores prior client state.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Jun 4, 2026

Review submitted successfully. Here's a summary of my review of PR #1739.

Review summary

The PR implements App Router BFCache segment-state retention with React.Activity under cacheComponents, separating render-tree state keys from useRouter().bfcacheId history identity.

Verdict: Sound, well-tested, nothing blocking.

What's good

  • Correct conceptual boundary: pathname-derived segment state key vs. userland history bfcacheId, matching the upstream Next.js layout-router stateKey / bfcache-state-manager split.
  • updateBfcacheSlotEntries LRU/eviction is clean and unit-tested; e2e fixtures closely mirror upstream back-forward-cache.test.ts.

Things I verified are safe (recorded in the review)

  • SSR/hydration convergence: the useState initializer produces correct initial order, so render-phase setEntries doesn't fire during SSR or first client render — no setState warning or hydration drift.
  • Define parity: __NEXT_CACHE_COMPONENTS is in the top-level define map, inlined identically across client/SSR/RSC, so no environment mismatch on the Activity path.
  • React.Activity is a stable export in React 19.2.7.
  • latestEntriesByStateKey ref is bounded (pruned on order change), no leak.

Comments posted (all minor)

  1. index.ts doc inconsistency — comment says active segment "is still keyed without the flag", but slot.tsx deliberately renders it unkeyed. Posted a suggestion to align the wording.
  2. Behavioral note — the render-phase setEntries now runs for all app-router apps (not just cacheComponents), widening the blast radius; flagged for a sanity check that existing e2e projects stay green.
  3. Snapshot semantics confirmation — noted that frozen inactive-entry content is the intended BFCache behavior.

No --admin actions or file edits were made; this was a review-only pass.

github run

@james-elicx
Copy link
Copy Markdown
Member

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

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

Reviewed the BFCache segment-state retention work. The core design is sound and matches the upstream Next.js split: pathname-derived render-tree state keys (createBfcacheSegmentStateKeyMap) are kept separate from the userland useRouter().bfcacheId history identity, mirroring Next.js layout-router stateKey vs bfcache-state-manager. The updateBfcacheSlotEntryOrder LRU/eviction is clean and well unit-tested, and the e2e fixtures map closely to the upstream back-forward-cache.test.ts.

Things I verified are NOT problems:

  • SSR/hydration convergence is safe. During SSR and the first client render the useState initializer already yields the single-entry order, so haveSameBfcacheSlotEntryOrder is true and the render-phase setEntryOrder never fires — no SSR setState warning, no hydration drift. Activity wraps the single entry in visible mode, matching the client first render.
  • renderEntries[0] is always defined in the non-cacheComponents path: the active key is set in the snapshot map every render and is always first in renderOrder.
  • React.Activity is a stable export in the pinned React 19.2.7 (typeof React.Activity === 'symbol').
  • define parity: __NEXT_CACHE_COMPONENTS is in the top-level define map, inlined identically across client/SSR/RSC, so no environment can disagree on the Activity path.

Nothing blocking. A couple of notes below for reviewer awareness, the main one being that this widens the per-render work for every app-router app, not just cacheComponents apps.

Comment thread packages/vinext/src/shims/slot.tsx Outdated
// state that survives a normal navigation. Reset for genuinely fresh entries is
// driven by userland bfcacheId keying, not by remounting the slot subtree, so
// the active entry renders unkeyed here.
if (!process.env.__NEXT_CACHE_COMPONENTS) {
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.

Behavioral-widening note (not a defect, worth a sanity check). Before this PR the non-cacheComponents path was just <SegmentContext.Provider value={id}>{content}</SegmentContext.Provider>. Now every app-router app that has BfcacheIdMapContext runs useBfcacheSlotEntries (with a render-phase setEntryOrder on each navigation where activeStateKey changes) and re-wraps content in three redundant providers (BfcacheStateKeyMapContext, ElementsContext, SegmentContext) whose fallback values equal the live context values.

The update converges (next render produces the same key order, so no second setEntryOrder) and the path renders unkeyed so it shouldn't remount, but this is extra per-render work and an extra setState on the hot common path for all app-router apps. Please confirm the existing app-router and use-router-bfcache-id e2e projects stay green — the new fixture only exercises the cacheComponents: true branch, so the non-flag regression surface is not directly covered by the added tests.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Valid. I split the Activity-retention path into BfcacheActivitySlotBoundary, so non-cacheComponents apps now return the baseline <SegmentContext.Provider value={id}>{content}</SegmentContext.Provider> path and no longer run useBfcacheSlotEntries, render-phase setEntryOrder, or the extra provider stack. Verified with vp check, vp test run tests/shims.test.ts, PLAYWRIGHT_PROJECT=app-router pnpm exec playwright test tests/e2e/app-router/nextjs-compat/use-router-bfcache-id.spec.ts --retries=0 (11/11), and PLAYWRIGHT_PROJECT=app-router-bfcache pnpm exec playwright test tests/e2e/app-router-bfcache/back-forward-cache.spec.ts --retries=0 (6/6).

Comment thread packages/vinext/src/index.ts Outdated
// in place (unkeyed); enabling it turns on the three-entry inactive
// Activity tree cache.
// See: packages/next/src/client/components/layout-router.tsx
defines["process.env.__NEXT_CACHE_COMPONENTS"] = JSON.stringify(nextConfig.cacheComponents);
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.

nextConfig.cacheComponents is optional, so when unset JSON.stringify(undefined) returns the JS value undefined (not a string), and defines["..."] is set to undefined. This matches the adjacent __NEXT_APP_SHELLS pattern so it's consistent, and Vite inlines the falsy/undefined replacement fine. Still, an explicit boolean reads more clearly and avoids relying on JSON.stringify(undefined) semantics:

Suggested change
defines["process.env.__NEXT_CACHE_COMPONENTS"] = JSON.stringify(nextConfig.cacheComponents);
defines["process.env.__NEXT_CACHE_COMPONENTS"] = JSON.stringify(nextConfig.cacheComponents ?? false);

Non-blocking — only worth it if you want the define map to always carry a concrete value.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Valid, with one extra guard needed. I applied the explicit ?? false define, but also changed the consumer to gate on the boolean define value via isCacheComponentsEnabled(). That avoids the unsafe truthiness shape where a concrete false define could accidentally behave as enabled, and keeps the non-flag path disabled while preserving the cacheComponents: true Activity path. Verified by rebuilding with vp run vinext#build and rerunning the app-router/app-router-bfcache e2e targets.

const snapshotsByStateKey = React.useRef(new Map<string, BfcacheSlotEntry>());
const [entryOrder, setEntryOrder] = React.useState<string[]>(() => [activeEntry.stateKey]);

snapshotsByStateKey.current.set(activeEntry.stateKey, activeEntry);
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.

Confirming the snapshot semantics are deliberate (no change requested): the ref write captures the active entry's content/elements/stateKeyMap each render, and hidden (inactive) entries keep their last-visit snapshot frozen because the boundary only re-renders with the active segment's content. That frozen snapshot is exactly what makes returning to a cached segment restore prior client state, so this is the crux of the feature working. Pruning to nextOrder on order change keeps the ref bounded to the retained set.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Jun 5, 2026

Review submitted successfully. Here's a summary of my review of PR #1739.

Review summary

The PR implements App Router BFCache segment-state retention using React.Activity under cacheComponents, separating render-tree state keys from useRouter().bfcacheId history identity.

Verdict: Sound design, well-tested, nothing blocking.

What's good

  • Correct conceptual boundary: pathname-derived segment state key (createBfcacheSegmentStateKeyMap) vs. userland history bfcacheId, matching the upstream Next.js layout-router stateKey / bfcache-state-manager split.
  • updateBfcacheSlotEntryOrder LRU/eviction is clean and thoroughly unit-tested; e2e fixtures closely mirror upstream back-forward-cache.test.ts (browser back/forward, link return, 3-entry bound, nested layout+page, fresh-id separation).

Things I verified are safe (recorded in the review)

  • SSR/hydration convergence: the useState initializer yields the correct single-entry order, so render-phase setEntryOrder never fires during SSR or first client render — no setState warning or hydration drift.
  • renderEntries[0] always defined in the non-cacheComponents path.
  • Define parity: __NEXT_CACHE_COMPONENTS is in the top-level define map, inlined identically across client/SSR/RSC.
  • React.Activity is a stable export in React 19.2.7.

Comments posted (all minor / non-blocking)

  1. Behavioral widening (slot.tsx:275) — the boundary now runs useBfcacheSlotEntries + a render-phase setEntryOrder and three redundant providers for every app-router app, not just cacheComponents apps. The new fixture only exercises the flag-on branch, so I asked for a sanity check that the existing app-router / use-router-bfcache-id projects stay green.
  2. define clarity (index.ts:1309) — JSON.stringify(undefined) yields undefined; suggested ?? false for an explicit value (consistent-with-appShells pattern, so non-blocking).
  3. Snapshot semantics (slot.tsx:232) — confirmed the frozen inactive-entry content is the intended BFCache behavior.

No file edits or git write operations were performed; review-only pass on PR #1739.

github run

@james-elicx
Copy link
Copy Markdown
Member

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

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

Reviewed the App Router BFCache segment-state retention work. The design is sound and faithful to Next.js: createBfcacheSegmentStateKeyMap derives a pathname-based render-tree state key (mirroring createRouterCacheKey(activeSegment, true) in layout-router.tsx), kept separate from the userland useRouter().bfcacheId history identity, and updateBfcacheSlotEntryOrder reimplements the most-recently-active LRU ordering from useRouterBFCache (bfcache-state-manager.ts) including the move-to-front-without-eviction fast path. The React.Activity visible/hidden wrapping matches upstream OuterLayoutRouter.

I verified locally:

  • vp test run tests/shims.test.ts -t "slot BFCache entry ordering" (5 pass) and the new app-browser-entry state-key test pass.
  • vp check is clean on all four changed source files (format, lint, types).
  • React.Activity is a stable export in the pinned React 19.2.7.
  • The useState initializer converges to a single-entry order, so the render-phase setEntryOrder never fires during SSR or first client render (no setState warning / no hydration drift), and isCacheComponentsEnabled() defaults safely to false when the define is absent.

Nothing blocking. A few non-blocking observations below for awareness.

const snapshotsByStateKey = React.useRef(new Map<string, BfcacheSlotEntry>());
const [entryOrder, setEntryOrder] = React.useState<string[]>(() => [activeEntry.stateKey]);

snapshotsByStateKey.current.set(activeEntry.stateKey, activeEntry);
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.

Non-blocking, concurrency note. This mutates a ref Map during render (and pruneBfcacheSlotEntrySnapshots mutates it again on order change), which diverges from upstream useRouterBFCache, which is pure copy-on-write over React state and never touches a ref during render. Under StrictMode dev double-invocation or a concurrent render that React discards before commit, this set runs for a render that may be thrown away.

It looks self-healing in practice: the active-key snapshot is overwritten every render so the last committed render wins, and the map is re-pruned to the retained order on the next order change, so it stays bounded. Worth a one-line comment documenting that the ref mutation is intentionally tolerant of discarded/StrictMode renders, since a future reader may otherwise flag it as an impure-render violation.

{
value: createBfcacheSegmentStateKeyMap({
elements,
pathname: navContext?.pathname ?? "/",
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.

Non-blocking, hydration-parity note. SSR derives the state-key map from navContext?.pathname ?? "/", while the client (app-browser-entry.ts) derives it from treeState.navigationSnapshot.pathname (ultimately window.location.href). Page state keys are ${id}@${pathname}, and they become the React.Activity key, so if these two pathname sources ever diverge for the same request (trailing-slash normalization, basePath stripping, encoded segments), the SSR'd Activity key would not match the hydration key.

The tested routes align so this is fine today, but it relies on the two pathname derivations staying byte-identical. Might be worth asserting/normalizing both through the same helper, or at least a comment noting the invariant.

const SegmentContext = BfcacheSegmentIdContext;
const elements = React.useContext(ElementsContext);
const stateKeyMap = React.useContext(BfcacheStateKeyMapContext);
const activeStateKey = stateKeyMap[id];
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.

Non-blocking. activeStateKey is read as stateKeyMap[id] and used as a bare object index. When id is not present the value is undefined and the === undefined guard below handles it correctly, but consider documenting that the empty-map default (EMPTY_BFCACHE_STATE_KEYS) plus the undefined guard is what keeps apps that never provide the context on the original unkeyed path. It's correct, just implicit.

</div>
<div>
<input id={`uncontrolled-input-${n}`} type="text" />
</div>
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.

The fixture ships search-param affordances (?param=true links in the layout, uncontrolled-input-*, has-search-param-*) ported from upstream, but the new back-forward-cache.spec.ts never exercises them — the PR lists the search-param BFCache case as a non-goal. That's fine, but the unused fixture surface may read as dead code to a future maintainer. Either add a short comment that these are reserved for the not-yet-ported search-param case, or trim them until that test lands.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Jun 5, 2026

Review submitted successfully. Here's a summary of my review of PR #1739.

Review summary

The PR adds App Router BFCache segment-state retention using React.Activity under cacheComponents, keeping render-tree state keys separate from useRouter().bfcacheId history identity.

Verdict: Sound, faithful to Next.js, well-tested — nothing blocking.

What I verified

  • Design parity: createBfcacheSegmentStateKeyMap mirrors createRouterCacheKey(activeSegment, true) from upstream layout-router.tsx, and updateBfcacheSlotEntryOrder reimplements the LRU ordering from useRouterBFCache (bfcache-state-manager.ts) — including the move-to-front-without-eviction fast path. The React.Activity visible/hidden wrapping matches OuterLayoutRouter.
  • Tests pass locally: the new slot BFCache entry ordering unit tests (5 pass) and the app-browser-entry state-key test.
  • vp check clean on all four changed source files.
  • SSR/hydration safety: the useState initializer converges to a single-entry order, so the render-phase setEntryOrder never fires during SSR/first client render; isCacheComponentsEnabled() defaults safely to false.
  • The pre-existing innerTree else-branch quirk and the define parity were confirmed unchanged/correct.

Comments posted (all non-blocking)

  1. slot.tsx:238 — ref Map mutation during render diverges from upstream's pure copy-on-write; appears self-healing but warrants a comment documenting StrictMode/discarded-render tolerance.
  2. app-ssr-entry.ts:327 — SSR pathname (navContext.pathname) vs client pathname (navigationSnapshot.pathname / window.location.href) feed the React.Activity key; flagged the hydration-parity invariant if they ever diverge.
  3. slot.tsx:303 — the empty-map default + undefined guard keeping no-context apps on the unkeyed path is correct but implicit.
  4. stateful-client-component.tsx:22 — unused search-param fixture affordances (non-goal); suggest a comment or trimming.

Review-only pass; no file edits or git write operations performed.

github run

@james-elicx
Copy link
Copy Markdown
Member

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

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

Reviewed the BFCache segment-state retention work after the latest revision. The design is sound and faithful to Next.js, the prior review feedback has been addressed, and I verified the key behaviors locally.

What I verified

  • vp check is clean on all four changed source files (format, lint, types).
  • Unit tests pass: tests/shims.test.ts -t "slot BFCache entry ordering" (5 pass) and the new app-browser-entry page-segment-state-key test (1 pass).
  • React.Activity is a stable export in the pinned React 19.2.7 (typeof React.Activity === "symbol").
  • Design parity: createBfcacheSegmentStateKeyMap derives a pathname-based render-tree state key, and updateBfcacheSlotEntryOrder reimplements the move-to-front LRU (incl. the no-eviction fast path) from bfcache-state-manager. React.Activity visible/hidden wrapping matches OuterLayoutRouter.
  • The identity split is correct and well-tested: the BfcacheIdMapContext.Provider sits above BfcacheActivitySlotBoundary in both BrowserRoot and handleSsr, so returning to a cached segment preserves useState (via Activity) while the leaf still re-renders a fresh bfcacheId from the context above the Activity boundary. The keeps Activity state separate from fresh bfcacheId identity e2e covers exactly this.
  • Provider nesting matches between SSR and browser (BfcacheIdMap > StateKeyMap > routeTree), so hydration keys line up.
  • Behavioral widening from the earlier review is resolved: splitting BfcacheActivitySlotBoundary out means non-cacheComponents apps return the baseline <SegmentContext.Provider value={id}>{content}</SegmentContext.Provider> path with no useBfcacheSlotEntries / render-phase setEntryOrder / extra provider stack. The remaining per-navigation cost for all app-router apps is the createBfcacheSegmentStateKeyMap useMemo in BrowserRoot, which is bounded.
  • define is consistent across environments (top-level define map at index.ts:1837) and the ?? false + isCacheComponentsEnabled() string-compare guard is deliberate and safe.

Notes (all non-blocking)

Nothing blocking. A couple of minor observations inline.

// Activity tree cache.
// See: packages/next/src/client/components/layout-router.tsx
defines["process.env.__NEXT_CACHE_COMPONENTS"] = JSON.stringify(
String(nextConfig.cacheComponents ?? false),
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.

Minor: String(nextConfig.cacheComponents ?? false) is intentional (the consumer does a === "true" string compare via isCacheComponentsEnabled(), so a string-typed define is required), but it reads as a double-wrap next to the boolean JSON.stringify(nextConfig.appShells) pattern right above it. Consider a one-line comment noting that this define is deliberately a string ("true"/"false") so a future reader doesn't "simplify" it to JSON.stringify(nextConfig.cacheComponents ?? false) and silently break the runtime check (which would then compare the boolean true against the string "true").

return (
<>
{renderEntries.map((entry) => (
<React.Activity
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.

Behavioral note (not a defect): retained hidden entries keep their full DOM mounted, so duplicate element ids exist while an inactive entry is cached (e.g. #counter-display-1 present for both the visible and a hidden page). The e2e locators correctly :visible-scope around this, and it matches Next.js Activity behavior, but it does mean document.getElementById / global id-based queries in userland code can resolve to a hidden snapshot under cacheComponents. Worth being aware of when documenting the cacheComponents opt-in surface.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Jun 5, 2026

Review submitted successfully. Here's a summary of my review of PR #1739.

Review summary

The PR implements App Router BFCache segment-state retention using React.Activity under cacheComponents, keeping render-tree state keys separate from useRouter().bfcacheId history identity.

Verdict: Sound, faithful to Next.js, well-tested — nothing blocking.

What I verified locally

  • vp check clean on all four changed source files (format/lint/types).
  • Unit tests pass: slot BFCache entry ordering (5) and the new app-browser-entry page-segment-state-key test (1).
  • React.Activity is a stable export in the pinned React 19.2.7.
  • Design parity: createBfcacheSegmentStateKeyMap (pathname-based render-tree key) and updateBfcacheSlotEntryOrder (move-to-front LRU with no-eviction fast path) mirror the upstream layout-router / bfcache-state-manager split.
  • The identity split is correct and well-tested: BfcacheIdMapContext.Provider sits above BfcacheActivitySlotBoundary in both BrowserRoot and handleSsr, so returning to a cached segment preserves useState via Activity while the leaf still re-renders a fresh bfcacheId. The keeps Activity state separate from fresh bfcacheId identity e2e exercises this precisely.
  • SSR/browser provider nesting matches (BfcacheIdMap > StateKeyMap > routeTree).
  • Prior reviewers' behavioral-widening concern is resolved: the BfcacheActivitySlotBoundary split keeps non-cacheComponents apps on the baseline unkeyed path with no extra hook/setState/providers.

Comments posted (both non-blocking)

  1. index.ts:1338 — the String(... ?? false) double-wrap is intentional (the runtime guard does a === "true" string compare), but worth a comment so a future reader doesn't "simplify" it and break the check.
  2. slot.tsx:282 — behavioral note that retained hidden entries keep duplicate element ids in the DOM under cacheComponents; e2e correctly :visible-scopes, but userland getElementById could resolve a hidden snapshot — worth documenting in the opt-in surface.

Review-only pass; no file edits or git write operations performed.

github run

@james-elicx
Copy link
Copy Markdown
Member

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

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

Reviewed the App Router BFCache segment-state retention work. The design is sound and faithful to Next.js: the pathname-derived render-tree state key (createBfcacheSegmentStateKeyMap, reusing the existing createBfcacheSegmentIdentity helper) is correctly kept separate from the userland useRouter().bfcacheId history identity, mirroring the upstream layout-router stateKey / bfcache-state-manager split. The updateBfcacheSlotEntryOrder LRU/eviction is clean and well unit-tested, and the new e2e fixtures map closely to upstream back-forward-cache.test.ts.

What I verified locally

  • vp check is clean on all five changed source files (format, lint, types).
  • Unit tests pass: tests/shims.test.ts -t "slot BFCache entry ordering" (5), the new app-browser-entry page-segment-state-key test (1), and the full tests/app-page-render.test.ts suite (41) — which still passes despite a test helper using getNavigationContext: () => null (it mocks loadSsrHandler, so it never reaches the real handleSsr).
  • The define is consistent: __NEXT_CACHE_COMPONENTS lands in the top-level define map as the JSON-encoded string "true"/"false", and isCacheComponentsEnabled() does a === "true" compare, so the inlined replacement matches across client/SSR/RSC. ResolvedNextConfig.cacheComponents is already a guaranteed boolean (normalized in config/next-config.ts), so the ?? false is redundant-but-harmless.
  • The non-cacheComponents path is correctly preserved as the baseline unkeyed <SegmentContext.Provider> — the prior behavioral-widening concern is resolved by the BfcacheActivitySlotBoundary split.
  • The new app-router-bfcache Playwright project does not collide with the app-router project glob (**/app-router/**/*.spec.ts does not match the app-router-bfcache/ directory), and the de-dup webServer map picks up the new server.

The one change worth a second look

The handleSsr signature change from navContext?.pathname ?? "/" to a hard requireNavigationContext() throw is the only behavioral hardening that converts a previously-degraded-but-rendering path into a 500. I traced the production callers and it appears safe: app-rsc-handler.ts:534 sets navigation context (with a real searchParams) before every page/not-found render, and handleSsr is HTML-only (RSC requests return the flight stream directly). But it does narrow the contract, so the inline notes below are worth confirming.

Nothing blocking.

pathname: navContext?.pathname ?? "/",
searchParams: navContext?.searchParams ? [...navContext.searchParams.entries()] : [],
pathname: navContext.pathname,
searchParams: [...navContext.searchParams.entries()],
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.

This now reads navContext.searchParams.entries() without the previous navContext?.searchParams ? guard. It's safe given requireNavigationContext() guarantees navContext is non-null, but it also now assumes searchParams is always present on the context. Production callers do set it (app-rsc-handler.ts passes url.searchParams, dispatch passes new URLSearchParams()), but several test nav contexts are shaped as { pathname } only (e.g. app-page-boundary-render.test.ts returns { pathname: "/posts/missing" }). Those tests mock handleSsr so they don't hit this line today — just flagging that the NavigationContext type should make searchParams non-optional if this assumption is now load-bearing, otherwise a future test/caller passing a partial context would throw here rather than degrade.


function requireNavigationContext(navContext: NavigationContext | null): NavigationContext {
if (!navContext) {
throw new Error("App SSR requires navigation context for BFCache state keys");
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.

Behavioral hardening note (not blocking): previously a null navContext degraded gracefully (navContext?.pathname ?? "/"); now it throws a hard error, surfacing as a 500. I traced the production paths and setNavigationContext is called in app-rsc-handler.ts (line ~534) before every render including the route-miss not-found branch, so this should be unreachable in prod. The throw is reasonable as a defensive invariant, but since it widens the failure mode, it would be worth a one-line comment here pointing at where the context is guaranteed to be set (the RSC handler), so a future change that adds a new handleSsr entry path knows the contract.


return renderOrder
.map((stateKey) => snapshotsByStateKey.current.get(stateKey))
.filter((entry): entry is BfcacheSlotEntry => entry !== undefined);
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.

Minor robustness note: renderOrder keys whose snapshot was never captured are silently dropped by the .filter(... !== undefined). In normal operation every key in renderOrder has a snapshot (the active key is set this render; inactive keys retain their last-active snapshot and are pruned together with the order), so this can't drop the active entry. It's correct, but the filter quietly masks an invariant violation if it ever happened. Consider asserting (dev-only) that the active stateKey is always present, since dropping it would render nothing for the slot without any signal.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Jun 5, 2026

Review submitted successfully. Here's a summary of my review of PR #1739.

Review summary

The PR implements App Router BFCache segment-state retention using React.Activity under cacheComponents, keeping render-tree state keys separate from useRouter().bfcacheId history identity.

Verdict: Sound, faithful to Next.js, well-tested — nothing blocking.

What I verified independently

  • vp check clean on all five changed source files.
  • Unit tests pass: slot BFCache entry ordering (5), the new app-browser-entry state-key test (1), and the full app-page-render suite (41) — confirming the getNavigationContext: () => null test helper still passes (it mocks loadSsrHandler).
  • define parity: __NEXT_CACHE_COMPONENTS is a JSON-encoded string "true"/"false" matching the === "true" runtime guard; ResolvedNextConfig.cacheComponents is already a normalized boolean.
  • No Playwright project collision: app-router-bfcache/ does not match the app-router/ glob, and the new server is picked up by the de-dup webServer map.
  • Non-cacheComponents path preserved via the BfcacheActivitySlotBoundary split (prior reviewers' widening concern resolved).

Comments posted (all non-blocking)

  1. app-ssr-entry.ts:223 — removing the navContext?.searchParams ? guard now assumes searchParams is always present; suggested making it non-optional on the NavigationContext type since it's now load-bearing.
  2. app-ssr-entry.ts:248 — the new requireNavigationContext() throw narrows the contract (null → 500). Traced it as unreachable in prod (app-rsc-handler.ts sets context before every render), but suggested a comment pointing at where the invariant is guaranteed.
  3. slot.tsx:252 — the .filter(... !== undefined) silently drops uncaptured renderOrder keys; correct today, but masks an invariant violation if the active key were ever missing — suggested a dev-only assertion.

Review-only pass; no file edits or git write operations performed. The earlier multi-round review by ask-bonk was thorough; my pass focused on independently verifying the handleSsr contract change, which hadn't been deeply examined before.

github run

@james-elicx
Copy link
Copy Markdown
Member

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

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

Independent review of the App Router BFCache segment-state retention work.

Verdict: Sound, faithful to Next.js, well-tested — nothing blocking.

The design correctly separates the pathname-derived render-tree state key (createBfcacheSegmentStateKeyMap, reusing createBfcacheSegmentIdentity) from the userland useRouter().bfcacheId history identity, mirroring the upstream layout-router stateKey / bfcache-state-manager split. The BfcacheActivitySlotBoundary split correctly keeps non-cacheComponents apps on the baseline unkeyed path, resolving the behavioral-widening concern from earlier rounds.

What I verified independently

  • requireNavigationContext() hardening is safe on all production paths. I traced every caller that reaches handleSsr:
    • app-rsc-handler.ts:534 sets a full context (pathname/searchParams/params) before the main render.
    • app-page-dispatch.ts:422 and :506 set it before the ISR/revalidation render that calls revalidatedSsrEntry.handleSsr (:595) — the revalidation path was the one I was least sure about, and it is covered.
    • app-page-boundary-render.ts:275 synthesizes a context via getNavigationContext() ?? { pathname, searchParams, params } so the fallback boundary never passes null.
      So the previous degrade-to-/ path is genuinely unreachable in prod; the throw is a defensive invariant, not a new failure mode.
  • searchParams is already non-optional on NavigationContext (navigation.ts:187), so the removal of the navContext?.searchParams ? guard in buildHeadInjectionHtml is type-safe — the earlier suggestion to make it non-optional is already satisfied.
  • SSR/browser provider nesting matches (BfcacheIdMap > BfcacheStateKeyMap > routeTree in both handleSsr and BrowserRoot), so hydration keys line up.
  • Unit tests pass: slot BFCache entry ordering (5).
  • CI matrix correctly adds the app-router-bfcache project, and the Playwright app-router-bfcache/ dir does not collide with the app-router/ glob.

Two minor, non-blocking observations inline.

elements,
// Must match the client navigation snapshot pathname byte-for-byte;
// Activity keys are derived from this value during hydration.
pathname: ssrNavigationContext.pathname,
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.

Non-blocking hydration-parity note. The SSR Activity state keys are derived from ssrNavigationContext.pathname, while the client derives them from treeState.navigationSnapshot.pathname (app-browser-entry.ts:1174). Since these become the React.Activity key, the two pathname sources must stay byte-identical or the SSR'd hidden-entry key won't match the hydrated one (trailing-slash normalization, basePath stripping, encoded segments are the usual divergence points). The comment here documents the invariant, which is good — but consider routing both through the same canonicalization helper so the invariant can't silently drift. The currently-tested routes align, so this is a robustness note, not a defect.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Normalized inside createBfcacheSegmentStateKeyMap (commit fa29e20) so both SSR and client callers produce byte-identical keys automatically — decodeURIComponent + trailing-slash stripping covers the common divergence cases.

if (!navContext) {
// Real request rendering must pass the RSC handler's navigation context;
// fallback boundary renderers synthesize one when request scope is gone.
throw new Error("App SSR requires navigation context for BFCache state keys");
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.

Non-blocking: this requireNavigationContext() throw makes the handleSsr navigation context load-bearing (null now 500s instead of degrading to /). I traced all production callers and it is safe — app-rsc-handler.ts:534, app-page-dispatch.ts:422/:506, and the app-page-boundary-render.ts:275 synthesized fallback all seed a full context before any handleSsr call, including the ISR/revalidation render at app-page-dispatch.ts:595. The comment here is good; consider also referencing the RSC handler as the guaranteeing caller so a future handleSsr entry path knows the contract it must uphold.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Updated the comment (commit fa29e20) to reference the specific guaranteeing callers: app-rsc-handler.ts (main render), app-page-dispatch.ts (ISR/revalidation), and app-page-boundary-render.ts (fallback synthesis).

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Jun 5, 2026

Review submitted successfully.

Review summary

I performed an independent review of PR #1739 (App Router BFCache segment-state retention with React.Activity).

Verdict: Sound, faithful to Next.js, well-tested — nothing blocking.

What I verified independently (beyond the 5 prior rounds)

The main thing prior reviewers flagged but hadn't fully traced was the requireNavigationContext() hardening — it converts a previously-degrading null-context path into a hard 500. I traced every production caller that reaches handleSsr:

  • app-rsc-handler.ts:534 — main render
  • app-page-dispatch.ts:422/:506 — including the ISR/revalidation render at :595 (the one I was least sure about; it's covered)
  • app-page-boundary-render.ts:275 — synthesizes a fallback context so the boundary never passes null

So the throw is a safe defensive invariant, not a new failure mode.

I also confirmed:

  • searchParams is already non-optional on NavigationContext (satisfies an earlier suggestion)
  • SSR/browser provider nesting matches (BfcacheIdMap > BfcacheStateKeyMap > routeTree) for hydration alignment
  • Unit tests pass (5 slot BFCache entry ordering)
  • CI matrix and Playwright glob isolation are correct

Comments posted (both non-blocking)

  1. app-ssr-entry.ts:338 — hydration-parity note: SSR and client derive the Activity key from two different pathname sources; suggested routing both through one canonicalization helper.
  2. app-ssr-entry.ts:250 — suggested the requireNavigationContext() comment reference the RSC handler as the guaranteeing caller, since the contract is now load-bearing.

Review-only pass; no file edits or git write operations performed.

github run

@james-elicx
Copy link
Copy Markdown
Member

/bigbonk review for issues

@NathanDrake2406
Copy link
Copy Markdown
Contributor Author

/bigbonk review for issues

u should try the keyword "adversarial review". I've been using it and it's peak
https://blog.cloudflare.com/cyber-frontier-models/

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

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

Reviewed the App Router BFCache segment-state retention work. The design is sound and faithful to Next.js — separating the pathname-derived render-tree state key (createBfcacheSegmentStateKeyMap) from the userland useRouter().bfcacheId history identity mirrors the upstream layout-router stateKey / bfcache-state-manager split, and the BfcacheActivitySlotBoundary split correctly keeps non-cacheComponents apps on the baseline unkeyed path.

I independently verified:

  • slot BFCache entry ordering unit tests (5) and the new app-browser-entry page-segment-state-key test pass via vp test run.
  • vp lint is clean on the changed shim/server files.
  • __NEXT_CACHE_COMPONENTS lands in the shared top-level define map (applies to dev + build, all three environments), and isCacheComponentsEnabled() does a matching === "true" string compare. Dev parity holds since the define is set in the config hook.
  • requireNavigationContext() is safe on every production handleSsr caller: app-rsc-handler.ts (main render), app-page-dispatch.ts:422 (the ISR/revalidation path that reaches handleSsr at :595), and the app-page-boundary-render.ts:275 synthesized fallback. The previous null→/ degrade is genuinely unreachable in prod.
  • The Playwright app-router-bfcache project is isolated: **/app-router/**/*.spec.ts does not match the app-router-bfcache/ segment, and the webServer de-dup map picks up the new fixture server.

One genuine (edge-case) defect worth addressing inline; everything else is non-blocking.

// Decode and strip trailing slash so SSR and client produce byte-identical
// Activity keys regardless of encoding or trailing-slash normalization
// differences between the two pathname sources.
const decoded = decodeURIComponent(pathname);
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.

decodeURIComponent throws on malformed percent sequences (e.g. a lone % in a path like /foo%). This runs unguarded during both the SSR flight-root render (app-ssr-entry.ts) and the client useMemo (app-browser-entry.ts), so a throw here crashes rendering for the whole route, not just BFCache retention.

The rest of the codebase treats decodeURIComponent(pathname) as throwable and wraps it — see prod-server.ts:562-566 (try { decodeURIComponent(pathname) } catch { return false }). While URL.pathname normally re-encodes a lone % to %25, navigationSnapshot.pathname / navContext.pathname aren't guaranteed to be URL.pathname-derived, so this should fail soft and fall back to the raw pathname rather than throwing:

Suggested change
const decoded = decodeURIComponent(pathname);
function normalizeBfcachePathname(pathname: string): string {
// Decode and strip trailing slash so SSR and client produce byte-identical
// Activity keys regardless of encoding or trailing-slash normalization
// differences between the two pathname sources. decodeURIComponent can throw
// on malformed input (a lone `%`), so fall back to the raw pathname rather
// than crashing the render — matching the defensive pattern in prod-server.ts.
let decoded: string;
try {
decoded = decodeURIComponent(pathname);
} catch {
decoded = pathname;
}
return decoded.length > 1 ? decoded.replace(/\/$/, "") : decoded;
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Already guarded with try/catch in the previous commit — normalizeBfcachePathname at line 301-306 falls back to the raw pathname on decode failure.


function useBfcacheSlotEntries(activeEntry: BfcacheSlotEntry): BfcacheSlotEntry[] {
const snapshotsByStateKey = React.useRef(new Map<string, BfcacheSlotEntry>());
const [entryOrder, setEntryOrder] = React.useState<string[]>(() => [activeEntry.stateKey]);
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.

Non-blocking: useBfcacheSlotEntries adjusts state during render (setEntryOrder(nextOrder) at line 253-255) and mutates a ref Map during render (line 238 / pruning). This is the documented "adjusting state when a prop changes" pattern and converges in two renders (the next render sees entryOrder === nextOrder, so orderChanged is false), but it diverges from upstream useRouterBFCache, which is pure copy-on-write over React state. Under StrictMode double-invoke or a discarded concurrent render the ref set runs for a render that may be thrown away. It's self-healing (active key overwritten every render, pruned to render order), so no change is required — but a one-line comment noting the ref mutation is intentionally tolerant of discarded/StrictMode renders would save a future reader from flagging it as an impure-render violation.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Already present — the comment at line 236-237 documents this: "Render can be restarted or discarded; snapshots are render-tolerant because the active key is overwritten on every render and pruned to render order."


return (
<>
{renderEntries.map((entry) => (
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.

Behavioral note for the cacheComponents opt-in docs (not a defect): retained hidden Activity entries keep their full DOM mounted, so duplicate userland element ids exist while an inactive entry is cached (e.g. #counter-display-1 present for both a visible and a hidden page). The e2e locators correctly :visible-scope around this, but userland document.getElementById / global id queries can resolve to a hidden snapshot under cacheComponents. Worth calling out when documenting the opt-in surface.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Already present — the comment in BfcacheActivitySlotBoundary above the React.Activity map documents this: "Hidden Activity entries keep their DOM mounted, so duplicate userland ids can exist... Consumers should query by visible scope when that distinction matters."

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Jun 5, 2026

Review submitted successfully. Here's a summary of my review of PR #1739.

Review summary

The PR implements App Router BFCache segment-state retention with React.Activity under cacheComponents, separating render-tree state keys from useRouter().bfcacheId history identity.

Verdict: Sound, faithful to Next.js, well-tested. One genuine edge-case defect, otherwise non-blocking.

What I verified independently

  • slot BFCache entry ordering unit tests (5) and the new app-browser-entry state-key test pass (vp test run).
  • vp lint clean on changed shim/server files.
  • __NEXT_CACHE_COMPONENTS is in the shared top-level define map (dev + build, all environments) with a matching === "true" string-compare guard.
  • requireNavigationContext() hardening is safe on every production handleSsr caller (main render, ISR/revalidation, synthesized fallback).
  • Playwright app-router-bfcache project is isolated from the app-router glob.

The one finding worth addressing (app-browser-state.ts:300)

normalizeBfcachePathname calls decodeURIComponent(pathname) unguarded. That can throw on malformed percent input (e.g. /foo%), and it runs during both SSR flight-root render and the client useMemo — so a throw crashes the whole route render, not just BFCache retention. The rest of the codebase already treats decodeURIComponent(pathname) as throwable (prod-server.ts:562-566 wraps it in try/catch). Posted a suggestion to fail soft to the raw pathname.

This is a net-new finding the prior five /bigbonk rounds didn't surface (they focused on design parity, the define, and the requireNavigationContext contract).

Non-blocking notes posted

  • slot.tsx:234useBfcacheSlotEntries adjusts state + mutates a ref during render; self-healing and converges, but worth a comment documenting StrictMode/discarded-render tolerance.
  • slot.tsx:288 — retained hidden Activity entries keep duplicate userland element ids in the DOM under cacheComponents; worth documenting on the opt-in surface.

No file edits or git write operations were performed.

github run

@james-elicx
Copy link
Copy Markdown
Member

/bigbonk adversarial review

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Jun 5, 2026

UnknownError: "Overloaded"

github run

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Jun 5, 2026

@james-elicx Bonk workflow was cancelled.

View workflow run · To retry, trigger Bonk again.

@james-elicx
Copy link
Copy Markdown
Member

/bigbonk adversarial review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

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

Adversarial review of the App Router BFCache segment-state retention work. I independently re-derived the design against the upstream Next.js v16.2.6 layout-router.tsx + bfcache-state-manager.ts, traced the requireNavigationContext() contract across every production handleSsr caller (including the SSG build-time prerender path, which invokes the RSC handler with a synthetic Request and so goes through app-page-dispatch.tssetNavigationContext), and ran the unit suite (slot BFCache entry ordering, 5 pass) plus vp check on the four changed source files (clean).

Verdict: Sound and faithful to Next.js where it claims to be. Nothing blocking. Prior rounds already covered the define parity, SSR/hydration convergence, the LRU ordering, and the requireNavigationContext hardening; I confirmed those and won't repeat them. Two findings below the prior rounds did not surface — one is a genuine behavioral-parity divergence worth documenting precisely, the other is a robustness note.

Verified against upstream

  • createRouterCacheKey(activeSegment, true) in upstream explicitly excludes search params from the state key ("search params do not cause state to be lost"). vinext's page state key ${id}@${pathname} likewise omits search params — correct, and consistent with the search-param case being a stated non-goal.
  • The 3-entry bound under cacheComponents and the move-to-front-without-eviction fast path in updateBfcacheSlotEntryOrder mirror useRouterBFCache / MAX_BF_CACHE_ENTRIES.
  • SSG prerender does not bypass the navigation-context invariant.

// state that survives a normal navigation. Reset for genuinely fresh entries is
// driven by userland bfcacheId keying, not by remounting the slot subtree, so
// the active entry renders unkeyed here.
if (!isCacheComponentsEnabled()) {
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.

Documented behavioral divergence from Next.js — worth making explicit in the comment, not a blocker.

Upstream OuterLayoutRouter keys the active segment's child by stateKey unconditionally via <TemplateContext.Provider key={stateKey}> — the key is present whether or not __NEXT_CACHE_COMPONENTS is set; cacheComponents only adds the extra <Activity> wrapper and raises MAX_BF_CACHE_ENTRIES from 1 to 3. With cacheComponents off, upstream still resets default (non-keyed) client state when re-pushing a route because the prior subtree was never retained (MAX=1), so a fresh push mounts a brand-new instance.

vinext deliberately renders the active segment unkeyed here and instead relies on userland bfcacheId keying (see the use-router-bfcache-id fixture, which keys its leaf by bfcacheId) to drive that reset. The observable result diverges for the default, un-keyed case: a plain useState leaf that upstream resets on a fresh re-push ('resets leaf form state when re-entering a route via fresh push') will persist in vinext without cacheComponents.

The comment correctly explains why keying by the pathname-derived stateKey would over-remount shared layouts, but it presents the unkeyed choice as parity-preserving when it is actually a deliberate parity trade-off. Suggest tightening the wording so a future reader understands this is a known divergence (state reset is opt-in via userland bfcacheId keying here, vs. automatic stateKey remount upstream), e.g. add: // NOTE: This diverges from Next.js, which keys the active child by stateKey even without cacheComponents; vinext defers fresh-entry reset to userland bfcacheId keying. See use-router-bfcache-id fixture.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added the diveregence note (commit c1abe1e): "NOTE: This diverges from Next.js, which keys the active child by stateKey even without cacheComponents; vinext defers fresh-entry reset to userland bfcacheId keying. See use-router-bfcache-id fixture."


function useBfcacheSlotEntries(activeEntry: BfcacheSlotEntry): BfcacheSlotEntry[] {
const snapshotsByStateKey = React.useRef(new Map<string, BfcacheSlotEntry>());
const [entryOrder, setEntryOrder] = React.useState<string[]>(() => [activeEntry.stateKey]);
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.

Robustness note (non-blocking). useState(() => [activeEntry.stateKey]) seeds the order from the first active key, but snapshotsByStateKey is a useRef(new Map()). If activeEntry.stateKey changes between mount and the next render (normal — that's the whole point), the render-phase setEntryOrder plus the ref write converge in two renders. The dev-only invariant throw at line 250 guards the active key, which is good.

The one thing not covered by the new fixture: the non-cacheComponents path never enters this hook at all (it returns early at the unkeyed provider), so this hook is only exercised under cacheComponents: true. That's fine, but it means the MAX_BFCACHE_SLOT_ENTRIES_WITHOUT_CACHE_COMPONENTS = 1 limit branch of getBfcacheSlotEntryLimit() is effectively dead in the React path — it's only reachable via the directly-unit-tested updateBfcacheSlotEntryOrder(..., 1). Worth a one-line comment that the WITHOUT_CACHE_COMPONENTS limit exists for the unit-tested helper contract and the future non-flag-keyed path, not the current render path, so a reader doesn't assume the limit-1 branch runs in BfcacheActivitySlotBoundary.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added a comment (commit c1abe1e) explaining that MAX_BFCACHE_SLOT_ENTRIES_WITHOUT_CACHE_COMPONENTS = 1 serves the directly-unit-tested helper contract and a future non-flag-keyed path, not the current render path which only reaches BfcacheActivitySlotBoundary under cacheComponents.

@@ -322,6 +333,18 @@ export async function handleSsr(
{ value: elements },
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.

Confirmation, no change requested. The SSR tree now nests BfcacheIdMapContext.Provider > BfcacheStateKeyMapContext.Provider(stateKeyTree) > routeTree, matching app-browser-entry.ts (BfcacheIdMapContext > stateKeyTree > routeTree). Under cacheComponents, BfcacheSlotBoundary will render React.Activity during SSR as well (the define is shared across environments), but with a single entry in visible mode, so the server markup is a single visible Activity that hydrates against the client's single-entry first render. Verified the provider order and the single-entry SSR convergence; the passing e2e (waitForAppRouterHydration) empirically confirms no hydration mismatch from the Activity wrapper. Flagging only so the SSR-renders-Activity-too fact is recorded — it's easy to assume Activity is client-only.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed. The define parity across environments is verified — __NEXT_CACHE_COMPONENTS is in the top-level define map so both SSR and client bundles inline the same value, and isCacheComponentsEnabled() does a === "true" string compare. The single-entry SSR Activity hydrates against the client single-entry first render with no mismatch (confirmed by e2e waitForAppRouterHydration).

…oject

The header noted that Activity-backed preservation is covered in
back-forward-cache.spec.ts, but that spec moved to the dedicated app-bfcache
fixture under the app-router-bfcache project. Name the project and path so the
cross-reference stays accurate.
Fallback boundary rendering can outlive the original request-scoped navigation context. That made app SSR fail fast with a missing navigation context when loading-boundary digest routes rendered their HTTP fallback shell.

The SSR boundary contract still requires a navigation context for real request renders, but boundary renderers now synthesize the same typed shape from the request URL and matched params when request scope has already been cleared.
…riant callers

Moves hydration-pathname normalization into createBfcacheSegmentStateKeyMap so SSR and client always produce byte-identical Activity keys. Documents the requireNavigationContext guaranteeing callers per review feedback.
@NathanDrake2406 NathanDrake2406 force-pushed the nathan/issue-1366-bfcache-restore branch from 0fae761 to cd5bb26 Compare June 6, 2026 04:18
@james-elicx
Copy link
Copy Markdown
Member

/bigbonk re-review for issues

@james-elicx
Copy link
Copy Markdown
Member

/bigbonk review this pr for issues

…an/issue-1366-bfcache-restore

# Conflicts:
#	.github/workflows/ci.yml
@james-elicx
Copy link
Copy Markdown
Member

/bigbonk review for issues

1 similar comment
@james-elicx
Copy link
Copy Markdown
Member

/bigbonk review for issues

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

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

Independent review of the App Router BFCache segment-state retention work. I re-derived the design from scratch rather than re-checking prior rounds, ran the unit suite, and type/lint-checked the changed source files.

Verdict: Sound, faithful to Next.js, well-tested. Nothing blocking — only minor notes below.

What I verified independently

  • Unit tests pass: slot BFCache entry ordering (5) via vp test run; vp check is clean on slot.tsx, app-browser-state.ts, app-ssr-entry.ts, app-browser-entry.ts.
  • React.Activity is a symbol export in the pinned React 19.2.7.
  • Eviction parity: traced the e2e "three most recent" scenario (1→2→3→4→2→1) against updateBfcacheSlotEntryOrder — order resolves to [1,2,4], so page 1 is correctly a fresh entry (Count:0) while page 2 is retained. The move-to-front-no-eviction fast path matches the unit tests.
  • SSR/hydration convergence: both SSR (app-ssr-entry.ts:343) and client (app-browser-entry.ts:1188) feed createBfcacheSegmentStateKeyMap through the same normalizeBfcachePathname, so Activity keys are byte-identical. SSR renders a single visible Activity entry; the useState initializer seeds the same single-entry order on first client render — no setState-in-render firing, no hydration drift.
  • Hook ordering is safe: isCacheComponentsEnabled() is a build-time define constant, so the BfcacheSlotBoundary branch into BfcacheActivitySlotBoundary is stable per build; the new useMemo in BrowserRoot sits after the existing conditional throw/use flow with no intervening early return.
  • define shape: JSON.stringify(String(cacheComponents ?? false)) yields the JSON string "true"/"false", matching the === "true" guard. ResolvedNextConfig.cacheComponents is already normalized to a boolean (config/next-config.ts:1423), so the ?? false is redundant-but-harmless.
  • Playwright isolation: the app-router glob **/app-router/**/*.spec.ts does not match the app-router-bfcache/ segment, and the CI matrix adds the new project.
  • requireNavigationContext() hardening: searchParams is non-optional on NavigationContext (navigation.ts:187), so the dropped ?. guards in buildHeadInjectionHtml are type-safe; the synthesized fallback in app-page-boundary-render.ts:276 keeps the throw unreachable on the boundary path.

Notes (all non-blocking)

  1. See inline comments on slot.tsx and app-browser-state.ts.
  2. Stale author reply: the malformed-percent concern from a prior round is genuinely resolved — normalizeBfcachePathname routes through decodeRouteSegment, which already try/catches per segment — but the author's reply citing a try/catch "at line 301-306" is inaccurate; the safety lives in the normalizer, not a local catch (see inline comment).
  3. Pre-existing innerTree quirk (not introduced here, just outside the changed hunk at app-browser-entry.ts:1201-1203): the innerTree ternary falls back to bfcacheTree in the no-AppRouterContext branch while the truthy branch wraps redirectedTree (with AppRouterRedirectBridge). When AppRouterContext is absent the redirect bridge is silently dropped. AppRouterContext is effectively always present so this is latent, but since this PR is already reshaping the surrounding provider stack, falling back to redirectedTree for consistency would be a cheap correctness fix.

!snapshotsByStateKey.current.has(activeEntry.stateKey)
) {
throw new Error("BFCache Activity slot is missing the active entry snapshot");
}
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.

Minor: this dev-only invariant throw is effectively dead. activeEntry.stateKey is .set() into the map at line 248, and renderOrder always begins with activeStateKey (updateBfcacheSlotEntryOrder unconditionally puts it first), so pruneBfcacheSlotEntrySnapshots(map, renderOrder) can never delete the active key. The guard is harmless defensive code, but a reader may wonder how it could ever fire — a one-line comment noting it guards against a future renderOrder that drops the active key would help.

// Use the route-match normalizer so decoded delimiters like %2F remain data
// inside their segment instead of becoming structural path separators.
const normalized = normalizePath(normalizePathnameForRouteMatch(pathname));
return normalized.length > 1 ? normalized.replace(/\/$/, "") : normalized;
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.

Confirming this is safe (no change requested): normalizeBfcachePathname no longer calls decodeURIComponent directly — it routes through normalizePathnameForRouteMatchdecodeRouteSegment, which try/catches per segment and falls back to the raw segment on malformed percent input. The falls back to raw pathname for malformed encoded state-key paths unit test (/page/%${id}@/page/%) covers this. Note for the record: the author's review reply citing a try/catch "at line 301-306" is stale — the actual safety lives in decodeRouteSegment, not in this function.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Jun 6, 2026

Review submitted successfully.

Review summary — PR #1739

I performed an independent review of the App Router BFCache segment-state retention work, re-deriving the design rather than re-checking the prior rounds.

Verdict: Sound, faithful to Next.js, well-tested. Nothing blocking.

What I verified independently

  • Unit tests pass (slot BFCache entry ordering, 5) and vp check is clean on all four changed source files.
  • Eviction parity: traced the 1→2→3→4→2→1 e2e scenario through updateBfcacheSlotEntryOrder — resolves to [1,2,4], so page 1 correctly resets while page 2 is retained.
  • SSR/hydration convergence: SSR and client both normalize the pathname through the same normalizeBfcachePathname, so Activity keys are byte-identical; the single-entry first render avoids any setState-in-render firing.
  • Hook ordering is safe (the cacheComponents branch is a build-time define constant; the new useMemo has no intervening early return).
  • React.Activity is a stable symbol export in React 19.2.7; define shape matches the === "true" guard; Playwright/CI isolation holds; requireNavigationContext() is type-safe and unreachable-by-throw on production paths.

Findings posted (all non-blocking)

  1. slot.tsx:261 — the dev-only invariant throw is effectively dead code (the active key is always present in renderOrder); suggested a clarifying comment.
  2. app-browser-state.ts:300 — confirmed the malformed-percent concern is genuinely resolved via decodeRouteSegment's per-segment try/catch, and noted the author's prior reply ("try/catch at line 301-306") is stale.
  3. Pre-existing innerTree quirk (in the review body) — the no-AppRouterContext branch silently drops the redirect bridge; latent but worth a cheap consistency fix while this code is being reshaped.

No file edits or git write operations were performed; this was a review-only pass.

github run

@NathanDrake2406
Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector
Copy link
Copy Markdown

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

ℹ️ 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.

App Router: implement history-entry BFCache restoration for back/forward traversal

3 participants