Skip to content

feat(app-router): support useRouter bfcacheId semantics#1588

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

feat(app-router): support useRouter bfcacheId semantics#1588
manNomi wants to merge 19 commits into
cloudflare:mainfrom
manNomi:fix/use-router-bfcache-id-semantics-v2

Conversation

@manNomi
Copy link
Copy Markdown
Contributor

@manNomi manNomi commented May 24, 2026

Summary

  • Implements segment-scoped useRouter().bfcacheId semantics for the App Router shim.
  • Persists Vinext-owned bfcache id maps in history state so back/forward traversal can restore previous ids.
  • Provides bfcache contexts through the browser/SSR route tree and layers the contextual bfcacheId onto the mounted AppRouterContext router.
  • Preserves bfcache metadata during hash-only navigation while avoiding stale scroll restoration/custom history state.

Feedback addressed

This is a fresh follow-up based on the review feedback from the earlier bfcacheId PR. In particular, this version:

  • keeps AppRouterContext as the authority for router methods and only adds the contextual bfcacheId in useRouter()
  • formats the public zero sentinel as _b_0_ while sharing the internal zero sentinel constant across the shim/runtime
  • documents the intentional hard-reload reset to the zero sentinel and uses version-gated history restoration to avoid cross-document id collisions
  • preserves bfcache ids after reducer-level element merges, including planner-approved parallel route state
  • clears stale restored ids across redirected traversals and server-action/cache invalidation paths
  • includes active slot route identity for intercepted/parallel slot bfcache ids
  • adds programmatic router.push() / router.replace() and forward-after-reload coverage in the E2E fixture
  • documents that Next.js Activity-backed form-state-on-back assertions are intentionally omitted until vinext implements React Activity
  • verifies same-segment input state survives search-param navigation while the leaf bfcacheId stays stable

Compatibility note

useRouter() now returns a memoized wrapper around the mounted context router instead of the old module-level singleton. This is intentional Next.js App Router parity so custom/instrumented context router methods are preserved while the nearest segment's bfcacheId is added. This is slightly more permissive than Next.js's explicit method list, but it avoids dropping vinext-provided or user-instrumented router methods.

The redirect-related updates in this PR are included to keep bfcache metadata coherent across navigation hops. Cached redirect traversal handling and server-action redirect history metadata updates clear or realign stale restored ids, currentHistoryMode, and currentPrevNextUrl so a redirected navigation cannot accidentally reuse history state from the pre-redirect entry.

Tests

  • pnpm exec vp check packages/vinext/src/server/app-bfcache-id.ts packages/vinext/src/server/app-browser-entry.ts packages/vinext/src/server/app-browser-state.ts packages/vinext/src/shims/navigation.ts packages/vinext/src/shims/slot.tsx tests/shims.test.ts tests/app-browser-entry.test.ts tests/e2e/app-router/nextjs-compat/use-router-bfcache-id.spec.ts
  • pnpm exec vp test run --project unit tests/shims.test.ts -t "useRouter|hash-only"
  • pnpm exec vp test run --project unit tests/app-browser-entry.test.ts -t "bfcache|history state|version|intercepted slot|redirected traverse"
  • pnpm exec vp check tests/e2e/app-router/nextjs-compat/use-router-bfcache-id.spec.ts
  • PLAYWRIGHT_PROJECT=app-router pnpm run test:e2e 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 24, 2026

Open in StackBlitz

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

commit: 7da1b2a

@NathanDrake2406
Copy link
Copy Markdown
Contributor

@codex review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e66ec5c91c

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

Comment on lines +195 to +196
const segmentCount = getVisibleTreePathSegments(parsed.treePath).length;
return `${id}@${getPathPrefix(pathname, segmentCount)}`;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Handle catch-all segments when deriving bfcache identities

createBfcacheSegmentIdentity() derives layout/slot/template identity from a fixed path-prefix length (getVisibleTreePathSegments(...).length), which treats each dynamic segment as consuming exactly one URL segment. For catch-all routes like [...slug]/[[...slug]], the segment value spans the remaining path, so navigating /docs/a/b/docs/a/c keeps the same computed prefix (/docs/a) and incorrectly reuses the previous bfcache id. That preserves client state for a segment whose params actually changed, so useRouter().bfcacheId semantics diverge for catch-all layouts/templates/slots.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

@manNomi manNomi May 24, 2026

Choose a reason for hiding this comment

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

I’ve updated it accordingly.

@NathanDrake2406
Copy link
Copy Markdown
Contributor

NathanDrake2406 commented May 24, 2026

Important PR bro, I think it’ll get merged when maintainer is online. That said, there are a few improvements to be made.
Request changes: architecture is good, but I think we should tighten two seams before merging.

Blocking

1. useRouter() should preserve the actual AppRouterContext router

Right now useRouter() reads AppRouterContext, checks that it exists, then ignores the context value and returns a clone of the module-level _appRouter.
That breaks context ownership. The provider should remain the authority for router methods; useRouter() should only add the contextual bfcacheId.
This also better matches Next’s shape: read the router from context, read the nearest segment/cache identity, then return the router methods plus the contextual bfcacheId.
Suggested shape:

export function useRouter() {
  if (!AppRouterContext || typeof React.useContext !== "function") {
    throw new Error("invariant expected app router to be mounted");
  }
  const router = React.useContext(AppRouterContext);
  if (router === null) {
    throw new Error("invariant expected app router to be mounted");
  }
  const bfcacheId = readBfcacheIdFromContext();
  return React.useMemo(
    () => ({
      ...router,
      bfcacheId,
    }),
    [router, bfcacheId],
  );
}

That removes the need for the global _appRouterByBfcacheId LRU and avoids silently dropping custom/instrumented router methods later.

  1. Public bfcacheId format should match the observable Next shape

The PR currently treats initial hook-visible IDs as "0". Next’s public hook materialises the zero cache node as "b_0".

Even though the value is opaque, this PR is Next-compat, and the b${n}_ format exists to make the value safe to concatenate into user keys. I’d prefer either storing b_0 in the map or storing internal numeric IDs and formatting at useRouter().

Architecture hardening worth doing now

I would not block on a full Route Manifest/semantic graph rewrite in this PR. That would make this slice too big.

But I do think we should create the seam now:

  • Put all bfcache segment identity derivation behind one helper/module, e.g. createBfcacheSegmentIdentity(...).
  • Document that the current AppElementsWire.parseElementKey(...) + pathname prefix logic is a legacy bridge, not the final semantic authority.
  • Do not let wire-key parsing spread beyond that helper.
  • Make history state strictly typed read/write transport for restoring prior IDs; it should not become route meaning.
  • Add a test that a custom AppRouterContext provider keeps its custom methods after useRouter() adds bfcacheId.

This keeps the PR small while preventing the architecture from drifting back into “flat wire keys are the router’s brain”.

Test gaps

The new tests are useful, especially around push/replace and reducer preservation. I’d still add:

  1. Browser back preserves actual keyed client state, not just the restored ID.
  2. Server-action refresh preserves state, since this PR touches the same-URL/server-action commit path.
  3. useRouter() with a non-singleton context router preserves the context router’s methods.

After those changes, I think the architecture is solid and aligned with the navigation direction.

@NathanDrake2406
Copy link
Copy Markdown
Contributor

Blocking issue:

BrowserRoot currently initialises bfcacheIds with createInitialBfcacheIdMap(resolvedElements), then the initial layout effect writes that map back into the current history.state.

That loses a persisted bfcache map when hydrating after a hard reload of a non-initial session-history entry.

Failure shape:

  1. /x/1 starts with _b_0_.
  2. Push /x/2; leaf gets a fresh _b_n_.
  3. Hard reload on /x/2.
  4. Hydration resets the current entry’s map to "0" and persists it.
  5. Browser back can now collide with /x/1’s _b_0_, breaking the “fresh push gets fresh identity, traversal restores old identity” invariant.

I think initial browser state should seed from readHistoryStateBfcacheIds(window.history.state) when present, filtered to the current rendered element/layout ids, and only fall back to createInitialBfcacheIdMap(resolvedElements) when no valid persisted map exists. Restored _b_n_ values should also advance the mint counter so future ids cannot collide.

Test I’d add before merge:

  • Navigate /x/1 -> /x/2.
  • Capture both bfcache ids.
  • Hard reload on /x/2.
  • Assert /x/2 keeps its pre-reload id.
  • Type into the /x/2 leaf input.
  • Browser back to /x/1.
  • Assert /x/1 restores its old id and does not inherit /x/2’s leaf input state.

