Skip to content

fix(app-router): supersede restored history traversals#2368

Open
james-elicx wants to merge 5 commits into
mainfrom
codex/fix-interception-forward-restore-race
Open

fix(app-router): supersede restored history traversals#2368
james-elicx wants to merge 5 commits into
mainfrom
codex/fix-interception-forward-restore-race

Conversation

@james-elicx

Copy link
Copy Markdown
Member

Summary

  • make approved in-memory App Router history restores commit synchronously
  • supersede the already-started async traversal after a snapshot restore so its live response cannot overwrite the restored tree
  • add an isolated production regression for a two-level interception restored via forward navigation

Next.js parity

Fixes the remaining failure from run 28143992598 in:

  • test/e2e/app-dir/interception-segments-two-levels-above/interception-segments-two-levels-above.test.ts

This is stacked on #2363 because it relies on that PR's restored-history interception proof. #2363 handles planner approval; this PR fixes the later lifecycle race where the network traversal could still win after the snapshot was visibly restored.

Reference: https://github.com/vercel/next.js/blob/v16.2.6/test/e2e/app-dir/interception-segments-two-levels-above/interception-segments-two-levels-above.test.ts

Validation

  • production isolated browser fixture, one worker — passed repeatedly
  • focused browser/history/planner unit tests — 265 passed
  • targeted vp check — passed
  • exact Next.js v16.2.6 deploy suite, concurrency 1 — 3/3 passed

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

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@github-actions

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

@ask-bonk

ask-bonk Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Review: fix(app-router): supersede restored history traversals

I traced the full lifecycle (popstate → restoreHistoryStateSnapshotrestoreHistorySnapshotVisibleStatedispatchSynchronousVisibleCommitapprovePendingNavigationCommit) and ran the relevant unit suites. The fix is correct and well-layered. One non-blocking documentation issue.

The fix is sound

The supersede now has two independent guards working together, which is good defense-in-depth:

  1. beginNavigation() (app-browser-entry.ts:358) bumps activeNavigationId after the synchronous restore commits. The in-flight async traversal captured the old navId and re-checks isCurrentNavigation(navId) at every await boundary (e.g. app-browser-entry.ts:1699,1713,1755), so it bails out before it can dispatch.
  2. visibleCommitVersion bumpapplyApprovedVisibleCommit increments visibleCommitVersion (app-browser-visible-commit.ts:108), and verifyOperationTokenForCommit rejects any stale traversal whose startedVisibleCommitVersion is behind (app-browser-state.ts:296-312). Even if a traversal slips past the nav-id checks, this catches it.

Both guards independently neutralize the live response, so the restored tree wins. The existing comment at app-browser-state.ts:300-306 already documents exactly this interaction.

flushSync change is correct (incl. the nested case)

dispatchSynchronousVisibleCommit (app-browser-navigation-controller.ts:582) now wraps the setter in flushSync. I verified the nested-flushSync case the e2e test exercises (outer flushSync at app-browser-entry.ts:343 → inner at controller :582) does not trigger React 19's "flushSync was called from inside a lifecycle method" warning: that warning (react-dom.development.js:140-143) only fires when React is actively rendering in the finally check, and neither call here runs during render/commit. Confirmed flushSync is imported (:8) and all 206 app-browser-entry tests pass.

This is also a genuine correctness fix for the server-action caller at :854, which runs in an async continuation after await resolveAndClassifyNavigationCommit (:810) — outside any synchronous event-handler context — so it needs flushSync to force the commit out, just like the gesture-commit path.

Issue: stale comment now contradicts the code (non-blocking)

app-browser-navigation-controller.ts:562-567 still describes the old behavior:

// This is intentionally distinct from dispatchSynchronousVisibleCommit
// below: that path's callers (HMR, history traversal) already run inside a
// synchronous event-handler/effect context where React flushes the plain
// setter itself, whereas the gesture commit fires after an await inside a
// held async transition and must be forced out with flushSync. Don't
// consolidate the two.

After this PR, dispatchSynchronousVisibleCommit no longer uses a "plain setter" — it uses flushSync too. The stated rationale for the distinction ("React flushes the plain setter itself", "Don't consolidate the two") is now inaccurate, and the claim that its callers "already run inside a synchronous event-handler/effect context" was already wrong for the server-action caller. Please update this comment so it doesn't mislead future readers into thinking the two paths still differ in flush strategy.

Test quality

