Skip to content

fix(app-router): restore intercepted history slots#2363

Merged
james-elicx merged 2 commits into
mainfrom
codex/fix-parallel-catchall-navigation
Jun 26, 2026
Merged

fix(app-router): restore intercepted history slots#2363
james-elicx merged 2 commits into
mainfrom
codex/fix-parallel-catchall-navigation

Conversation

@james-elicx

Copy link
Copy Markdown
Member

Summary

  • restore previously intercepted parallel-slot worlds during browser history traversal even when the current route is the slot's catch-all sibling
  • keep forward navigation interception source validation unchanged
  • add a local App Router regression for modal → catch-all page → back → new catch-all navigation

Next.js parity

Fixes the remaining failure from run 28143992598 in:

  • test/e2e/app-dir/parallel-routes-catchall-specificity/parallel-routes-catchall-specificity.test.ts

Next.js restores the exact router tree stored in browser history. Vinext incorrectly required the restored interception source to match the currently visible catch-all route, rejected the snapshot, and fell back to a document navigation that lost the modal.

Reference: https://github.com/vercel/next.js/blob/v16.2.6/test/e2e/app-dir/parallel-routes-catchall-specificity/parallel-routes-catchall-specificity.test.ts

Validation

  • vp test run tests/navigation-planner.test.ts tests/app-browser-history-controller.test.ts --maxWorkers=1 — 58 passed
  • local App Router browser regression, one worker — 1 passed
  • exact Next.js v16.2.6 targeted deploy suite, concurrency 1 — 1 passed
  • targeted vp check — passed before disposable dependency links were removed

Excludes cacheComponents, PPR, resume, fallback-shell, and external endpoint failures.

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@pkg-pr-new

pkg-pr-new Bot commented Jun 25, 2026

Copy link
Copy Markdown

Open in StackBlitz

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

commit: 8f75dae

@github-actions

Copy link
Copy Markdown
Contributor
Example Preview Production Original
app-router-cloudflare preview production
pages-router-cloudflare preview production
app-router-playground preview production original
realworld-api-rest preview production
nextra-docs-template preview production
benchmarks preview production
hackernews preview production original
workers-cache preview production
web preview production

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review: restore intercepted history slots

Reviewed the fix end-to-end. The change is small, correctly scoped, and well-tested. The core fix is sound: during traverse (browser back/forward), Next.js restores the exact router tree stored in history, so requiring the proof's interception source to match the currently visible route (which may legitimately be the catch-all sibling) is wrong. Gating only the source-identity check on lane !== "traverse" while leaving every other validation in place is the right surgical fix.

What I verified

  • Bypass is correctly scoped. Only the interceptedRejectedUnknownSource source-identity check is skipped for traverse. All structural guards still run on traverse: missing-proof, targetMatchedUrl match, manifest-declared interception, layout-ancestor persistence, and the active/owned slot-binding check. So a traverse can't restore a structurally invalid intercepted world — it can only trust the stored proof's source, which matches Next.js "restore the exact stored tree" semantics.
  • Forward navigation unchanged. The existing "rejects intercepted preservation when the visible source route is stale" test (navigation lane) still rejects with interceptedRejectedUnknownSource. Confirmed all 43 planner tests pass.
  • Single source of truth. validateInterceptedPreservation is the only place this logic lives; it's invoked from the environment-agnostic planner, so there's no dev/prod parity gap to mirror.
  • Type check + targeted tests pass. tsc --noEmit clean; vitest run tests/navigation-planner.test.ts → 43 passed.
  • Fixtures/e2e consistent. Port 4174 + waitForAppRouterHydration match the rest of the suite; the modal → catch-all page → back → catch-all-sibling flow maps to the new unit test scenario.

Suggestions (non-blocking)

  1. Add an inline comment explaining the lane gate. options.lane !== "traverse" is the entire fix and is non-obvious to a future reader. Per this repo's convention of documenting deliberate Next.js parity decisions, a one-line comment would help, e.g.:

    // Browser history traversal restores the exact router tree stored in history.
    // The currently visible route may be the catch-all sibling rather than the
    // original interception source, so the source-identity match (which only
    // applies to forward navigation) must be skipped here. Structural proof
    // (manifest topology + slot bindings) is still enforced below.
  2. Minor: getVisibleInterceptionSourceIdentity is still computed on the traverse path where its result is unused. It's a cheap pure call so not worth restructuring, but you could move it inside the if for clarity.