I’d also port Next’s original browser-back form-state assertion, not just the restored-id assertion. The id text proves the map changed; the form-state assertion proves the user-visible semantics are correct.
Link: https://github.com/vercel/next.js/blob/56d95137fd6d84f4bc1e5ef2bb31e0136d5fad9c/test/e2e/app-dir/use-router-bfcache-id/use-router-bfcache-id.test.ts

@NathanDrake2406
Copy link
Copy Markdown
Contributor

@codex review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 61bf1e9d29

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

Comment on lines +1413 to +1414
const restoredBfcacheIds =
navigationKind === "traverse" ? readHistoryStateBfcacheIds(window.history.state) : null;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Recompute restored bfcache IDs after traverse redirects

When a traverse navigation follows one or more server redirects, this captures restoredBfcacheIds once from the original history entry and reuses it for the final redirected payload. Because createNextBfcacheIdMap prioritizes restored IDs over current/minted IDs, a back/forward navigation that redirects can incorrectly apply stale segment IDs from the source entry to the redirected target, leading to wrong state preservation/reset behavior in router.bfcacheId-keyed components. The restored map should be recomputed (or cleared) when currentHref changes due to redirect hops.

Useful? React with 👍 / 👎.

@NathanDrake2406
Copy link
Copy Markdown
Contributor

I think slots/intercepted routes still have an identity bug.

createBfcacheSegmentIdentity() treats slots like layouts/templates: id + pathname prefix. But slot ids are owner-stable, e.g. slot:modal:/feed, while the active slot page can change underneath it.
So /photo/1 intercepted into @modal/photo/2 intercepted into the same @modal could preserve the same bfcacheId, even though it’s a fresh slot page.

Can we make slot identity include the active slot route/page identity, and add an E2E for fresh intercepted-slot navigation resetting state, while browser back restores it?

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

Review: Implement useRouter bfcacheId semantics

Solid implementation overall. The architecture is well-layered: history persistence (app-history-state), id lifecycle (app-browser-state), browser integration (app-browser-entry), and rendering (slot + navigation shims) are cleanly separated. The version-gated restoration mechanism for hard reload safety, consistent redirect clearing across all three redirect paths, and the preserveBfcacheIdsForMergedElements post-merge reconciliation are all well-designed.

The E2E and unit test coverage is thorough — 12 unit tests for the id map algorithm, updated integration tests, and 10 Playwright E2E tests covering traversal, redirect, hard reload, hash-only, intercepted routes, server actions, and programmatic push/replace.

I have a few inline observations and one thing I'd like resolved before merge. The rest are non-blocking notes.

Blocking

formatPublicBfcacheId has an unreachable branch. The INITIAL_BFCACHE_ID is "0" (truthy), but formatPublicBfcacheId uses !value as the first check and then value === _appRouter.bfcacheId as a separate condition. The "0" sentinel is truthy, so the !value check won't catch it — but value === _appRouter.bfcacheId will. However, the intent would be clearer (and less fragile if the internal sentinel ever changes) with an explicit check. See the inline comment.

Non-blocking observations

  1. useRouter() referential stability change. This PR changes useRouter() from returning a stable singleton to a useMemo-ed spread. Components in user code that depend on Object.is(router1, router2) across renders will break. This is the correct behavior for Next.js 15.3+ parity, and the docstring is updated accordingly, but it's worth calling out in release notes.

  2. cloneHistoryState is a shallow clone. The bfcache ids value read via readHistoryStateBfcacheIds() goes through cloneHistoryState which does Object.entries() at the top level only. The inner __vinext_bfcacheIds object is a direct reference from the original state. Since readHistoryStateBfcacheIds builds a fresh ids object from the entries, this is safe — just noting it for future maintainers.

  3. Module-level nextBfcacheId counter. The counter is global to the browser entry module, which is correct since bfcache ids are only used in a single Vite environment. The Math.max in rememberBfcacheId ensures monotonic advancement even under concurrent navigation starts.

  4. isBfcacheSegmentId calls parseElementKey on every invocation. This runs for every key in collectBfcacheSegmentIds and for every key during readHistoryStateBfcacheIds validation. For typical route trees (5-20 segments) this is negligible, but it's worth noting as a potential optimization target if route trees grow large.

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

function formatPublicBfcacheId(value: string | null | undefined): string {
if (!value || value === _appRouter.bfcacheId) return "_b_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.

The logic here is subtly correct but easy to misread.

!value catches null, undefined, and "" (all falsy). value === _appRouter.bfcacheId catches "0" (the internal sentinel, which is truthy). The two conditions handle different cases.

However, if INITIAL_BFCACHE_ID ever changes from "0" to something else, or _appRouter.bfcacheId drifts, this breaks silently. I'd prefer making the sentinel comparison explicit:

Suggested change
if (!value || value === _appRouter.bfcacheId) return "_b_0_";
if (!value || value === "0") return "_b_0_";

This makes it obvious that the internal "0" sentinel is mapped to the public "_b_0_" format, and doesn't depend on _appRouter.bfcacheId remaining "0". Alternatively, import INITIAL_BFCACHE_ID from app-browser-state and compare against that.

| 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 state — the nextBfcacheId counter resets to 0 on every full page load. That's fine for the browser environment, but worth a brief comment noting that this is intentionally scoped to a single browser document lifetime and resets are safe because the version-gating mechanism in app-browser-entry.ts prevents cross-document id collisions.

const currentValue = currentIdentity === nextIdentity ? options.current[id] : undefined;
// History traversals restore persisted ids first, matching segments keep
// their current id, and newly-created segments mint a fresh opaque id.
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.

The priority chain restored > currentValue > mint is the core invariant and it's correct. One thing I want to confirm: when restored contains an id for a segment whose identity has changed (e.g., [group] layout at /x now at /y), the restored id still wins because options.restored?.[id] is checked before currentValue. Is this intentional for traversals?

I believe the answer is yes — traversal should restore the exact ids from the target history entry regardless of identity matching, because those ids were correct when the entry was created. The identity-matching logic only applies to currentValue (the "carry forward" case). If that's the intent, a brief comment would help future readers.

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

function BfcacheSlotBoundary({ content, id }: { content: React.ReactNode; id: string }) {
const SegmentContext = BfcacheSegmentIdContext!;
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-null assertion is safe here because BfcacheSlotBoundary is only rendered when BfcacheSegmentIdContext is truthy (line 230). But if someone moves this component or calls it independently, it'll crash. Consider a defensive guard:

Suggested change
const SegmentContext = BfcacheSegmentIdContext!;
const SegmentContext = BfcacheSegmentIdContext;
if (!SegmentContext) return <>{content}</>;
return <SegmentContext.Provider value={id}>{content}</SegmentContext.Provider>;

readHistoryStateTraversalIndex(window.history.state) ?? 0;
let nextHistoryTraversalIndex: number = currentHistoryTraversalIndex;

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

When readHistoryStateBfcacheVersion(state) returns null (no version in history state) and currentBfcacheVersion is 0, this returns true. That means on a completely fresh page load (no prior bfcache version in history), any history entry with bfcache ids but no version field would be treated as current-version.

Is that the intended behavior? I think it is — the only way to have ids without a version is if they were written before version-gating was introduced, and treating them as version 0 (the fresh-load default) is reasonable. Just want to confirm this edge case was considered.

}
}

if (metadata.bfcacheIds !== 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.

When bfcacheIds is non-null but empty ({}), both the ids key and the version key are deleted. This is clean — empty maps are equivalent to no bfcache state. But note that bfcacheVersion handling on lines 75-81 runs independently, so if someone passes bfcacheIds: {} with bfcacheVersion: 5, the version is first deleted (line 69) then re-written (line 77). The final state is correct (version is present but ids are absent, then version is written back), but the interaction is a bit surprising.

Considering the empty-ids branch already deletes the version key, the version block at line 75 should probably be guarded to skip when ids were explicitly cleared. Not a bug, just a code clarity nit.

}
}

