Skip to content

Implement segment-scoped useRouter bfcacheId semantics#1197

Open
manNomi wants to merge 2 commits into
cloudflare:mainfrom
manNomi:fix/use-router-bfcache-id-semantics
Open

Implement segment-scoped useRouter bfcacheId semantics#1197
manNomi wants to merge 2 commits into
cloudflare:mainfrom
manNomi:fix/use-router-bfcache-id-semantics

Conversation

@manNomi
Copy link
Copy Markdown
Contributor

@manNomi manNomi commented May 14, 2026

Summary

  • implement segment-scoped useRouter().bfcacheId values for the App Router browser state
  • persist bfcache id maps in history state so back/forward restores prior ids
  • preserve ids across shared layouts, hash/search-param navigation, and refresh while minting fresh ids for fresh segment entries
  • add a Next.js compat fixture and E2E coverage adapted from upstream commit 56d9513

This is a follow-up to the placeholder API-surface PR. It keeps the public field added by #1192, but wires it into vinext's current App Router state model.

Tests

  • pnpm exec vp check packages/vinext/src/server/app-browser-state.ts packages/vinext/src/server/app-browser-visible-commit.ts packages/vinext/src/server/app-browser-navigation-controller.ts packages/vinext/src/server/app-browser-entry.ts packages/vinext/src/server/app-ssr-entry.ts packages/vinext/src/shims/navigation.ts packages/vinext/src/shims/slot.tsx tests/app-browser-entry.test.ts tests/shims.test.ts tests/e2e/app-router/nextjs-compat/use-router-bfcache-id.spec.ts
  • pnpm exec vp test run tests/shims.test.ts -t "useRouter"
  • pnpm exec vp test run tests/app-browser-entry.test.ts -t "bfcacheId"
  • pnpm exec vp run vinext#build
  • PLAYWRIGHT_PROJECT=app-router pnpm exec playwright test tests/e2e/app-router/nextjs-compat/use-router-bfcache-id.spec.ts

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 14, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@1197

commit: 361e6e6

@james-elicx
Copy link
Copy Markdown
Collaborator

/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.

Review: Segment-scoped useRouter().bfcacheId

Overall this is a well-structured implementation. The segment identity model (tying bfcacheIds to path-prefix equality for layouts and full-pathname equality for pages), history state persistence for traversals, and the two-context approach (BfcacheIdMapContext + BfcacheSegmentIdContext) are all sound. Test coverage is thorough — both unit tests for the state helpers and E2E tests ported from the upstream Next.js commit.

A few items worth discussing, ranging from a potential memory leak to smaller correctness/style points:

},
};

const _appRouterByBfcacheId = new Map<string, typeof _appRouter>();
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.

Unbounded cache growth. _appRouterByBfcacheId is a Map that grows one entry per unique bfcacheId over the session lifetime but is never pruned. Every fresh navigation mints a new id (e.g. _b_1_, _b_2_, ...) and each produces a cached router object here that is never evicted.

In practice the objects are tiny (shallow spread of _appRouter + one string), so this won't cause real memory pressure in any realistic session. But it's still a leak by principle — and if someone instruments the router object (attaches large debug data, etc.), it could compound.

Consider either:

  • Switching to a WeakRef-based cache or an LRU with a small bound (e.g. 64 entries)
  • Clearing the map inside clearClientNavigationCaches() (called by router.refresh())
  • Documenting that the cache is intentionally unbounded and considered acceptable

Not a blocker, but worth a one-line comment at minimum explaining the decision.

| DispatchPendingNavigationCommitDispositionDecision
| NonDispatchPendingNavigationCommitDispositionDecision;

let nextBfcacheId = 0;
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.

Module-level mutable counter is shared across test runs. nextBfcacheId is a module-level let that only ever increases (via rememberBfcacheId / mintBfcacheId). Because Vitest reuses the module between tests in the same file, the counter carries forward. This means tests can't assert exact id values — they must use regex patterns like /^_b_\d+_$/.

The tests already do this, so it works today. But it's fragile: a test that calls mintBfcacheId (indirectly through createNextBfcacheIdMap) will bump the counter, affecting subsequent tests' id values. If a future test needs to assert exact ids (e.g. for snapshot testing), it'll be surprised.

Consider exporting a resetBfcacheIdCounter() test helper (guarded behind process.env.NODE_ENV !== 'production' or only exported from the test barrel), or restructuring the counter to be injectable.

const currentIdentity = createBfcacheSegmentIdentity(id, options.currentPathname);
const nextIdentity = createBfcacheSegmentIdentity(id, options.nextPathname);
const currentValue = currentIdentity === nextIdentity ? options.current[id] : undefined;
const value = options.restored?.[id] ?? currentValue ?? mintBfcacheId();
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.

Subtle ordering question: restored takes priority over currentValue, which makes sense for traversals (back/forward should restore the exact prior ids). But what happens if the restored map has stale segment ids that no longer exist in elements?

