Skip to content

fix(app-router): preserve layouts across soft navigation - #2316

Merged
james-elicx merged 8 commits into
mainfrom
codex/fix-app-index-parity
Jun 28, 2026
Merged

james-elicx merged 8 commits into
mainfrom
codex/fix-app-index-parity

Conversation

@james-elicx

Copy link
Copy Markdown
Member

Summary

  • preserve approved shared layouts across sibling App Router navigation
  • restore history snapshots synchronously without replacement RSC traversal
  • use refresh semantics for revalidating Server Actions so fresh layout output is committed
  • add focused layout identity, history, revalidatePath, and revalidateTag coverage

Next.js parity impact

Targeted test/e2e/app-dir/app/index.test.ts improves from 94 passed / 9 failed / 5 skipped to 97 passed / 6 failed / 5 skipped, fixing same-layout rerender, soft back, and soft forward.

Overlap

This is intended as a narrower, reviewed replacement/superseding attempt for draft PR #2215, and overlaps #2101 history restoration. It should not merge alongside those overlapping implementations.

Validation

  • 236 focused unit tests passed
  • 17 targeted Playwright tests passed with one worker
  • focused format/lint/type checks passed
  • package build passed
  • two independent review passes; final pass had no findings

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

commit: b2d7822

@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 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: preserve layouts across soft navigation

Solid, well-tested change. The bfcache-identity–keyed preservation in reduceApprovedVisibleCommitState and the mergeElements prev-wins-for-preserved-ids change are coherent and well covered by the new reducer fixtures. One concern worth a closer look before merge, plus a couple of minor notes.

Concern: snapshot-counter release in the synchronous popstate restore path

packages/vinext/src/server/app-browser-entry.ts:365

The synchronous restore now calls commitClientNavigationState(navId). The old code called commitClientNavigationState() with no navId (so shouldReleaseSnapshot was false). Passing navId flips shouldReleaseSnapshot to true, so this now releases a navigationSnapshotActiveCount slot.

The problem is that the synchronous restore path never acquires a snapshot. restoreHistorySnapshotVisibleState (app-browser-navigation-controller.ts:626) commits via dispatchSynchronousVisibleCommit and does not call activateNavigationSnapshot() — unlike renderNavigationPayload, which pairs every activateNavigationSnapshot() (line 768) with a later commitClientNavigationState(navId) (the established balance, see app-browser-entry.ts:567).

Trace of the new flow:

  1. A prior navigation is in flight → it called activateNavigationSnapshot() (count = 1).
  2. popstate fires; restoreHistoryStateSnapshot(...) runs and, via onApprovedBeforeCommit, calls abortSupersededNavigation(). AbortController.abort() only aborts the fetch — it does not synchronously release that navigation's snapshot.
  3. commitClientNavigationState(navId) (line 365) decrements the count (count = 0), releasing a snapshot it never acquired — i.e. the prior navigation's snapshot.
  4. The aborted navigation later settles and releases its own snapshot through the commit/drain lifecycle (drainPrePaintEffects → commitClientNavigationState(..., { releaseSnapshot: true })).

That's a double-release. In the two-navigation case the state.navigationSnapshotActiveCount > 0 guard at navigation.ts:1416 masks it (it won't go negative). But with a third concurrent navigation in flight, the count is decremented one too many times, which can make useSearchParams/useParams/usePathname stop reading the render snapshot one transition early (navigation.ts:1314/1338/1364).

Note that in the old code the release was correctly deferred: handlePopstate ran first and started the async traverse that activated the snapshot, and commitClientNavigationState() (no navId) intentionally did not release — the async no-commit path released it later. The new ordering removed that async activation but kept (and strengthened) the release.