The new isolated-fixture browser spec follows the established pattern in tests/e2e/app-router/nextjs-compat/*.browser.spec.ts (same linkFixtureNodeModules/closeServer/build-and-serve boilerplate). The per-file duplication is pre-existing repo convention, not introduced here. The test is correctly ported with a link back to the Next.js source, and the assertion sequence (intercept → back → forward → re-intercept) directly reproduces the race.

Verification

  • tests/app-browser-entry.test.ts — 206 passed
  • tests/navigation-planner-rsc-fetch-result.test.ts — 16 passed

@ask-bonk

ask-bonk Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2368.

Summary

I reviewed PR #2368 (fix(app-router): supersede restored history traversals) by tracing the full popstate/restore lifecycle and running the relevant unit suites.

Verdict: the fix is correct and well-layered. One non-blocking documentation issue.

Key findings

  1. The supersede mechanism is sound — two independent guards work together:

    • beginNavigation() bumps activeNavigationId, so the in-flight traversal bails at its next isCurrentNavigation(navId) check.
    • applyApprovedVisibleCommit bumps visibleCommitVersion, which verifyOperationTokenForCommit uses to reject any stale traversal that slips past.
  2. The flushSync change is correct, including the nested case the e2e test exercises (outer flushSync at app-browser-entry.ts:343 → inner at controller :582). I verified against React 19's source that this does not trigger the "flushSync from inside a lifecycle method" warning, since neither call runs during render/commit. It's also a real correctness fix for the async server-action caller at :854.

  3. Issue (non-blocking): stale comment. app-browser-navigation-controller.ts:562-567 still claims dispatchSynchronousVisibleCommit uses a "plain setter" and that the two paths should not be consolidated — but after this PR both use flushSync. The comment now contradicts the code and should be updated.

  4. Test quality is good — the new browser spec follows the established isolated-fixture pattern, is correctly ported from Next.js with a source link, and faithfully reproduces the race.

Verification: app-browser-entry.test.ts (206 passed), navigation-planner-rsc-fetch-result.test.ts (16 passed).

github run

@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@2368
npm i https://pkg.pr.new/vinext@2368

commit: 96591bb

@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: fix(app-router): supersede restored history traversals

I traced the popstate restore lifecycle (popstatehandlePopstate/navigaterestoreHistoryStateSnapshotbeginNavigation + dispatchSynchronousVisibleCommit) and ran the affected unit suites. The fix is correct. Verification: tests/app-browser-entry.test.ts (206 passed), tests/navigation-planner-rsc-fetch-result.test.ts (16 passed), vp check on both changed source files (clean). The stale-comment that an earlier /bigbonk pass flagged at app-browser-navigation-controller.ts:562-567 is already fixed by the second commit (5b22513), so the controller comment now matches the code.

My one substantive finding is a documentation gap at the new beginNavigation() call site — the comment that explains the supersede mechanism predates this PR's primary change and no longer describes how the supersede actually works.

Why beginNavigation() is the actual fix (and why the existing comment is now stale)

The big comment at app-browser-entry.ts:2082-2100 explains the supersede as relying solely on the visibleCommitVersion bump: the async traverse captures startedVisibleCommitVersion = N, the synchronous restore bumps it to N+1, and when the traverse resolves verifyOperationTokenForCommit rejects it as a staleOperation.

But this PR adds a second, earlier, and now-primary supersede: restoreHistoryStateSnapshot now calls browserNavigationController.beginNavigation() (app-browser-entry.ts:358), bumping activeNavigationId. Tracing the ordering shows this guard fires before the version guard is ever reached:

  1. handlePopstate(event) (:2081) calls navigate(), which runs beginNavigation() synchronously (app-browser-navigation-controller.ts-fed path at app-browser-entry.ts:1491), capturing navId = X.
  2. For a traverse, navigate() hits its first await at app-browser-entry.ts:1539 (waitForBrowserRouterStateReady) before it ever calls renderNavigationPayload/captures startedState. So it yields here.
  3. Control returns to the popstate handler; restoreHistoryStateSnapshot(event.state) runs synchronously, commits the cached snapshot (version NN+1), then calls beginNavigation() again → activeNavigationId becomes X+1.
  4. The traverse microtask resumes and checks isCurrentNavigation(X) at app-browser-entry.ts:1540false → it returns early, before capturing startedState or rendering any payload.

So with this fix the in-flight traverse typically bails at step 4 (the navId guard) and never reaches the startedVisibleCommitVersion !== currentState.visibleCommitVersion check that the :2082-2100 comment describes. The version bump is now the backstop (defense-in-depth for a traverse that slipped past the navId check), not the mechanism the comment presents it as.

Request: update the comment at app-browser-entry.ts:2082-2100 so it documents the beginNavigation() supersede as the primary mechanism (the navId bump invalidating the in-flight traversal at its next isCurrentNavigation(navId) boundary), with the visibleCommitVersion bump as the secondary guard. As written, a future reader debugging this path would look only for a version mismatch and be confused why the traverse already returned at :1540. Given the PR title is literally "supersede restored history traversals," the mechanism that does the superseding deserves a comment at its call site (:358).

Other things I checked (all OK)

  • flushSync on all three dispatchSynchronousVisibleCommit callers. HMR (:506), restore (:656), and server-action (:852) all invoke it from async continuations (after await createPendingNavigationCommit / await resolveAndClassifyNavigationCommit), not during React render/commit, so flushSync won't trip React 19's "flushSync from inside a lifecycle method" warning. The nested case the e2e test exercises (outer flushSync at app-browser-entry.ts:343 → inner at controller :580) is also outside render. Note this is a minor behavioral change to the dev-only HMR path (previously a plain batched setter, now a forced flush) — benign for a single tree replacement, but it's worth being aware the old "React flushes the plain setter itself" rationale no longer holds for HMR either.
  • Scroll-restore interplay. Bumping activeNavigationId in the restore means the deferred scroll restore inside createPopstateRestoreHandler (gated on isCurrentNavigation(popstateNavId == X), app-browser-popstate.ts:85) is now skipped, and the synchronous restore at app-browser-entry.ts:2102 instead handles it under the new navId X+1. No double-restore or lost restore; the shouldSkipScrollRestore dedup (:2064) becomes partially redundant for this path but stays harmless.
  • Interception route shape. app/foo/bar/(..)(..)hoge/page.tsx correctly climbs two visible segments (foo, bar) to root and matches /hoge, consistent with the (..)(..) convention in routing/app-route-graph.ts:2207.

Test quality

The new isolated-fixture spec follows the established *.browser.spec.ts pattern (linkFixtureNodeModules/closeServer/createBuilder().buildApp()runPrerenderstartProdServer), is correctly ported with a source link back to the Next.js v16.2.6 fixture, and its intercept → back → forward → re-intercept sequence directly reproduces the forward-restore race. Assertions use Playwright auto-retrying toHaveText, so the cross-navigation timing is handled without manual waits.

@ask-bonk

ask-bonk Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2368.

Summary

I reviewed PR #2368 (fix(app-router): supersede restored history traversals) by tracing the popstate restore lifecycle and running the affected unit suites.

Verdict: the fix is correct. One substantive (non-blocking) documentation gap.

Key findings

  1. The fix is sound, but the comment explaining the supersede is now stale. This PR's primary change is adding beginNavigation() to restoreHistoryStateSnapshot (app-browser-entry.ts:358), which bumps activeNavigationId. I traced the ordering and found this navId guard fires first: after the restore bumps the navId, the in-flight traverse returns early at its isCurrentNavigation(navId) check (:1540) — before it ever reaches the visibleCommitVersion mismatch path that the big comment at :2082-2100 describes as the supersede mechanism. That comment predates the fix and now presents the backstop (version bump) as the primary mechanism. I asked the author to update it to document the beginNavigation() supersede, since the PR title is literally about superseding.

  2. flushSync change is correct for all three dispatchSynchronousVisibleCommit callers (HMR, restore, server-action) — all run in async continuations outside React render/commit, so React 19's lifecycle warning won't fire. Noted the dev-only HMR path now force-flushes instead of batching (benign).

  3. No scroll-restore regression — bumping the navId routes scroll restore through the synchronous path under the new navId; no double/lost restore.

  4. Test quality is good — follows the established isolated-fixture pattern, correctly ported from the Next.js v16.2.6 fixture with a source link, and faithfully reproduces the forward-restore race.

The earlier /bigbonk pass's stale-comment finding (controller :562-567) was already addressed by the second commit (5b22513).

Verification: app-browser-entry.test.ts (206 passed), navigation-planner-rsc-fetch-result.test.ts (16 passed), vp check on both changed source files (clean).

github run

@github-actions

github-actions Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Performance benchmarks

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

2 improved · 0 regressed · 4 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.70 s 2.64 s 🟢 -2.4%
Production build time vinext 3.28 s 3.22 s 🟢 -1.9%
RSC entry closure size (gzip) vinext 93.0 KB 93.0 KB ⚫ -0.0%
Server bundle size (gzip) vinext 157.0 KB 157.0 KB ⚫ -0.0%

View detailed results and traces

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

@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: fix(app-router): supersede restored history traversals

I traced the full popstate restore lifecycle and verified the supersede ordering, the flushSync change (including the nested case), and ran the affected unit suites. The fix is correct, and the prior /bigbonk stale-comment findings are now resolved by commits 5b22513 (controller :562-565) and 96591bb (entry :2086-2095). I found no outstanding issues.

Supersede ordering is correct (verified by tracing)

The primary fix — adding beginNavigation() to restoreHistoryStateSnapshot (app-browser-entry.ts:358) — works as intended, and the ordering is sound:

  1. handlePopstate (:2081) → navigate() calls beginNavigation() synchronously (app-browser-navigation-controller.ts:1491 path), capturing navId = X, then yields at its first await waitForBrowserRouterStateReady() (:1539) for a traverse — before capturing startedState or rendering.
  2. Control returns to the popstate handler; restoreHistoryStateSnapshot(event.state) runs synchronously. The visible restore commits inside the outer flushSync (:343) via the approveVisibleRestore callback → restoreHistorySnapshotVisibleStatedispatchSynchronousVisibleCommit (:656). Crucially, the restore's own approval at :642-649 uses startedNavigationId: navId (X) and is checked against activeNavigationId before the bump, so it is correctly approved.
  3. beginNavigation() (:358) then bumps activeNavigationIdX+1.
  4. The traverse microtask resumes and isCurrentNavigation(X) (:1540) is now false → returns early before it can dispatch the fetched payload.

The visibleCommitVersion bump remains as a documented backstop. The updated comment at :2086-2095 now correctly presents the navId supersede as primary and the version bump as the secondary guard.

flushSync change is correct for all three callers + the nested case

dispatchSynchronousVisibleCommit now uses flushSync (:580). Verified all three callers run in async continuations (after an await), not during React render/commit:

  • HMR (:506, after await createPendingNavigationCommit)
  • restore (:656, reached synchronously from the popstate handler outside render)
  • server-action (:852)

I confirmed against React 19.2.7 source (react-dom-profiling.development.js:28150-28168) that flushSync is a plain wrapper that sets transition/priority and runs fn(); the "flushSync was called from inside a lifecycle method" warning fires only in the finally block when ReactDOMSharedInternals.d.f() returns true while React is rendering. The nested case the e2e test exercises (outer flushSync at :343 → inner at controller :580) is benign: work is already flushed when the inner call returns, and neither runs during render. The dev-only HMR path now force-flushes instead of batching — benign for a single tree replacement.

Test quality

The new interception-two-levels.browser.spec.ts follows the established isolated-fixture pattern (linkFixtureNodeModules / createBuilder().buildApp()runPrerenderstartProdServer / closeServer), is correctly ported from the Next.js v16.2.6 fixture with a source link, and its intercept → back → forward → re-intercept sequence faithfully reproduces the forward-restore race. Assertions use Playwright auto-retrying toHaveText.

Verification

  • tests/app-browser-entry.test.ts — 206 passed
  • tests/navigation-planner-rsc-fetch-result.test.ts — 16 passed
  • vp check on both changed source files — clean (format + lint + types)

@ask-bonk

ask-bonk Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2368.

Summary

I reviewed PR #2368 (fix(app-router): supersede restored history traversals) by tracing the full popstate restore lifecycle, verifying the flushSync change against React 19's source, and running the affected unit suites.

Verdict: the fix is correct, with no outstanding issues. The stale-comment findings from earlier /bigbonk passes are already resolved by commits 5b22513 and 96591bb.

Key findings

  1. Supersede ordering is correct — I traced the exact interleaving: the traverse captures navId = X and yields at await waitForBrowserRouterStateReady() (:1539) before capturing state; the synchronous restore commits (its own approval correctly checked against the pre-bump navId at :642-649), then beginNavigation() (:358) bumps to X+1; the resumed traverse fails isCurrentNavigation(X) (:1540) and bails before dispatching. The visibleCommitVersion bump is a correct backstop.

  2. flushSync change is safe for all three callers + the nested case — verified against React 19.2.7 source (react-dom-profiling.development.js:28150-28168) that the lifecycle warning only fires when React is actively rendering, which none of the callers (HMR, restore, server-action — all async continuations) do. The nested flushSync (outer :343 → inner :580) is benign.

  3. Test quality is good — the new browser spec follows the established isolated-fixture pattern, is correctly ported from the Next.js v16.2.6 fixture with a source link, and reproduces the race.

Verification

  • tests/app-browser-entry.test.ts — 206 passed
  • tests/navigation-planner-rsc-fetch-result.test.ts — 16 passed
  • vp check on both changed source files — clean

github run

@james-elicx james-elicx marked this pull request as ready for review June 25, 2026 19:39
Base automatically changed from codex/fix-parallel-catchall-navigation to main 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