The for...of collectBfcacheSegmentIds(options.elements) loop ensures we only produce ids for segments present in the new elements, so stale keys from restored are silently dropped. That's correct behavior. Just confirming this is intentional — worth a brief inline comment explaining the precedence: restored > current-if-identity-matches > mint-fresh.

Comment thread packages/vinext/src/shims/navigation.ts Outdated
return router;
}

function hasReactDispatcher(): boolean {
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.

Accessing React internals. __CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE is a React implementation detail that could change across React versions. The function name itself is a warning.

I understand the motivation: useContext can only be called inside a React component/hook call stack, so you need a way to detect whether a dispatcher is active before calling it. This is the same pattern Next.js uses internally.

Two concerns:

  1. If React changes this internal (they've renamed it before — it was __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED in React 18), this will silently fall back to "0" instead of crashing, which is the safe direction. Good.
  2. The try/catch in readBfcacheIdFromContext already handles the no-dispatcher case. Is the hasReactDispatcher() pre-check actually needed, or could you rely solely on the catch? If useContext throws without a dispatcher, the catch returns "0" anyway.

If the pre-check is a performance optimization (avoiding the exception cost on hot paths outside React), that's reasonable — but worth a comment explaining why both guards exist.

replaceHistoryStateWithoutNotify(historyState, "", href);
} else if (historyUpdateMode === "push" && window.location.href !== targetHref) {
pushHistoryStateWithoutNotify(historyState, "", href);
} else if (historyUpdateMode === 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.

This new else if arm fires for traversals (historyUpdateMode === undefined) to persist the updated bfcacheIds into history.state even when the URL hasn't changed. Makes sense — but it also fires for refresh navigations (which also pass undefined for historyUpdateMode).

For refresh, this replaceState is harmless (same URL, same-ish state), but it's a behavioral change from before this PR where refresh commits didn't touch history state at all. Worth a comment clarifying that this is intentional for both traverse and refresh cases.

Comment thread packages/vinext/src/shims/navigation.ts Outdated
const hash = fullHref.includes("#") ? fullHref.slice(fullHref.indexOf("#")) : "";
if (mode === "replace") {
replaceHistoryStateWithoutNotify(null, "", fullHref);
replaceHistoryStateWithoutNotify(window.history.state, "", fullHref);
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.

Good fix: preserving window.history.state through hash-only navigations ensures bfcacheIds (and other vinext state like __vinext_previousNextUrl, __vinext_scrollY) survive hash changes. The old code passed null, which would have wiped all history state on hash-only navigation. This was a pre-existing bug that this PR correctly fixes as a side effect of the bfcacheId work.

Worth noting in the PR description that this is a broader fix beyond just bfcacheId.

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

return (
const BfcacheSegmentIdContext = getBfcacheSegmentIdContext();
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.

Calling getBfcacheSegmentIdContext() on every Slot render creates/fetches the context via the global symbol lookup. This is fast (global property access + null check), but it's called once per slot per render. Since the result is stable (created once, then cached on globalThis), consider hoisting this to module level like ServerInsertedHTMLContext is in navigation.ts — unless there's a reason it needs to be lazily initialized per render (e.g., RSC environment where createContext isn't available).

Actually, looking at this more carefully: slot.tsx is marked "use client", so createContext is always available here. You could safely hoist to module level:

Suggested change
const BfcacheSegmentIdContext = getBfcacheSegmentIdContext();
const BfcacheSegmentIdContext = getBfcacheSegmentIdContext()!;

Or compute it once at module scope. Same applies to the getBfcacheIdMapContext() calls in app-browser-entry.ts and app-ssr-entry.ts that are inside render functions.

for (const layoutId of AppElementsWire.readMetadata(elements).layoutIds) {
ids.add(layoutId);
}
} catch {
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.

Silencing the catch with an empty block is fine for test robustness, but consider at least a // ignore or more specific comment about which tests hit this path, so future readers don't wonder if this swallows real errors in production.

@@ -0,0 +1,97 @@
/**
* Next.js Compat E2E: useRouter().bfcacheId
* Ported from: https://github.com/vercel/next.js/blob/56d95137fd6d84f4bc1e5ef2bb31e0136d5fad9c/test/e2e/app-dir/use-router-bfcache-id/use-router-bfcache-id.test.ts
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.

Nice to see the upstream commit reference. The E2E tests cover the key scenarios well: fresh minting, traversal restoration, form state reset via key={bfcacheId}, shared layout preservation, cross-group reset, and hash/search/refresh stability.

One scenario not covered: programmatic navigation via router.push/router.replace (as opposed to <Link> clicks). The tests use revealAndClick which goes through <Link>. Consider adding a test that calls router.push() directly (e.g., via a button onClick) to confirm bfcacheId minting works the same way for programmatic navigations.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 14, 2026

Review posted. Here's a summary of the feedback:

Architecture is solid. The two-context approach (BfcacheIdMapContext at the root, BfcacheSegmentIdContext per Slot) cleanly separates the id-map from the segment identity lookup. The identity model — path-prefix matching for layouts, full-pathname matching for pages — correctly captures when segments are "the same" across navigations.

Key feedback points:

  1. _appRouterByBfcacheId unbounded cache — The Map caching router objects per bfcacheId never gets pruned. Not a practical problem (objects are tiny), but worth documenting or adding to clearClientNavigationCaches().

  2. Module-level nextBfcacheId counter — Shared across tests since Vitest reuses the module. Tests handle this correctly with regex assertions, but it's fragile for future test additions. Consider a resettable test helper.

  3. hasReactDispatcher() + try/catch double guard — The catch block already handles the no-dispatcher case. The pre-check via React internals adds a fast path but probes __CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE, which React has renamed before. Worth a comment explaining why both guards exist.

  4. New historyUpdateMode === undefined arm — Fires for both traversals and refreshes. The refresh case is a behavioral change (previously didn't touch history state). Harmless but should be documented.

  5. Hash-only navigation fix — Preserving window.history.state through hash navigations (lines 1207/1209) fixes a pre-existing bug beyond just bfcacheId. Worth calling out in the PR description.

  6. getBfcacheSegmentIdContext() per-render call in Slot — Could be hoisted to module level since slot.tsx is "use client".

  7. E2E gap — Tests only cover <Link> navigations. Adding a test for router.push() programmatic navigation would strengthen coverage.

github run

@manNomi
Copy link
Copy Markdown
Contributor Author

manNomi commented May 14, 2026

@james-elicx Thanks for the review. I pushed a follow-up commit addressing the concrete items and added comments where the behavior is intentional.

Could you take another look when you have a chance?

@james-elicx
Copy link
Copy Markdown
Collaborator

/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.

Re-review: Segment-scoped useRouter().bfcacheId

The follow-up commit addresses all the concrete items from the first review cleanly:

  • Unbounded cache → Fixed with an LRU cap of 64 entries plus MRU promotion on hit. Good.
  • hasReactDispatcher() removed → The __CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE probe is gone; the try/catch on useContext is the sole guard now. Simpler and more resilient to React internals changes.
  • BfcacheIdMapContext hoisted to module scope → Done in app-browser-entry.ts, app-ssr-entry.ts, and slot.tsx. All three now resolve the context once at module load instead of per-render.
  • Traversal/refresh replaceState commented → The else if (historyUpdateMode === undefined) arm now has an inline comment explaining it covers both traversals and refreshes.
  • Programmatic navigation E2E → Added a test for router.push() and router.replace() with fixture buttons. Covers the gap noted in the first review.
  • INITIAL_BFCACHE_ID constant → Magic "0" string extracted to a named constant.
  • Precedence comment in createNextBfcacheIdMap → Added inline explaining restored > current-if-identity-matches > mint-fresh.

CI is fully green (all Vitest shards, all 11 Playwright E2E projects, Check, create-next-app).

Two minor observations below — neither is blocking. This is ready to merge.

// their current id, and newly-created segments mint a fresh opaque id.
const value = options.restored?.[id] ?? currentValue ?? mintBfcacheId();
ids[id] = value;
rememberBfcacheId(value);
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.

Nit: this rememberBfcacheId(value) call is redundant. By this point value is one of:

  1. From options.restored — already remembered at line 260–261
  2. From options.current — already remembered at line 257–258
  3. From mintBfcacheId() — freshly minted with ++nextBfcacheId, so rememberBfcacheId is a no-op (the counter is already at or past this value)

Not harmful (it's a fast regex + Math.max no-op), but removing it would make the data flow clearer. Totally optional.

);
}

function createHashOnlyHistoryState(state: unknown): unknown {
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.

Nice addition — the allowlist approach (only carrying __vinext_previousNextUrl and __vinext_bfcacheIds forward through hash navigations) is correct. It intentionally drops scroll position keys and any third-party state that might have been on the history entry.

Worth noting: if vinext ever adds more keys to history state (beyond the current four), this function will need updating too. A quick future-proofing option would be to define the set of VINEXT_*_HISTORY_STATE_KEY constants in one place and have this function iterate over a shared list, but that's a refinement for another day.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 14, 2026

Review posted. Here's a summary:

Approved. The follow-up commit cleanly addresses all 7 concrete items from the first review:

  1. LRU cache (64-entry cap with MRU promotion) replaces the unbounded _appRouterByBfcacheId map
  2. hasReactDispatcher() removed — no more probing React internals; try/catch on useContext is the sole guard
  3. Context hoisted to module scope in all three files (app-browser-entry.ts, app-ssr-entry.ts, slot.tsx)
  4. Comment on the undefined historyUpdateMode arm explaining it covers traversals and refreshes
  5. Programmatic navigation E2E test added for router.push() and router.replace()
  6. INITIAL_BFCACHE_ID constant extracted from magic string
  7. Precedence comment in createNextBfcacheIdMap

CI is fully green across all checks. Left two non-blocking nits: a redundant rememberBfcacheId call, and a note about future-proofing the createHashOnlyHistoryState allowlist.

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.

2 participants