Could you confirm the intended snapshot accounting here? Options:

  • If the synchronous restore should own a snapshot slot, call activateNavigationSnapshot() before the commit so the release is balanced.
  • If it should not touch the counter at all (it doesn't activate one), revert to commitClientNavigationState() without navId (or pass navId only for the pendingPathname clearing while keeping releaseSnapshot: false).

A focused unit test asserting navigationSnapshotActiveCount returns to its starting value after a synchronous popstate restore that supersedes an in-flight navigation would lock this down. The current tests don't exercise the counter on this path.

Minor

  • app-browser-visible-commit.ts:163-170: action.slotBindings.find(...) inside the preservePreviousSlotIds.filter(...) is O(n·m). Fine for typical slot counts, but per the perf guidance in AGENTS.md a Map<slotId, binding> built once would avoid the nested scan. Not blocking.
  • Fixture action-revalidate/actions.ts calls revalidateTag("action-revalidate-layout"), but nothing registers that cache tag, so the tag invalidation has no tagged data to clear. The e2e assertion still passes because crypto.randomUUID() re-renders on any refresh — so the test validates "fresh layout committed after a revalidating action," not tag-specific invalidation. Worth a comment so the intent is clear, or tag some cached data in the layout.

Looks good

  • bfcache-identity gating of preserveElementIds (mismatched/refresh-lane layouts correctly fall back to the fresh next value) — verified by the new reducer fixtures.
  • resolveServerActionOperationLane centralization and the refresh lane for revalidating server actions.
  • mergeElements prev-wins change is correctly bounded to the single production caller, which now passes the filtered preserveElementIds.

@ask-bonk

ask-bonk Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2316.

Summary

I reviewed the layout-persistence changes and posted a review with one notable concern and two minor notes.

Main concern — snapshot-counter double-release in the synchronous popstate restore path (app-browser-entry.ts:365):

The synchronous restore now calls commitClientNavigationState(navId) instead of the old commitClientNavigationState() (no args). Passing navId flips shouldReleaseSnapshot to true, so it now releases a navigationSnapshotActiveCount slot — but the synchronous restore path (restoreHistorySnapshotVisibleState → dispatchSynchronousVisibleCommit) never acquires one (unlike renderNavigationPayload, which pairs activateNavigationSnapshot() with the release). Since abortSupersededNavigation() only aborts the fetch (it doesn't synchronously release the prior nav's snapshot), the aborted navigation will also release its own snapshot when it settles → double-release. The > 0 guard masks it in the 2-navigation case, but with a third concurrent navigation the count under-flows, which can make useSearchParams/useParams/usePathname stop reading the render snapshot one transition early. I asked the author to confirm the intended accounting and suggested either activating a snapshot before the restore commit or reverting to the no-navId release, plus a counter-balance unit test.

Minor notes:

  • O(n·m) slotBindings.find inside the preservePreviousSlotIds filter — a prebuilt Map would avoid the nested scan (per AGENTS.md perf guidance).
  • The revalidateTag("action-revalidate-layout") fixture has no tagged data registered; the e2e passes only because crypto.randomUUID() re-renders on any refresh — worth a clarifying comment.

Verified clean: bfcache-identity gating of preserveElementIds, the refresh lane centralization via resolveServerActionOperationLane, and the well-scoped mergeElements prev-wins change. Ran the relevant unit tests (236 passed) and vp check on all changed source files (clean).

github run

@github-actions

github-actions Bot commented Jun 25, 2026 •

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared 1d134cb against base f67f872 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 123.6 KB 123.7 KB ⚫ +0.1%
Client entry size (gzip) vinext 118.5 KB 118.6 KB ⚫ +0.1%
Dev server cold start vinext 2.66 s 2.64 s ⚫ -1.1%
Production build time vinext 3.20 s 3.21 s ⚫ +0.1%
RSC entry closure size (gzip) vinext 95.0 KB 95.0 KB ⚫ -0.0%
Server bundle size (gzip) vinext 161.2 KB 161.2 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.

Re-review: layout preservation across soft navigation

The main concern from the prior review (snapshot-counter double-release in the synchronous popstate restore path) has been resolved by the follow-up commit e9b11d1 ("fix(navigation): balance history restore snapshots"), which postdates that review. Verified the fix and the rest of the change.

Resolved: snapshot-counter accounting

app-browser-entry.ts:365 now calls commitClientNavigationState(navId, { releaseSnapshot: false }), and commitClientNavigationState changed its guard to options?.releaseSnapshot ?? navId !== undefined (navigation.ts:1415). This keeps the navId-driven pendingPathname clearing while explicitly not releasing a snapshot the synchronous restore never acquired — exactly the "pass navId but keep releaseSnapshot: false" option suggested previously.

I confirmed the new test in tests/shims.test.ts actually locks this down: reverting the guard to the old navId !== undefined || options?.releaseSnapshot === true makes it fail (expected '/restored' to be '/in-flight'), and it passes on the current code. So useUntrackedPathname correctly keeps reading the in-flight render snapshot after a history-restore URL commit. No double-release.

Verified clean

  • mergeElements prev-wins change (slot.tsx:377): bounded to the single app-browser-visible-commit.ts caller, and preserveElementIds is only non-empty under reuseCurrentBfcacheIds && lane !== "refresh" plus the bfcache-identity filter. Coherent with the layout-preservation goal and covered by slot.test.ts.
  • resolveServerActionOperationLane (app-browser-action-result.ts): revalidating actions now take the refresh lane, which empties preserveElementIds at app-browser-visible-commit.ts:155 so fresh layout output is committed. Non-revalidating actions stay server-action. Lane values match OperationLane.
  • Unit suites pass: tests/shims.test.ts, tests/app-browser-entry.test.ts (211), tests/slot.test.ts (25). vp check clean on all 7 changed source files.

Minor (non-blocking, still open)

  • app-browser-visible-commit.ts:164: action.slotBindings.find(...) inside the preservePreviousSlotIds.filter(...) is O(n·m). A Map<slotId, binding> built once would avoid the nested scan per AGENTS.md perf guidance. Fine at typical slot counts.
  • tests/fixtures/.../action-revalidate/layout.tsx + actions.ts: the layout renders crypto.randomUUID() with no cached/tagged data registered for "action-revalidate-layout", so revalidateTagAction has nothing tag-specific to invalidate — the e2e passes because any refresh re-renders the UUID. The test validates "fresh layout committed after a revalidating action," not tag-scoped invalidation. A clarifying comment (or tagging real cached data) would make the intent explicit.

Both are minor and don't block. The core change is correct and well-tested.

@ask-bonk

ask-bonk Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2316.

Summary

The PR adds a follow-up commit e9b11d1 that the earlier bot review (on 0ab8847) didn't see, and it resolves the main concern that review raised.

Main concern — resolved: The snapshot-counter double-release in the synchronous popstate restore path is fixed. app-browser-entry.ts:365 now passes commitClientNavigationState(navId, { releaseSnapshot: false }), and the guard in navigation.ts:1415 became options?.releaseSnapshot ?? navId !== undefined. This keeps the navId-driven pendingPathname clearing while not releasing a snapshot the synchronous restore never acquired. I verified the regression by reverting the guard — the new tests/shims.test.ts test fails on the old code ('/restored' vs '/in-flight') and passes on the fix.

Verified clean:

  • mergeElements prev-wins change is bounded to its single caller and gated by bfcache identity + non-refresh lane.
  • resolveServerActionOperationLane routes revalidating actions through the refresh lane (empties preserveElementIds, committing fresh layout output).
  • Unit suites pass (shims, app-browser-entry 211, slot 25); vp check clean on all 7 changed source files.

Two minor non-blocking notes still open (carried from the prior review): the O(n·m) slotBindings.find inside the preservePreviousSlotIds.filter, and the revalidateTag fixture having no tagged data registered (test passes via crypto.randomUUID() re-render rather than tag-scoped invalidation).

The core change is correct and well-tested.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

Fixed the post-merge HMR regression from the visible-commit conflict resolution.

The reducer now refreshes planner-approved layout/page elements during HMR replace commits so fresh server-component errors reach the dev overlay, while still preserving the current route shell and slot-owner layouts needed for named parallel-route slot state.

Validated locally with:

  • vp test run tests/app-browser-entry.test.ts
  • vp run vinext#build
  • PLAYWRIGHT_PROJECT=app-router pnpm run test:e2e tests/e2e/app-router/dev-error-overlay.spec.ts -g "layout server component HMR updates the overlay"
  • PLAYWRIGHT_PROJECT=app-router pnpm run test:e2e tests/e2e/app-router/parallel-routes-use-selected-layout-segment.spec.ts
  • vp check

@james-elicx
james-elicx enabled auto-merge (squash) June 28, 2026 23:07
@james-elicx
james-elicx merged commit 0ea90fd into main Jun 28, 2026
60 checks passed
@james-elicx
james-elicx deleted the codex/fix-app-index-parity branch June 28, 2026 23:07
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