function createActiveSlotIdentity(id: string, metadata: AppElementsMetadata | null): string | null {
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: createActiveSlotIdentity addresses the intercepted-route identity concern from the earlier review by checking activeRouteId from slot bindings first, falling back to the interception metadata. This means /photo/42/photo/43 intercepted into the same @modal slot correctly produces different identities.

One edge case: if activeSlotBinding?.activeRouteId is an empty string (falsy), it falls through to the interception check. Empty string seems unlikely from encodeRouteId, but a strict !== null && !== undefined check would be more robust than truthiness.

@@ -0,0 +1,256 @@
/**
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.

Excellent test coverage. The attribution comment linking to the original Next.js test file is appreciated per the AGENTS.md guidelines.

One gap I noticed: there's no test for "hard reload on a non-initial history entry preserves the reloaded entry's fresh ids while ensuring back navigation gets fresh (not stale pre-reload) ids." The "uses the hydration bfcacheId after hard reload" test (line 43) covers this partially — it reloads on /x/2, verifies it gets _b_0_, then goes back to /x/1 and verifies a fresh minted id — but it doesn't verify that the reloaded /x/2 entry's id survives if you navigate forward to it again.

This was flagged in the PR comments by NathanDrake2406. The version-bumping mechanism should handle it correctly, but an explicit test would close the loop.

const [treeStateValue, setTreeStateValue] = useState<AppRouterState | Promise<AppRouterState>>(
() => ({
activeOperation: null,
bfcacheIds: createInitialBfcacheIdMap(resolvedElements),
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 change: switching from a direct object to a lazy initializer () => ({...}) for useState. This avoids re-creating the initial state object on every render (only matters if BrowserRoot re-renders before mounting, but it's a correct React pattern).

const mergedElements = mergeElements(state.elements, action.elements, {
clearAbsentSlots: action.type === "traverse",
preserveAbsentSlots: commit.decision.preserveAbsentSlots,
preserveElementIds: commit.decision.preserveElementIds,
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.

Clean separation: compute mergedElements first, then reconcile bfcache ids against the merged element set. This correctly handles the case where element merging preserves segments from the previous state that aren't in the new action's bfcache id map.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 26, 2026

Review posted on PR #1588 with 10 inline comments. Here's a summary of the key findings:

Architecture: Well-layered and clean. History persistence, id lifecycle, browser integration, and rendering are properly separated across app-history-state.ts, app-browser-state.ts, app-browser-entry.ts, and the slot/navigation shims.

One item to resolve before merge:

  • formatPublicBfcacheId at navigation.ts:1595 — the sentinel comparison value === _appRouter.bfcacheId works but is fragile. Suggested replacing with an explicit value === "0" check to decouple from the singleton's property.

Non-blocking observations:

  1. useRouter() referential stability change (singleton → useMemo spread) is correct for Next.js 15.3+ parity but should be noted in release notes.
  2. The restored > currentValue > mint priority chain in createNextBfcacheIdMap is correct — restored ids from traversals intentionally override identity-matching, which is the right behavior for back/forward navigation.
  3. BfcacheSlotBoundary has a non-null assertion on BfcacheSegmentIdContext! that's safe in context but fragile if the component is extracted — suggested a defensive guard.
  4. The version-gating mechanism (currentBfcacheVersion bump on hard reload) correctly handles the NathanDrake2406 concern about cross-document id collisions, though an additional E2E test for "forward after reload" would strengthen coverage.
  5. Test coverage is thorough: 12 unit tests, updated integration tests, 10 E2E Playwright tests covering all documented scenarios including intercepted routes.

github run

@manNomi manNomi force-pushed the fix/use-router-bfcache-id-semantics-v2 branch from 9f8d07e to 0c373bf Compare May 26, 2026 14:38
@manNomi
Copy link
Copy Markdown
Contributor Author

manNomi commented May 26, 2026

@james-elicx Would it be okay to trigger another /bigbonk review ?

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

Review: useRouter bfcacheId semantics (v2)

This is a well-structured implementation. The layering is clean — history persistence (app-history-state), id lifecycle (app-browser-state), browser integration (app-browser-entry), rendering (slot + navigation shims) — and the separation of concerns makes each piece independently testable. The test coverage is strong: 12 unit tests for the id map algorithm, integration tests for reducer-level preservation, and 10 E2E Playwright tests.

The version-gating mechanism for hard reload safety is sound. The redirect-clearing across all three redirect paths (cached, streamed, server-action) is consistent. The preserveBfcacheIdsForMergedElements post-merge reconciliation correctly bounds the map to the merged element set.

I have a few items below, roughly in order of importance.

Items to address

  1. Hydration on non-initial history entry discards persisted ids (inline at app-browser-entry.ts:887). This was raised in the PR thread. The current code always calls createInitialBfcacheIdMap(resolvedElements) on hydration, so a hard reload on a non-initial entry resets to "0" and persists that map, potentially colliding with the initial entry's _b_0_. The version bump at line 244 mitigates cross-document collisions for traversals, but the hydration entry itself still gets a stale-looking "0" instead of preserving its pre-reload minted id. The E2E test at spec line 43 asserts this reset to _b_0_ is expected — is this intentional parity with Next.js, or a known gap? If intentional, a brief comment at line 887 explaining why would help.

  2. createActiveSlotIdentity truthiness check (inline at app-browser-state.ts:207). Empty string from activeRouteId would fall through to the interception check. Strict nullish check would be more robust.

  3. BfcacheSlotBoundary non-null assertion (inline at slot.tsx:125). Safe in context but fragile if the component is ever extracted.

Non-blocking observations

  1. useRouter() referential stability change. Switching from singleton return to useMemo spread is correct for Next.js 15.3+ parity, but components relying on Object.is(router1, router2) across renders will break. Worth noting in release notes.

  2. Module-level nextBfcacheId counter resets to 0 on every full page load. The Math.max in rememberBfcacheId ensures monotonic advancement even under concurrent navigation starts. The version-gating mechanism prevents cross-document collisions. This is correct.

  3. isBfcacheSegmentId calls parseElementKey per invocation. For typical route trees (5-20 segments) this is negligible, but worth noting as an optimization target if trees grow large.

  4. Forward-after-reload E2E gap. The hard reload test (spec line 43) verifies reload on /x/2 → back to /x/1 gets a fresh id, but doesn't verify that forward back to /x/2 preserves the reloaded entry's id. The version-bumping mechanism should handle it, but an explicit test would strengthen coverage.

const [treeStateValue, setTreeStateValue] = useState<AppRouterState | Promise<AppRouterState>>(
() => ({
activeOperation: null,
bfcacheIds: createInitialBfcacheIdMap(resolvedElements),
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 always seeds bfcache ids from createInitialBfcacheIdMap (all "0"), even when hydrating a non-initial history entry that has persisted bfcache ids from a prior soft navigation.

The version bump at line 244 prevents traversal restoration of stale ids, which is correct. But the hydration entry itself gets "0" and then persists that map to history state in the layout effect at line 949. This means the reloaded entry loses its pre-reload identity.

Is this intentional Next.js parity (i.e., Next.js also resets to _b_0_ on hard reload of any entry)? If so, a brief comment here would help future readers understand the design decision:

Suggested change
bfcacheIds: createInitialBfcacheIdMap(resolvedElements),
bfcacheIds: createInitialBfcacheIdMap(resolvedElements), // Intentional: hard reload resets to hydration sentinel, matching Next.js behavior. Version-gating prevents cross-document traversal collisions.

If it's not intentional parity, the fix would be to seed from readCurrentBfcacheVersionHistoryIds(window.history.state) when present (filtered to the current element set), falling back to createInitialBfcacheIdMap. But the version bump means persisted ids will always be version-gated out here, so that would require special-casing the initial version check. Either way, documenting the choice is the ask.


function createActiveSlotIdentity(id: string, metadata: AppElementsMetadata | null): string | null {
const activeSlotBinding = metadata?.slotBindings.find((binding) => binding.slotId === id);
if (activeSlotBinding?.activeRouteId) {
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 uses truthiness to check activeSlotBinding?.activeRouteId. If encodeRouteId ever returns an empty string (falsy), this falls through to the interception check instead of using the binding's route id. Empty string seems unlikely from encodeRouteId, but a strict nullish check would be more defensive:

Suggested change
if (activeSlotBinding?.activeRouteId) {
if (activeSlotBinding?.activeRouteId != null && activeSlotBinding.activeRouteId !== '') {

Or simply != null if empty string is truly impossible from the wire format.

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

function BfcacheSlotBoundary({ content, id }: { content: React.ReactNode; id: string }) {
const SegmentContext = BfcacheSegmentIdContext!;
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-null assertion is safe here because BfcacheSlotBoundary is only rendered when BfcacheSegmentIdContext is truthy (line 230). But if this component is ever moved or used independently, it will crash with a confusing error. A defensive guard costs nothing:

Suggested change
const SegmentContext = BfcacheSegmentIdContext!;
const SegmentContext = BfcacheSegmentIdContext;
if (!SegmentContext) return <>{content}</>;
return <SegmentContext.Provider value={id}>{content}</SegmentContext.Provider>;

const currentValue = currentIdentity === nextIdentity ? options.current[id] : undefined;
// History traversals restore persisted ids first, matching segments keep
// their current id, and newly-created segments mint a fresh opaque id.
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.

The priority chain restored > currentValue > mint is the core invariant. For traversals, restored wins even if the segment identity has changed (e.g., [group] layout at /x restored to /y's entry). This is correct — traversal should restore exact ids from the target history entry regardless of identity matching, because those ids were correct when the entry was created.

A brief comment documenting this would help future readers:

Suggested change
const value = options.restored?.[id] ?? currentValue ?? mintBfcacheId();
// History traversals restore persisted ids first, matching segments keep
// their current id, and newly-created segments mint a fresh opaque id.
// Restored ids intentionally win over identity-matching: the target entry's
// ids were authoritative when that entry was created, and traversal must
// faithfully restore them even if the segment's identity has since changed.
const value = options.restored?.[id] ?? currentValue ?? mintBfcacheId();

Comment on lines 66 to +69
}
}

if (metadata.bfcacheIds !== 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.

Nit: when bfcacheIds is non-null but empty ({}), lines 68-69 delete both the ids key and the version key. Then the version block at line 75 runs independently — if bfcacheVersion was also passed (e.g., bfcacheIds: {}, bfcacheVersion: 5), it would re-write the version key at line 77 after it was just deleted.

The final state is correct (version is present without ids, which is harmless), but the interaction is surprising. Consider guarding the version write against the empty-ids case, or adding a comment noting this is intentional.

let nextHistoryTraversalIndex: number = currentHistoryTraversalIndex;

function isCurrentBfcacheVersion(state: unknown): boolean {
return (readHistoryStateBfcacheVersion(state) ?? 0) === currentBfcacheVersion;
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.

When readHistoryStateBfcacheVersion(state) returns null (no version field) and currentBfcacheVersion is 0, this returns true. That means on a completely fresh page load, any history entry with bfcache ids but no version field is treated as current-version.

This is a narrow edge case (only happens if ids were written before version-gating was introduced), and treating them as version 0 is reasonable. Just want to confirm this was considered — if old pre-version-gating entries exist in user session history, they'll be restored as current, which may or may not be desirable.

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

The module-level nextBfcacheId counter resets to 0 on every full page load. This is correct for the browser environment (single document lifetime), and the version-gating mechanism in app-browser-entry.ts prevents cross-document id collisions. A brief comment noting this scoping would help:

Suggested change
let nextBfcacheId = 0;
// Monotonic counter scoped to the current browser document lifetime. Resets on
// full page load, which is safe because version-gating in app-browser-entry.ts
// prevents cross-document bfcache id collisions.
let nextBfcacheId = 0;

@@ -0,0 +1,256 @@
/**
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 test coverage here is thorough — traversal, redirect, hard reload, hash-only, intercepted routes, server actions, and programmatic push/replace are all covered.

One gap: no test for "forward after reload preserves the reloaded entry's id." The hard reload test (line 43) verifies reload → back gives a fresh id, but doesn't verify that going forward back to the reloaded entry restores the post-reload _b_0_ id (rather than minting yet another fresh one). The version-bumping mechanism should handle this correctly, but an explicit assertion would close the loop.

Also appreciate the Next.js test attribution comment at line 3 per AGENTS.md guidelines.

Comment on lines 356 to +359
const override = resolveSlotOverride(slotKey, slot.name);
const slotId = resolveAppPageSlotId(slot, layoutEntry?.treePath ?? "/");
const state = resolveAppPageSlotBindingState(slot, override);
const activeRouteId =
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.

Clean implementation: activeRouteId is only populated for state === "active" slots, distinguishing interception targets from regular route ids via encodeRouteId. This correctly addresses the intercepted-route identity concern from the earlier review, where /photo/42/photo/43 in the same @modal slot would incorrectly share a bfcache id.

Comment on lines +154 to +163
const mergedElements = mergeElements(state.elements, action.elements, {
clearAbsentSlots: action.type === "traverse",
preserveAbsentSlots: commit.decision.preserveAbsentSlots,
preserveElementIds: commit.decision.preserveElementIds,
preservePreviousSlotIds: commit.decision.preservePreviousSlotIds,
});
return commitVisibleRouterState(
state,
{
elements: mergeElements(state.elements, action.elements, {
clearAbsentSlots: action.type === "traverse",
preserveAbsentSlots: commit.decision.preserveAbsentSlots,
preserveElementIds: commit.decision.preserveElementIds,
preservePreviousSlotIds: commit.decision.preservePreviousSlotIds,
bfcacheIds: preserveBfcacheIdsForMergedElements({
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 refactor: computing mergedElements first, then reconciling bfcache ids against the merged set. This correctly handles the case where element merging preserves segments from the previous state that aren't in the new action's bfcache id map. The separation is clean and makes the bfcache id scope obvious — the final map is always bounded by the merged element keys.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 26, 2026

Review posted on PR #1588 with 10 inline comments. Here's a summary:

Architecture: Well-layered and clean. History persistence, id lifecycle, browser integration, and rendering are properly separated. The version-gating mechanism for hard reload safety, redirect-clearing across all three redirect paths, and preserveBfcacheIdsForMergedElements post-merge reconciliation are all well-designed.

Items to address (3):

  1. Hydration on non-initial history entry (app-browser-entry.ts:887) — createInitialBfcacheIdMap always resets to "0" on hydration. The version bump prevents cross-document traversal collisions, but the hydration entry itself loses its pre-reload identity. If this is intentional Next.js parity, it needs a comment explaining the design choice.

  2. createActiveSlotIdentity truthiness check (app-browser-state.ts:207) — empty string from activeRouteId would fall through to the interception check. Strict nullish check would be more robust.

  3. BfcacheSlotBoundary non-null assertion (slot.tsx:125) — safe in context but fragile if extracted. Suggested a defensive guard.

Non-blocking observations (4):

  • useRouter() referential stability change (singleton → useMemo spread) is correct for Next.js 15.3+ parity but should be noted in release notes
  • Module-level nextBfcacheId counter scoping is correct; suggested a clarifying comment
  • isBfcacheSegmentId calls parseElementKey per invocation — negligible for typical trees but a future optimization target
  • Forward-after-reload E2E test gap — the hard reload test doesn't verify that going forward back to the reloaded entry preserves its post-reload id

github run

@manNomi manNomi force-pushed the fix/use-router-bfcache-id-semantics-v2 branch from 0c373bf to 63129fe Compare May 26, 2026 15:33
@manNomi manNomi changed the title Implement useRouter bfcacheId semantics Implement App Router bfcacheId semantics May 26, 2026
@manNomi manNomi changed the title Implement App Router bfcacheId semantics feat(app-router): support useRouter bfcacheId semantics May 26, 2026
@chatgpt-codex-connector
Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Chef's kiss.

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

@manNomi
Copy link
Copy Markdown
Contributor Author

manNomi commented May 26, 2026

@james-elicx Would it be okay to trigger one final /bigbonk review?

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

Review: useRouter bfcacheId semantics (v2)

The architecture is well-designed and the layering is clean. History persistence (app-history-state), id lifecycle (app-browser-state), browser integration (app-browser-entry), and rendering (slot + navigation shims) have good separation of concerns. The version-gating mechanism for hard reload safety is sound. The redirect-clearing across all three redirect paths is consistent. The preserveBfcacheIdsForMergedElements post-merge reconciliation correctly bounds the map to the merged element set.

Test coverage is thorough: 12 unit tests for the id map algorithm, integration tests for reducer-level preservation, and 10 Playwright E2E tests covering traversal, redirect, hard reload, hash-only, intercepted routes, server actions, and programmatic push/replace. The Next.js test attribution at the top of the E2E spec follows AGENTS.md guidelines. The catch-all segment handling (from the earlier Codex review) is addressed.

I have a few items below, ordered by importance.

Items to address

  1. formatPublicBfcacheId sentinel check couples to the singleton (inline at navigation.ts:1610). The "0" string literal is already correct there, but appRouterInstance.bfcacheId is also "0" — making it ambiguous whether the check is against the sentinel constant or the singleton's property. Import INITIAL_BFCACHE_ID from app-browser-state (it's already "0") or add a constant locally to make intent clear.

  2. createActiveSlotIdentity truthiness check (inline at app-browser-state.ts:210). If encodeRouteId ever produces empty string, the fallthrough would silently use the interception check instead of the binding's route id. Strict nullish coalescing would be more robust.

  3. BfcacheSlotBoundary non-null assertion (inline at slot.tsx:125). The current code is safe because the caller gates on BfcacheSegmentIdContext being truthy, but if the component is extracted or reused, it will crash. A defensive guard costs nothing.

  4. Hydration on non-initial history entry always resets to "0" (inline at app-browser-entry.ts:891). This was raised by NathanDrake2406 and addressed by ask-bonk. The version bump prevents cross-document traversal collisions, but the hydration entry itself loses its pre-reload identity. The E2E test at spec line 61 asserts _b_0_ after reload, confirming this is the intended behavior. A brief inline comment documenting this as intentional parity would prevent future churn.

Non-blocking observations

  1. Module-level nextBfcacheId counter scoping. Already has a good comment block (lines 147-149), but noting for completeness — the Math.max in rememberBfcacheId + version-gating make this safe.

  2. areBfcacheIdMapsEqual is O(n) on every syncCurrentHistoryStatePreviousNextUrl call. For typical route trees (5-20 segments) this is negligible, but documenting the expectation would help if someone uses this for a deep tree.

  3. Forward-after-reload E2E gap. The hard reload test (spec line 43) was updated to cover forward-after-reload (line 73-76), which closes the loop raised in the thread. Good.

  4. useRouter() referential stability change. Switching from singleton return to useMemo spread is correct for Next.js 15.3+ parity, but components relying on Object.is(router1, router2) will see new references. Worth noting in release notes.

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

function formatPublicBfcacheId(value: string | null | undefined): string {
if (!value || value === "0") return "_b_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.

The "0" literal is correct and already decouples from _appRouter.bfcacheId, which addresses the concern from the earlier ask-bonk review. But the relationship between this "0" and the INITIAL_BFCACHE_ID constant in app-browser-state.ts is implicit — if one changes, the other silently breaks.

Consider importing the constant to make the coupling explicit:

Suggested change
if (!value || value === "0") return "_b_0_";
function formatPublicBfcacheId(value: string | null | undefined): string {
if (!value || value === INITIAL_BFCACHE_ID) return "_b_0_";
return value;
}

(with import { INITIAL_BFCACHE_ID } from "../server/app-browser-state.js" — or just export it from app-history-state.ts where the type already lives.)

Alternatively, a local constant const INITIAL_BFCACHE_ID = "0" would also work. The goal is to avoid silent drift.

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.

Addressed in 55135824 by moving the internal zero sentinel into shared bfcache id constants and using INITIAL_BFCACHE_ID / PUBLIC_INITIAL_BFCACHE_ID in the navigation shim, so the public formatting can no longer drift from the runtime sentinel.


function createActiveSlotIdentity(id: string, metadata: AppElementsMetadata | null): string | null {
const activeSlotBinding = metadata?.slotBindings.find((binding) => binding.slotId === id);
if (activeSlotBinding?.activeRouteId != null && activeSlotBinding.activeRouteId !== "") {
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 != null && !== "" guard was added per earlier review feedback — good. One nit: the != null check already covers undefined (via loose equality), but the empty-string check uses strict equality. This is fine logically but reads a bit mixed. If you want consistency, either:

if (activeSlotBinding?.activeRouteId !== undefined && activeSlotBinding.activeRouteId !== null && activeSlotBinding.activeRouteId !== '')

or keep the current form (which is correct). Not blocking.

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 addressed: createActiveSlotIdentity now uses a strict guard for activeRouteId (!= null plus an empty-string check) before falling back to interception metadata.

}

function BfcacheSlotBoundary({ content, id }: { content: React.ReactNode; id: string }) {
const SegmentContext = BfcacheSegmentIdContext;
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 non-null assertion BfcacheSegmentIdContext! is safe here because the caller at line 231 checks BfcacheSegmentIdContext is truthy before rendering this component. But if BfcacheSlotBoundary is ever extracted or called from a different site, it'll crash with a confusing error.

A defensive guard costs nothing and prevents future surprises:

Suggested change
const SegmentContext = BfcacheSegmentIdContext;
function BfcacheSlotBoundary({ content, id }: { content: React.ReactNode; id: string }) {
const SegmentContext = BfcacheSegmentIdContext;
if (!SegmentContext) return <>{content}</>;
return <SegmentContext.Provider value={id}>{content}</SegmentContext.Provider>;
}

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 addressed: BfcacheSlotBoundary now reads the context into a local SegmentContext and returns the content unchanged when the context is unavailable, avoiding the non-null assertion.

// in-memory CacheNode/Activity tree. Hydrate the new document on the
// zero sentinel and rely on the document-scoped bfcache version gate to
// reject stale ids persisted by previous documents.
bfcacheIds: createInitialBfcacheIdMap(resolvedElements),
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 always seeds from createInitialBfcacheIdMap (all "0"), even when hydrating a non-initial history entry. The version bump at line 244 ensures traversals into this entry won't collide, and the E2E test at spec line 61 asserts _b_0_ is the expected post-reload value.

The existing comment on lines 887-890 is good but doesn't explicitly state that this is intentional Next.js parity. Adding a brief note would prevent future engineers from "fixing" this:

Suggested change
bfcacheIds: createInitialBfcacheIdMap(resolvedElements),
bfcacheIds: createInitialBfcacheIdMap(resolvedElements), // Intentional: hard reload resets to hydration sentinel, matching Next.js behavior.

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.

Addressed in 55135824: the hydration comment now explicitly calls this intentional Next.js parity and explains that hard reload starts a new browser document on the zero sentinel while the document-scoped bfcache version gate rejects stale ids from previous documents.

// Restored ids intentionally win over identity-matching: the target entry's
// ids were authoritative when that entry was created, and traversal must
// faithfully restore them even if the segment's identity has since changed.
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.

The comment here is clear and correct. The restored > currentValue > mint priority chain is the core invariant and the comment documents why restored wins over identity-matching for traversals. Well done — this was one of the trickier design decisions and the explanation helps future readers.

}
}

if (metadata.bfcacheVersion !== 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.

Nit: when bfcacheIds is non-null but empty ({}), lines 70-72 delete both the ids key and the version key. Then this bfcacheVersion block runs independently — if bfcacheVersion was also passed (e.g., bfcacheIds: {}, bfcacheVersion: 5), line 82 would re-write the version key after it was just deleted at line 72.

The final state is correct (version ends up present without ids, which is harmless — and the unit test at app-browser-entry.test.ts:4374 covers this exact case, asserting both are cleaned up). But the intermediate write-then-overwrite is slightly surprising.

Consider guarding:

if (metadata.bfcacheVersion !== undefined && !bfcacheIdsWereCleared) {

Not blocking since the test already covers the correct final state.

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 addressed: createHistoryStateWithNavigationMetadata now tracks bfcacheIdsWereCleared and skips/re-clears the version field when ids are explicitly cleared. The regression is covered by the empty bfcacheIds unit test.

let nextHistoryTraversalIndex: number = currentHistoryTraversalIndex;

function isCurrentBfcacheVersion(state: unknown): boolean {
return (readHistoryStateBfcacheVersion(state) ?? 0) === currentBfcacheVersion;
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.

When readHistoryStateBfcacheVersion(state) returns null and currentBfcacheVersion is 0, this returns true. This means pre-version-gating history entries (which lack the version field) are treated as current-version on a fresh page load.

This is a narrow edge case — only possible if ids were written before version-gating was introduced — and treating them as version 0 is reasonable. The behavior is sound; just flagging that this was considered.

const override = resolveSlotOverride(slotKey, slot.name);
const slotId = resolveAppPageSlotId(slot, layoutEntry?.treePath ?? "/");
const state = resolveAppPageSlotBindingState(slot, override);
const activeRouteId =
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.

Clean implementation: activeRouteId is only populated for state === "active" slots, and the interception target distinction via encodeRouteId correctly handles the intercepted-route identity concern from the earlier review (where /photo/42/photo/43 in the same @modal would incorrectly share a bfcache id).

expect(postReloadBackBfcacheId).not.toBe(x1BfcacheId);
await expect(visibleTestId(page, "leaf-input")).not.toHaveValue("x2-state-after-reload");

await page.goForward();
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: this forward-after-reload assertion at lines 73-76 closes the coverage gap that was flagged in the PR thread. It verifies that navigating forward back to the reloaded entry preserves the post-reload _b_0_ id rather than minting yet another fresh one.

Comment thread tests/shims.test.ts
// the mounted router instance as the source of method behavior and only layer
// contextual bfcacheId on top.
// Ported from: https://github.com/vercel/next.js/blob/canary/packages/next/src/client/components/navigation.ts
it("useRouter() preserves methods from the mounted AppRouterContext router", async () => {
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.

Excellent test — this verifies that useRouter() preserves custom methods from the mounted AppRouterContext router (not just the singleton's methods) while layering bfcacheId on top. This directly addresses the concern from NathanDrake2406's earlier review about context ownership.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 27, 2026

Review posted successfully on PR #1588 with 10 inline comments. Here's a summary:

Architecture: Well-layered and clean. History persistence, id lifecycle, browser integration, and rendering are properly separated. The version-gating mechanism, redirect-clearing across all three redirect paths, and preserveBfcacheIdsForMergedElements post-merge reconciliation are all well-designed.

Items to address (4):

  1. formatPublicBfcacheId sentinel check at navigation.ts:1610 — the "0" literal should be tied to the INITIAL_BFCACHE_ID constant to prevent silent drift
  2. createActiveSlotIdentity truthiness check at app-browser-state.ts:210 — strict nullish check would be more robust
  3. BfcacheSlotBoundary non-null assertion at slot.tsx:125 — defensive guard suggested
  4. Hydration reset comment at app-browser-entry.ts:891 — should document that the "0" reset on hard reload is intentional Next.js parity

Non-blocking observations: module-level counter scoping is correct, areBfcacheIdMapsEqual O(n) is fine for typical trees, forward-after-reload E2E gap is now covered, and useRouter() referential stability change should be noted in release notes.

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.

Review: useRouter bfcacheId semantics

This is a well-designed feature with clean layering and thorough test coverage. The architecture separates concerns effectively:

  • app-bfcache-id.ts — shared constants (the simplest possible shared module)
  • app-history-state.ts — serialization/deserialization of bfcache state in history entries
  • app-browser-state.ts — identity derivation, minting, and map lifecycle
  • app-browser-entry.ts — version-gating, browser integration, and state threading
  • slot.tsx + navigation.ts — React context plumbing and public API surface

The version-gating mechanism for hard reload safety is sound. The three-way redirect clearing (cached, streamed, server-action) is consistent. The preserveBfcacheIdsForMergedElements post-merge reconciliation correctly bounds the map to the merged element set. The E2E suite ports the Next.js test scenarios well and adds coverage for programmatic navigation and intercepted routes.

I have a few observations and one substantive concern. The rest are minor or non-blocking.

Substantive

  1. readBfcacheIdFromContext swallows all exceptions (navigation.ts:1627). The try/catch around useContext calls catches everything and returns the fallback sentinel. This means if the context providers are present but a bug causes useContext to throw for a reason other than "called outside render" (e.g., a React internal invariant violation), the error is silently swallowed and the component renders with _b_0_ instead of surfacing the bug. Consider narrowing the catch or at least logging in dev.

Non-blocking

  1. SSR entry doesn't provide BfcacheSegmentIdContext (app-ssr-entry.ts:306-312). The SSR entry provides BfcacheIdMapContext but not the per-segment BfcacheSegmentIdContext. This means readBfcacheIdFromContext() during SSR will always fall through to the null path and return _b_0_. This is likely intentional (SSR doesn't have segment-level bfcache ids), but a brief comment in app-ssr-entry.ts would make that explicit.

  2. createHashOnlyHistoryStatePreservingNavigationMetadata omits traversalIndex (app-history-state.ts:152-166). The hash-only preservation function reads and carries forward previousNextUrl, bfcacheIds, and bfcacheVersion, but drops the traversalIndex. Compare with commitHashOnlyNavigation in app-browser-entry.ts:298 which handles traversal index separately via allocateNavigationHistoryTraversalIndex. This is fine because the two code paths serve different purposes (one is for the browser entry, the other for the navigation shim's direct hash commits), but the asymmetry is easy to miss.

  3. collectBfcacheSegmentIds silently catches metadata errors (app-browser-state.ts:252-258). The empty catch when reading metadata means partial element maps (from tests) don't blow up, but it also means corrupted metadata in production would silently produce incomplete bfcache id maps. The comment says "Some low-level tests pass partial element maps" — this is fine for now but consider making the catch dev-only or logging if you add more segment sources later.

  4. areBfcacheIdMapsEqual iterates a but not b (app-browser-entry.ts:504-510). The function checks aEntries.length === bEntries.length then only iterates a to verify values match in b. Since the lengths are equal, this is correct — every key in a must exist in b with the same value, and since b has the same number of entries, there can't be extra keys. But at first glance it looks like it only checks one direction. A brief comment noting the length check makes this sufficient would help.

Good work overall. The feedback from prior reviews has been addressed well — useRouter() correctly reads from AppRouterContext, the public format uses shared constants, and the forward-after-reload E2E gap is closed.

} catch {
// Low-level tests and direct module calls can hit this outside render.
return formatPublicBfcacheId(null);
}
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 catch-all silently swallows any exception from useContext, not just the "called outside render" case. If the contexts are wired up but React throws an internal invariant (e.g., a hook ordering violation in a caller that happens to use readBfcacheIdFromContext), this hides the real error and quietly returns _b_0_.

Consider at minimum logging in dev:

Suggested change
}
} catch (error) {
// Low-level tests and direct module calls can hit this outside render.
if (process.env.NODE_ENV !== "production") {
console.warn("[vinext] readBfcacheIdFromContext failed:", error);
}
return formatPublicBfcacheId(null);
}

{ value: createInitialBfcacheIdMap(elements) },
routeTree,
)
: routeTree;
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 SSR entry provides BfcacheIdMapContext but not BfcacheSegmentIdContext. This means readBfcacheIdFromContext() during SSR will always return _b_0_ (the null fallback), which is correct since SSR doesn't have segment-level bfcache ids. A brief comment would make this intentional omission explicit and prevent someone from "fixing" it later.

Something like:

// BfcacheSegmentIdContext is intentionally omitted during SSR: per-segment
// bfcache ids are a browser-only concern. useRouter().bfcacheId returns the
// hydration sentinel during SSR, matching Next.js behavior.

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.

Nit: the empty catch here means corrupted metadata in production would silently produce an incomplete bfcache id map (only the direct element keys, not the layoutIds). The comment explains the test motivation, which is fine. But if you later add more metadata-derived segment sources, this catch could mask real issues.

Not blocking — just flagging for future awareness.

return null;
}

return createHistoryStateWithNavigationMetadata(null, {
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 function preserves previousNextUrl, bfcacheIds, and bfcacheVersion from the current history state but intentionally omits traversalIndex. The browser entry's commitHashOnlyNavigation handles the traversal index separately via allocateNavigationHistoryTraversalIndex, while the navigation shim's commitHashOnlyHistoryState calls this function directly.

The asymmetry is correct (two different code paths for two different callers), but it's subtle. Consider adding a brief comment noting that traversal index is handled by the caller in the browser entry path.

if (aEntries.length !== bEntries.length) return false;
return aEntries.every(([key, value]) => b[key] === 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 iterates only a's entries and checks them against b, relying on the length equality check to ensure completeness. This is correct — if a and b have the same number of entries, and every key-value in a exists in b, then a === b as maps. But on first reading it looks like a one-directional subset check.

A one-line comment would help:

Suggested change
return aEntries.every(([key, value]) => b[key] === value); // length check above guarantees bidirectional equality

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 27, 2026

Review posted successfully on PR #1588 with 5 inline comments. Here's a summary:

Architecture: Clean and well-layered. The separation across app-bfcache-id.ts (constants), app-history-state.ts (serialization), app-browser-state.ts (identity/minting), app-browser-entry.ts (version-gating/integration), and slot.tsx/navigation.ts (React context/API) is solid. Version-gating for hard reload safety, three-way redirect clearing, and post-merge bfcache id reconciliation are all well-designed.

Substantive concern:

  • readBfcacheIdFromContext (navigation.ts:1627) swallows all exceptions silently, not just the "called outside render" case. Suggested logging in dev to avoid masking real bugs.

Non-blocking observations:

  1. SSR entry provides BfcacheIdMapContext but intentionally omits BfcacheSegmentIdContext — a comment would prevent someone from "fixing" it
  2. createHashOnlyHistoryStatePreservingNavigationMetadata omits traversalIndex (correct, but the asymmetry with commitHashOnlyNavigation is subtle)
  3. collectBfcacheSegmentIds silently catches metadata errors — fine for tests but could mask production issues
  4. areBfcacheIdMapsEqual one-directional iteration is correct (length check guarantees bidirectional equality) but reads like a subset check — suggested a clarifying comment

github run

@manNomi manNomi force-pushed the fix/use-router-bfcache-id-semantics-v2 branch from c87b723 to f0ff09d Compare May 27, 2026 12:39
@manNomi
Copy link
Copy Markdown
Contributor Author

manNomi commented May 27, 2026

@james-elicx could you run /bigbonk review once more when you have a chance?

@james-elicx
Copy link
Copy Markdown
Member

/bigbonk review

@manNomi
Copy link
Copy Markdown
Contributor Author

manNomi commented May 28, 2026

Addressed the functional concern around the segmentId truthiness check.

readBfcacheIdFromContext now uses a strict nullish check (segmentId !== null) before looking up the bfcache id map, so an empty string segment id is treated as a valid context key instead of falling back to the initial sentinel. I also added a focused useRouter() unit test covering the empty-string context key case.

Targeted local verification passed:

pnpm exec vp check packages/vinext/src/shims/navigation.ts tests/shims.test.ts
pnpm exec vp test run --project unit tests/shims.test.ts -t "useRouter"

@manNomi manNomi force-pushed the fix/use-router-bfcache-id-semantics-v2 branch from 2bcf94f to 4b309da Compare May 29, 2026 06:09
@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.

Review: useRouter bfcacheId semantics

This is a well-designed feature implementation that has clearly benefited from multiple rounds of thoughtful review feedback. The architecture is clean and the layering is well-considered:

  • app-bfcache-id.ts — shared constants (minimal, the right abstraction)
  • app-history-state.ts — serialization/deserialization with validation and version-gating
  • app-browser-state.ts — identity derivation, minting, map lifecycle
  • app-browser-entry.ts — version-gating integration, browser state threading
  • slot.tsx + navigation.ts — React context plumbing and public API surface

Strengths

  • The restored > currentValue > mint priority chain in createNextBfcacheIdMap is the right design for back/forward navigation semantics, and it's well-documented.
  • Version-gating for hard reload safety (currentBfcacheVersion = initialVersion + 1) is sound and avoids cross-document id collisions without requiring complex bookkeeping.
  • Redirect clearing is consistent across all three paths (cached, streamed, server-action).
  • preserveBfcacheIdsForMergedElements correctly scopes the bfcache id map to the merged element set, preventing stale slot ids from leaking across reducer-level element merges.
  • The isHistoryStateNavigationMetadataInSync extraction is clean and eliminates the sync-predicate duplication that was flagged.
  • Test coverage is thorough: unit tests for identity matching (catch-all, intercepted slots, reducer-level preservation, monotonic counter), history serialization round-trips, and 10 E2E Playwright tests covering the full behavioral surface.
  • The E2E fixture design is clever — LinkAccordion avoids prefetch-triggered navigations, and the redirect test simulates a stale history entry with pushState to test traverse redirect behavior.

Minor observations (non-blocking)

A few inline comments below — nothing blocking, just notes for future maintainers. The prior review feedback has been comprehensively addressed.

Good work overall. This is ready to merge.

// Some low-level tests pass partial element maps without metadata.
}

return Array.from(ids).filter(isBfcacheSegmentId);
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: collectBfcacheSegmentIds iterates Object.keys(elements) and then filters through isBfcacheSegmentId, which calls AppElementsWire.parseElementKey per key. For a typical route tree this is negligible, but this function is called from both createInitialBfcacheIdMap and createNextBfcacheIdMap (the latter calls it twice — once for current, once for next elements, via the collectBfcacheSegmentIds in createBfcacheSegmentIdentity). If someone later adds deeply nested parallel routes, the O(n × parse) cost could add up.

Not worth changing now, but worth noting for future optimization if route trees grow large — a cached Set<string> per element map or a fast prefix check could replace the parseElementKey calls.

let nextHistoryTraversalIndex: number = currentHistoryTraversalIndex;

function isCurrentBfcacheVersion(state: unknown): boolean {
return (readHistoryStateBfcacheVersion(state) ?? 0) === currentBfcacheVersion;
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: when readHistoryStateBfcacheVersion(state) returns null and currentBfcacheVersion is 0 (a completely fresh page load with no prior bfcache history), this returns true — meaning pre-version-gating history entries (with ids but no version field) are treated as current-version.

This is the correct behavior for the bootstrap edge case (there's no way to have ids without a version except from before version-gating existed), and readCurrentBfcacheVersionHistoryIds correctly returns null when ids themselves are absent, so it's safe in practice. A one-line comment noting this edge case was considered would help future readers, but not blocking.

// Restored ids intentionally win over identity-matching: the target entry's
// ids were authoritative when that entry was created, and traversal must
// faithfully restore them even if the segment's identity has since changed.
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.

The comment here is excellent. The restored > currentValue > mint priority chain is the trickiest invariant in this implementation, and the explanation makes the design decision immediately clear to future readers. Well done.

try {
const segmentId = React.useContext(segmentContext);
const idMap = React.useContext(idMapContext);
return formatPublicBfcacheId(segmentId !== null ? idMap?.[segmentId] : null);
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: this now uses segmentId !== null (strict nullish) rather than truthiness, correctly handling the empty-string segment id case. The unit test at shims.test.ts:158 covers this explicitly.

preserveAbsentSlots: commit.decision.preserveAbsentSlots,
preserveElementIds: commit.decision.preserveElementIds,
preservePreviousSlotIds: commit.decision.preservePreviousSlotIds,
bfcacheIds: preserveBfcacheIdsForMergedElements({
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.

Clean refactor: computing mergedElements before preserveBfcacheIdsForMergedElements makes the scope obvious — the final bfcache id map is always bounded by the merged element keys, so stale slot ids that were cleared during element merge are also dropped. The variable extraction makes this two-phase process readable.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 29, 2026

Review posted successfully on PR #1588 with an APPROVE and 5 inline comments. Here's the summary:

Architecture: Clean five-layer separation — shared constants, serialization, identity/minting, browser integration, React context/API. The design decisions are sound and well-documented after multiple review iterations.

No blocking items. All prior review feedback has been comprehensively addressed:

  • useRouter() correctly reads from AppRouterContext (not the singleton)
  • Public format uses shared INITIAL_BFCACHE_ID / PUBLIC_INITIAL_BFCACHE_ID constants
  • BfcacheSlotBoundary has a defensive guard
  • Hydration reset is documented as intentional Next.js parity
  • readBfcacheIdFromContext uses strict nullish check for segmentId
  • Sync-predicate duplication extracted into isHistoryStateNavigationMetadataInSync
  • Forward-after-reload E2E gap is closed

Inline observations (all non-blocking):

  1. collectBfcacheSegmentIds O(n × parse) is fine for typical trees, noted as future optimization target
  2. isCurrentBfcacheVersion null/0 edge case is correctly handled
  3. The restored > currentValue > mint priority comment is excellent documentation
  4. segmentId !== null strict nullish check with unit test coverage is good
  5. mergedElements variable extraction makes the two-phase bfcache id scoping readable

github run

@james-elicx
Copy link
Copy Markdown
Member

/bigbonk re-review for issues and next.js parity

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 29, 2026

@james-elicx Bonk workflow was cancelled.

View workflow run · To retry, trigger Bonk again.

@james-elicx
Copy link
Copy Markdown
Member

/bigbonk re-review for issues and next.js parity

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

/bigbonk re-review for issues and next.js parity

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: Next.js parity and correctness

I've compared this implementation against the Next.js source at .nextjs-ref (packages/next/src/client/components/navigation.ts, ppr-navigations.ts, segment-cache/bfcache.ts, app-router-context.shared-runtime.ts) and the reference test suite at test/e2e/app-dir/use-router-bfcache-id/.

Architecture parity assessment

The vinext implementation uses a fundamentally different mechanism than Next.js but achieves equivalent user-visible semantics:

Concern Next.js vinext (this PR)
Storage bfcacheId: number on each CacheNode in the router state tree BfcacheIdMap (Record<string, string>) keyed by element wire keys
Generation ++nextBFCacheId (monotonic counter, 0 for hydration) mintBfcacheId() returning _b_N_ strings (monotonic counter, "0" sentinel for hydration)
Context scoping LayoutRouterContext.parentCacheNode.bfcacheId (per-CacheNode) BfcacheSegmentIdContext (per-slot boundary) + BfcacheIdMapContext (tree-wide map)
Public format '_b_' + number + '_' computed in useRouter() formatPublicBfcacheId() — same format
History persistence Stored in segment-cache BFCache data store (not history state) Stored in window.history.state.__vinext_bfcacheIds
Identity matching compareSegments() with SegmentMatchKind enum createBfcacheSegmentIdentity() with pathname prefix matching

The architectural difference in history persistence is notable: Next.js stores bfcache data in an in-memory segment cache (bfcache.ts) and reads it during traversals. Vinext stores the id map directly in history state, which is correct for the Cloudflare Workers deployment target where there's no persistent in-memory cache across navigations. The version-gating mechanism compensates for the cross-document risks this introduces.

Behavioral parity

The PR correctly implements the core Next.js semantics:

  • Fresh push/replace → new bfcacheId ✓
  • Back/forward → restored bfcacheId ✓
  • router.refresh() → same bfcacheId ✓
  • Search-param-only change → same bfcacheId ✓
  • Hash-only change → same bfcacheId ✓
  • Shared layout across sibling leaves → same layout bfcacheId ✓
  • Different dynamic group → new layout bfcacheId ✓
  • Hard reload → reset to _b_0_ (matches Next.js hydration sentinel) ✓
  • Server action refresh → same bfcacheId ✓

Items found

I have two parity concerns to flag, a question about non-bfcache changes bundled in this PR, and a test coverage suggestion. See inline comments.

Minor note on non-bfcache changes

The cached redirect lifecycle hop handling (around line 1538 of app-browser-entry.ts) and the server-action redirect currentHistoryMode/currentPrevNextUrl updates (around line 1800) appear to be new navigation behavior that isn't specific to bfcacheId. If these are fixing pre-existing bugs, they should be called out in the PR description to make the scope clear.

...router,
bfcacheId,
}),
[router, bfcacheId],
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.

Next.js parity note: useRouter() spreads the entire context router, but Next.js enumerates specific method names.

Next.js's useRouter() at packages/next/src/client/components/navigation.ts:193-204 explicitly lists the method names it returns:

return useMemo(() => ({
  back: router.back,
  forward: router.forward,
  refresh: router.refresh,
  hmrRefresh: router.hmrRefresh,
  push: router.push,
  replace: router.replace,
  prefetch: router.prefetch,
  experimental_gesturePush: router.experimental_gesturePush,
  bfcacheId: '_b_' + bfcacheIdNumber + '_',
}), [router, bfcacheIdNumber])

Vinext uses { ...router, bfcacheId } which includes any extra properties on the context router value. The unit test at shims.test.ts:62 actually tests this "preserves custom methods" behavior as a feature — but it's a divergence from Next.js, which does not preserve extra properties.

This is a minor divergence that's arguably more permissive than Next.js. Not blocking, but worth noting in a code comment or compatibility note that the spread approach leaks internal properties to userland (e.g., any future private properties added to appRouterInstance).

metadata: nextMetadata,
pathname: options.nextPathname,
});
const currentValue = currentIdentity === nextIdentity ? options.current[id] : 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.

Next.js parity note: identity matching uses pathname prefix vs. Next.js's direct segment comparison.

Next.js determines whether a segment changed via compareSegments(newSegment, oldSegment) which compares the segment values directly from the flight response tree. Vinext's createBfcacheSegmentIdentity derives identity from getTreePathIdentityPrefix(pathname, treePath) — which computes how many pathname segments the tree path consumes, then takes that prefix of the actual pathname.

This works correctly for the tested cases, and the existing comment at line 220-223 ("Legacy bridge...") correctly acknowledges this is a temporary approach. No action needed now, just confirming the design boundary is understood and documented.

One thing worth testing in the future: route groups with multiple dynamic segments at different tree depths where the identity prefix length matters (e.g., /(group)/[a]/[b]/layout.tsx navigating between /x/1 and /x/2 — the [b] change should mint a new id for this layout).

performHardNavigationForScrollIntent(compatibilityDecision.hardNavigationTarget);
return;
}
const cachedRedirectDecision = resolveRscRedirectLifecycleHop({
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.

Question: is this cached redirect lifecycle hop handling new non-bfcache behavior?

This block (lines 1538-1564) adds redirect handling for cached (visited-response) routes, including redirect resolution, max-redirect guard, and currentHref/currentHistoryMode/currentPrevNextUrl updates. The restoredBfcacheIds = null clearing at line 1557 is correct for bfcache purposes.

However, the redirect lifecycle hop resolution itself appears to be new navigation behavior. Was this cached redirect path previously missing entirely, or was it handled differently before? If this is fixing a pre-existing gap (cached routes that redirect were not followed), that's a separate concern from bfcacheId and should be noted in the PR description for reviewer clarity.

@@ -0,0 +1,261 @@
/**
* 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.

Next.js test parity gap: missing form-state preservation assertions.

The Next.js reference test at the linked URL has a test preserves leaf form state on browser back navigation that types into the leaf input, navigates away, goes back, and asserts the input retains its typed value. This relies on React Activity (cacheComponents: true) preserving actual DOM state.

Vinext doesn't have Activity yet, so back navigation remounts the form (same key → same identity, but no DOM preservation). The vinext test at line 23 correctly tests bfcacheId restoration but not form-state preservation, which is the right call for now.

Two suggestions:

  1. Add a comment noting that Next.js's form-state-preservation tests are omitted because vinext doesn't implement Activity yet.
  2. The search-param navigation test at line 121 should also test form-state preservation (type into input, navigate to ?q=2, assert input retains value) — this should work even without Activity, since the bfcacheId doesn't change and React won't remount the <form>.

</ParallelSlotsContext.Provider>
);

return BfcacheIdMapContext && BfcacheSegmentIdContext ? (
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.

Two-level context indirection is clean.

The Slot component wraps every element in a BfcacheSlotBoundary that sets BfcacheSegmentIdContext to the element's wire key. Then readBfcacheIdFromContext looks up that key in BfcacheIdMapContext (the tree-wide map). This is a clean design — the context tree naturally gives each segment the nearest key.

The defensive guard in BfcacheSlotBoundary (line 126) is appreciated.

for (const value of Object.values(options.current)) {
rememberBfcacheId(value);
}
for (const value of Object.values(options.restored ?? {})) {
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 but correct: rememberBfcacheId is called before the minting loop to advance the counter past all known ids.

This ensures mintBfcacheId() on line 304 produces ids strictly greater than anything in current or restored. The second rememberBfcacheId(value) at line 306 handles the case where a restored/carried-forward value is used directly (not minted).

Correct, but the intent could be clearer. Consider a brief comment on these lines, e.g.:

Suggested change
for (const value of Object.values(options.restored ?? {})) {
// Advance the monotonic counter past all known ids before minting.
// This ensures freshly minted ids never collide with existing or
// restored values.
for (const value of Object.values(options.current)) {
rememberBfcacheId(value);
}

const state = resolveAppPageSlotBindingState(slot, override);
const activeRouteId =
state === "active"
? options.interception?.slotId === slotId
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: interception target is distinguished from regular route via targetRouteId vs encodeRouteId.

When the slot is the interception target, activeRouteId uses the interception's targetRouteId (which includes the interception context suffix). Otherwise, it uses encodeRouteId(routePath, null). This ensures /photo/42 and /photo/43 intercepted into the same @modal slot produce distinct identities in createActiveSlotIdentity.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 29, 2026

Review posted successfully on PR #1588 with 7 inline comments. Here's the summary:

Overall assessment: The implementation is architecturally sound and achieves behavioral parity with Next.js's useRouter().bfcacheId semantics through a different underlying mechanism (history-state-persisted id maps vs. CacheNode-stored ids). The version-gating, redirect clearing, and element-merge reconciliation are all well-designed.

Key findings:

  1. useRouter() spread vs. explicit enumeration — Next.js explicitly lists method names in the returned object; vinext uses { ...router, bfcacheId } which leaks any extra context router properties. Minor divergence, not blocking.

  2. Identity matching approach — vinext uses pathname prefix matching instead of Next.js's direct segment comparison. The "Legacy bridge" comment correctly acknowledges this is temporary. Works for all tested cases.

  3. Non-bfcache changes bundled in the PR — The cached redirect lifecycle hop handling and server-action redirect currentHistoryMode/currentPrevNextUrl updates appear to be new navigation behavior beyond bfcacheId. Flagged for PR description clarity.

  4. E2E test coverage suggestion — The search-param navigation test should also verify form-state preservation (type → navigate to ?q=2 → assert input retains value), and Next.js's Activity-based form-state-on-back tests should be noted as intentionally omitted.

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.

3 participants