Overall this looks correct and matches the cited Next.js v16.2.6 test. LGTM with the comment suggestion.

@ask-bonk

ask-bonk Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Review posted on PR #2363.

Summary

The change is correct, narrowly scoped, and well-tested. The fix gates only the interception source-identity check (interceptedRejectedUnknownSource) behind lane !== "traverse", while leaving all structural validations intact.

Findings:

  • No correctness or security issues. Forward navigation source validation is unchanged; the existing stale-source rejection test still passes (it uses the navigation lane).
  • The bypass is sound for Next.js parity — history traversal restores the exact stored router tree, and the proof's stored source is the authority, not the currently-visible catch-all sibling.
  • Structural guards (manifest topology, slot bindings, layout ancestor persistence, target match) still run on traverse, so an invalid tree can't be restored.
  • Single source of truth (validateInterceptedPreservation in the shared planner) — no dev/prod parity gap.
  • Type check clean; all 43 planner tests pass.

Non-blocking suggestions:

  1. Add an inline comment explaining the lane !== "traverse" gate (it's the entire fix and non-obvious).
  2. Minor: getVisibleInterceptionSourceIdentity is computed but unused on the traverse path.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review: restore intercepted history slots

Reviewed end-to-end against the cited Next.js v16.2.6 test. The fix is correct, narrowly scoped, and well-tested. LGTM.

Note: this is the second commit (8f75dae, "scope interception restore proof") and the implementation now differs meaningfully from how the earlier ask-bonk review described it. The earlier review said the gate is options.lane !== "traverse". The actual code does not gate on the lane — it threads a new restoredHistorySnapshot flag from the history-restore commit producer through to the planner. This is a strictly better design, and the difference matters (see below).

Why the restoredHistorySnapshot flag is the right approach (not a lane gate)

A lane === "traverse" gate would have bypassed the source-identity check for every traverse, including non-history-restore traverse flows that legitimately should still validate the visible source. The actual fix only sets the flag in createRestoredHistorySnapshotCommit (app-browser-navigation-controller.ts:621), which is the single producer of a restored-history-snapshot commit. So the bypass applies precisely to "browser restored the exact stored router tree," which is the only case where requiring the proof's source to match the currently-visible catch-all sibling is wrong.

The new unit test rejects a fresh traverse response from an unrelated interception source (uses lane: "traverse" without restoredHistorySnapshot) proves exactly this distinction: a traverse that is not a restored history snapshot still rejects with interceptedRejectedUnknownSource. This is a sharper guarantee than a lane-based gate would give.

What I verified

  • Bypass scope. Only the interceptedRejectedUnknownSource source-identity check is skipped (navigation-planner.ts:1310). Every structural guard still runs on the restored snapshot: missing-proof, targetMatchedUrl match, manifest-declared interception, layout-ancestor persistence, the active/owned slot-binding check, and the declared-interception owner match. A structurally invalid restored tree is still rejected; when approvePendingNavigationCommit returns approvedCommit === null, restoreHistorySnapshotVisibleState returns false and the caller falls back to document navigation (app-browser-navigation-controller.ts:651). So the worst case for an invalid snapshot is unchanged (hard nav), and the best case (valid stored modal world) is now correctly restored.
  • Flag wiring is clean end-to-end. PendingNavigationCommit.restoredHistorySnapshot (app-browser-state.ts:130) → propagated into FlightResult in planPendingRootBoundaryFlightResponse (app-browser-state.ts:469) → consumed in planFlightResponseArrived via options.event.result.restoredHistorySnapshot === true (navigation-planner.ts:1470). Optional field, defaults to falsy, no behavior change for existing callers.
  • Single source of truth. validateInterceptedPreservation is the only place this logic lives and is invoked from the environment-agnostic planner — no dev/prod parity gap to mirror across dev-server/prod-server/worker-entry.
  • Tests pass locally. vitest run tests/navigation-planner.test.ts → 44 passed; tests/app-browser-history-controller.test.ts → 15 passed.

Fixture parity note (not a bug — flagging for the record)

The original Next.js fixture places comments, u, trending at the app root and uses the @modal/(...)comments interception convention (intercept-from-root). The vinext fixture nests everything under app/parallel-catchall-specificity/ (since app-basic is a shared app tree) and therefore correctly uses @modal/(.)comments (intercept-same-segment). The convention change from (...) to (.) is the correct consequence of the nesting, not a divergence — comments is now a sibling of @modal in the same segment. Semantics are preserved.

The e2e spec also adds one assertion the upstream test lacks: after goBack() it asserts the Modal heading is visible (line 21). That extra assertion is the actual regression guard for this fix, so it's a welcome strengthening.

Non-blocking suggestions

  1. Lazy-evaluate the unused source identity. getVisibleInterceptionSourceIdentity (navigation-planner.ts:1308) is now computed unconditionally even when restoredHistorySnapshot is true, where its result is unused. It's a cheap pure call, but per this repo's hot-path guidance ("avoid repeated hot-path work"), moving it inside the if (!options.restoredHistorySnapshot && ...) would both avoid the dead compute and read more clearly.
  2. Add an inline comment on the bypass. The !options.restoredHistorySnapshot condition is the entire fix and is non-obvious. Per this repo's convention of documenting deliberate Next.js-parity decisions, a one-liner pointing at "history traversal restores the exact stored tree; the visible route may be the catch-all sibling, so source-identity is skipped here while structural proof is still enforced below" would help future readers.

Overall: correct, surgical, and matches the cited Next.js v16.2.6 behavior.

@github-actions

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared 8f75dae against base de014d4 using alternating same-runner rounds. Next.js was unchanged and skipped.

0 improved · 0 regressed · 6 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 117.1 KB 117.1 KB ⚫ +0.0%
Client entry size (gzip) vinext 112.5 KB 112.5 KB ⚫ +0.0%
Dev server cold start vinext 2.44 s 2.41 s ⚫ -1.1%
Production build time vinext 2.78 s 2.79 s ⚫ +0.7%
RSC entry closure size (gzip) vinext 93.0 KB 93.0 KB ⚫ -0.0%
Server bundle size (gzip) vinext 156.9 KB 157.0 KB ⚫ +0.0%

View detailed results and traces

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

@ask-bonk

ask-bonk Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Review posted on PR #2363.

Summary

The fix is correct, narrowly scoped, and well-tested. No correctness or security issues found. LGTM.

Key finding: The implementation in the latest commit (8f75dae) differs from how the earlier ask-bonk review described it. The earlier review claimed the gate is options.lane !== "traverse". The actual code does not gate on the lane — it threads a new restoredHistorySnapshot flag from the single history-restore commit producer (createRestoredHistorySnapshotCommit) through PendingNavigationCommitFlightResultvalidateInterceptedPreservation. This is strictly better: a lane gate would have bypassed source-identity validation for all traverses, whereas the flag scopes the bypass precisely to "browser restored the exact stored router tree."

What I verified:

  • The new unit test rejects a fresh traverse response from an unrelated interception source proves the distinction — a traverse lane without restoredHistorySnapshot still rejects with interceptedRejectedUnknownSource.
  • Only the source-identity check is skipped; all structural guards (target-URL match, manifest topology, layout-ancestor persistence, slot bindings, declared-interception owner) still run. Invalid restored snapshots still fall back to document navigation.
  • Flag wiring is clean and optional (defaults falsy, no behavior change for existing callers); single source of truth, no dev/prod parity gap.
  • Tests pass locally: navigation-planner (44) + app-browser-history-controller (15).
  • Fixture parity note (not a bug): the original Next.js fixture uses (...)comments at app root; vinext nests under a subpath so it correctly uses (.)comments — the convention change is a correct consequence of the nesting, not a divergence.

Non-blocking suggestions:

  1. Lazy-evaluate getVisibleInterceptionSourceIdentity (now computed even when unused on the restored-snapshot path).
  2. Add an inline comment explaining the !restoredHistorySnapshot bypass.

github run

@james-elicx james-elicx marked this pull request as ready for review June 25, 2026 18:44
@james-elicx james-elicx merged commit cab6622 into main Jun 26, 2026
58 checks passed
@james-elicx james-elicx deleted the codex/fix-parallel-catchall-navigation branch June 26, 2026 20:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant