Skip to content

fix(app-router): restore scroll snapshots on back#1743

Merged
james-elicx merged 8 commits into
cloudflare:mainfrom
NathanDrake2406:nathan/nav-scroll-restoration
Jun 8, 2026
Merged

fix(app-router): restore scroll snapshots on back#1743
james-elicx merged 8 commits into
cloudflare:mainfrom
NathanDrake2406:nathan/nav-scroll-restoration

Conversation

@NathanDrake2406
Copy link
Copy Markdown
Contributor

@NathanDrake2406 NathanDrake2406 commented Jun 4, 2026

Summary

  • Port the Next.js App Router scroll restoration regression from navigation.test.ts into the app-router Playwright suite.
  • Stop forcing App Router documents into manual history.scrollRestoration; Next's App Router does not do that by default.
  • Cache visible App Router state by vinext history traversal index and synchronously restore the cached source snapshot on popstate before the async RSC traverse finishes.

Root Cause

The failing upstream case navigates from /scroll-restoration to /scroll-restoration/other, calls router.back(), and immediately reads window.pageYOffset. Vinext already persisted scroll coordinates in history state, but the App Router back path waited for an async RSC traverse before the source route was visible again. At the immediate assertion point the document was still rendering the short /other page, so the browser reported 0 instead of the original scroll offset.

Next.js stores App Router tree state in the browser history entry and restores from that state during traversal. Vinext has a different router-state representation, so this PR keeps a bounded document-local snapshot cache keyed by the existing traversal index and restores the visible snapshot synchronously while still letting the normal RSC traverse reconcile afterward.

Next.js References

Verification

Red before fix:

  • PLAYWRIGHT_PROJECT=app-router pnpm run test:e2e tests/e2e/app-router/scroll-restoration.spec.ts failed with expected saved scroll offset and received 0.

Green after fix:

  • 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/scroll-restoration.spec.ts tests/e2e/app-router/nextjs-compat/hash-popstate-scroll.spec.ts tests/e2e/app-router/nextjs-compat/router-autoscroll.spec.ts
  • PLAYWRIGHT_PROJECT=app-router pnpm run test:e2e tests/e2e/app-router/scroll-restoration.spec.ts
  • vp check

Closes #1367

@NathanDrake2406 NathanDrake2406 marked this pull request as draft June 4, 2026 15:41
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Jun 4, 2026

Open in StackBlitz

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

commit: 562fd40

@NathanDrake2406 NathanDrake2406 force-pushed the nathan/nav-scroll-restoration branch 2 times, most recently from b95701f to 1b406b4 Compare June 6, 2026 15:04
@NathanDrake2406 NathanDrake2406 marked this pull request as ready for review June 6, 2026 15:07
@james-elicx
Copy link
Copy Markdown
Member

/bigbonk review for issues

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: scroll snapshot restoration on back

Reviewed the diff against Next.js v16.2.6 source/tests. The core approach is sound and matches Next.js behavior in two important ways:

  • Removing history.scrollRestoration = "manual" is correct parity. Confirmed against packages/next/src/client/components/app-router.tsx at v16.2.6 — Next's App Router never sets scrollRestoration. It relies on browser-default restoration plus its own focusAndScrollRef mechanism. Good catch on the divergence.
  • Synchronous snapshot restore on popstate before the async RSC traverse settles is a reasonable way to bridge the gap between vinext's router-state representation and Next's __PRIVATE_NEXTJS_INTERNALS_TREE traversal. The bounded (50-entry), document-local snapshot cache keyed by traversal index is appropriate and won't leak across documents.

The positional-arg threading through navigate(...) in app-browser-popstate.ts matches the NavigationRuntimeNavigate signature, and the restoreVisibleState controller method correctly bumps visibleCommitVersion instead of rewinding it.

The main concerns are around scope creep and test coverage — this PR is titled/scoped as a scroll fix but also rewrites the server-action handler with a new cache-invalidation guard and threads a reuseCurrentBfcacheIds flag through the entire visible-commit merge path. Those are significant behavioral changes that deserve their own focused unit tests (per AGENTS.md guidance on testing extracted runtime helpers). Inline notes below.

No blocking correctness bugs found, but I'd want unit tests for the new guard/reuse logic before merge given how central these code paths are.

{ temporaryReferences },
);
if (
revalidation === "none" &&
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.

Behavioral change beyond the PR title. The server-action handler was rewritten so cache invalidation now happens eagerly before createFromFetch when revalidation !== "none", and shouldClearClientNavigationCachesForServerActionResult(...) is only consulted when revalidation === "none".

The final union of "when do we clear caches" looks equivalent to the old code (since shouldClear returns true for revalidation !== "none" anyway), but the timing now differs: caches are cleared before the result is decoded. Combined with the new pendingServerActionCacheInvalidationGuards, this is a non-trivial change to server-action semantics that isn't really about scroll restoration.

This would be much easier to review (and safer to land) as a separate stacked PR, per the repo's "small stacked PRs" guidance. At minimum, please add a focused unit test asserting the clear-timing for both the revalidation !== "none" and return-value-only paths.

pendingServerActionCacheInvalidationGuards - 1,
);
};
}
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 guard counter (beginServerActionCacheInvalidationGuard / isClientNavigationCacheInvalidationGuarded) is module-level mutable state with no direct unit test. It's consumed in three places (readCurrentBfcacheVersionHistoryIds, restoreHistoryStateSnapshot, and the reuseCurrentBfcacheIds computation in bootstrapHydration). Per AGENTS.md ("add a focused unit test for the new helper module"), please add a small test covering nesting (multiple concurrent guards) and double-release idempotency — the released flag and Math.max(0, ...) clamp both deserve coverage.

});
commitClientNavigationState();
return true;
}
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.

rememberHistoryStateSnapshot / restoreHistoryStateSnapshot carry the bulk of this PR's new logic (index keying, bfcache-version gating, LRU eviction at MAX_HISTORY_STATE_SNAPSHOTS, guard gating) but are only exercised via the new Playwright spec. A unit test for these (snapshot hit/miss, stale-version eviction, eviction at the 50-entry cap) would catch regressions far faster than the E2E and aligns with the repo guidance to unit-test extracted runtime helpers rather than relying on generated/E2E assertions.

preserveAbsentSlots: action.reuseCurrentBfcacheIds && commit.decision.preserveAbsentSlots,
preserveElementIds,
preservePreviousSlotIds,
});
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 reuseCurrentBfcacheIds === false branch flips several merge behaviors at once: forces clearAbsentSlots, drops preserveAbsentSlots, empties preserveElementIds/preservePreviousSlotIds, and resets the previous bfcache map to {}. This is the right idea for a fresh traverse, but it's a meaningful change to the merge reducer with no dedicated unit test in tests/app-browser-entry.test.ts. Consider adding a reducer-level test asserting the merged output for both reuseCurrentBfcacheIds: true and false, since this path affects all navigate/traverse commits, not just back-with-stale-bfcache.

return;
}
handlePopstate(event);
if (restoreHistoryStateSnapshot(event.state)) {
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 isSameAppRoutePopstateTarget(href) is true (hash-only traversal) the handler returns early at the block above and never calls restoreHistoryStateSnapshot, so a snapshot for that index can persist until evicted by the 50-entry cap. That's harmless given the cap, but worth a one-line comment noting the hash-only path intentionally skips snapshot restore so future readers don't think it's an oversight.

await expect(page.locator("#back-button")).toBeVisible();
await page.locator("#back-button").click();

const newScrollPosition = await page.evaluate(() => window.pageYOffset);
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.

window.pageYOffset is read immediately after the #back-button click without first awaiting any post-back content assertion. This mirrors the upstream Next test (which also reads immediately), and the fix is synchronous, so it should be deterministic. But if this turns out flaky in CI, add an await expect(body).toContainText("Item 200") (or wait for the page URL) before reading the offset to ensure the back traverse has visibly committed.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Jun 6, 2026

Review submitted successfully. Here's a summary of my review of PR #1743:

Review summary

Verdict: Approach is sound, no blocking correctness bugs, but flagged scope creep and missing unit tests.

What's good (verified against Next.js v16.2.6)

  • Removing history.scrollRestoration = "manual" is correct parity. I fetched packages/next/src/client/components/app-router.tsx at v16.2.6 and confirmed Next's App Router never sets scrollRestoration — it uses browser-default restoration + focusAndScrollRef. The PR's claim is accurate.
  • Synchronous snapshot restore on popstate is a reasonable bridge between vinext's router-state model and Next's __PRIVATE_NEXTJS_INTERNALS_TREE traversal. The 50-entry, document-local LRU cache is bounded and doesn't leak across documents.
  • Positional args threaded through navigate(...) match the NavigationRuntimeNavigate signature.
  • restoreVisibleState correctly bumps visibleCommitVersion (not rewinds).

Concerns raised (6 inline comments)

  1. Scope creep / server-action rewrite — cache invalidation now happens eagerly before createFromFetch; this is a server-action timing change unrelated to scroll. Suggested splitting into a stacked PR.
  2. Cache-invalidation guard — module-level mutable counter with no unit test for nesting/double-release.
  3. Snapshot helpersremember/restoreHistoryStateSnapshot (index keying, version gating, LRU eviction) only covered by E2E; AGENTS.md asks for focused unit tests.
  4. visible-commit reducer — the reuseCurrentBfcacheIds === false branch flips multiple merge behaviors at once with no reducer-level test.
  5. Hash-only popstate path skips snapshot restore — harmless but deserves a clarifying comment.
  6. E2E flake risk — reads pageYOffset without awaiting post-back content; suggested a guard if CI flakes.

The key theme: solid fix verified against upstream, but the PR bundles two significant behavioral subsystems (server-action guard + bfcache-reuse merge flag) under a scroll-restoration title, and those need dedicated unit tests before merge.

github run

@NathanDrake2406 NathanDrake2406 marked this pull request as draft June 7, 2026 05:44
@NathanDrake2406 NathanDrake2406 force-pushed the nathan/nav-scroll-restoration branch from 1b406b4 to dc0b18e Compare June 8, 2026 06:29
@NathanDrake2406 NathanDrake2406 marked this pull request as ready for review June 8, 2026 06:29
@NathanDrake2406 NathanDrake2406 force-pushed the nathan/nav-scroll-restoration branch from d1623d5 to de9c72f Compare June 8, 2026 06:33
@NathanDrake2406
Copy link
Copy Markdown
Contributor Author

Addressed the latest review on the rebased head de9c72fa.

  • The CI flake risk in the scroll regression test was valid. The test now waits for /scroll-restoration/other, waits for the restored /scroll-restoration URL and restored long content (Item 200), then polls window.pageYOffset instead of reading it immediately after the back click.
  • Added focused reducer-level coverage for reuseCurrentBfcacheIds: false: pending commits mint fresh BFCache ids instead of reusing current ids, and approved commits do not preserve skipped layout element/flag state when the current ids are stale.
  • The eager server-action invalidation concern is stale on the current head. registerServerActionCallback() no longer permanently calls invalidateRestorableClientState() before createFromFetch; durable invalidation remains aligned with the existing classification path through clearClientNavigationCaches().
  • I did not export the private snapshot/in-flight guard helpers only to unit-test them. Their observable contracts are covered through the BFCache/server-action E2E cases plus the reducer tests above.

Also rebased the branch onto current upstream/main and force-pushed with lease.

Local verification:

  • vp check
  • vp run vinext#build
  • vp test run tests/app-browser-entry.test.ts
  • PLAYWRIGHT_PROJECT=app-router pnpm run test:e2e tests/e2e/app-router/scroll-restoration.spec.ts
  • PLAYWRIGHT_PROJECT=app-router pnpm run test:e2e tests/e2e/app-router/nextjs-compat/use-router-bfcache-id.spec.ts

@NathanDrake2406 NathanDrake2406 force-pushed the nathan/nav-scroll-restoration branch 4 times, most recently from 1bc7771 to 3effaf8 Compare June 8, 2026 06:59
@NathanDrake2406
Copy link
Copy Markdown
Contributor Author

NathanDrake2406 commented Jun 8, 2026

Clarifying the scope question from the latest review thread: the server-action/cache-invalidation pieces are intentional, not incidental cleanup. Once this PR adds a synchronous visible-state snapshot restore for popstate, that snapshot has to participate in the same invalidation boundary as history BFCache ids. Otherwise the scroll fix can resurrect stale client state after a server action revalidates or returns a replacement tree.

Concretely:

  • The snapshot cache must be tied to the BFCache epoch, otherwise it bypasses the existing stale-history-id guard.
  • The in-flight server-action guard is needed so a popstate cannot synchronously restore a snapshot while an unresolved action may still prove that the epoch should be invalidated.
  • The permanent invalidation still stays aligned with the existing server-action classification point: revalidation / raw RSC payload / replacement root. Return-value-only actions with no revalidation keep restorable client state.
  • The visible-commit merge change is coupled to that same invariant: when reuseCurrentBfcacheIds is false, we also must not preserve old visible elements/slots through reducer merge semantics, or stale client state can survive through a different path even though history ids were rejected.

So this does expand beyond the original scroll timing symptom, but it is the correctness envelope for making synchronous snapshot restore safe. The nonessential wrapper cleanup was removed; what remains is tied to the invalidation contract.

@NathanDrake2406 NathanDrake2406 force-pushed the nathan/nav-scroll-restoration branch from 3effaf8 to fbcd910 Compare June 8, 2026 07:20
@james-elicx
Copy link
Copy Markdown
Member

/bigbonk review for issues

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Jun 8, 2026

@james-elicx Bonk workflow was cancelled.

View workflow run · To retry, trigger Bonk again.

@NathanDrake2406 NathanDrake2406 marked this pull request as draft June 8, 2026 09:14
@james-elicx
Copy link
Copy Markdown
Member

/bigbonk review for issues

@NathanDrake2406 NathanDrake2406 marked this pull request as ready for review June 8, 2026 09:41
@james-elicx
Copy link
Copy Markdown
Member

/bigbonk review for issues

@NathanDrake2406
Copy link
Copy Markdown
Contributor Author

@james-elicx Bonk workflow was cancelled.

View workflow run · To retry, trigger Bonk again.

10 mins cap broke it at the end

@james-elicx
Copy link
Copy Markdown
Member

@james-elicx Bonk workflow was cancelled.
View workflow run · To retry, trigger Bonk again.

10 mins cap broke it at the end

It died 2 minutes into its run and did nothing for the following 8 mins.

@james-elicx
Copy link
Copy Markdown
Member

/bigbonk review for issues

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: synchronous scroll snapshot restore on back

I re-reviewed the rebased head against the App Router state/commit machinery. The core fix holds up and the new test coverage (reducer-level reuseCurrentBfcacheIds: false, the HistoryStateSnapshotCache/RestorableClientStateController unit tests, the synchronous popstate scroll guard, and the polled E2E) addresses the prior round's concerns well. Removing history.scrollRestoration = "manual" matches Next v16.2.6, and the bfcache-epoch coupling of the snapshot cache + in-flight server-action guard is a coherent correctness envelope, as described in the thread.

No blocking correctness bugs. My main remaining ask is documentation of one subtle, load-bearing invariant plus a couple of minor scope/clarity notes. Details inline.

Key invariant that should be commented

The whole fix depends on the synchronous snapshot commit superseding the in-flight async RSC traverse for the same navId. Concretely:

  • popstate calls navigate(...) first; renderNavigationPayload synchronously captures startedState = getBrowserRouterState() (version N) and then awaits nextElements, yielding at least one microtask.
  • restoreHistoryStateSnapshot(event.state) then runs synchronously (no await) in the same task, commits the cached snapshot, and bumps visibleCommitVersion to N+1.
  • When the async traverse resolves, resolvePendingNavigationCommitDispositionDecision sees operation.startedVisibleCommitVersion (N) !== currentState.visibleCommitVersion (N+1) and returns staleOperation -> no-commit (app-browser-state.ts:512-522).

So the fresh server RSC payload is intentionally discarded on every snapshot-restored back/forward, and the user keeps the cached client snapshot (matching Next's in-memory bfcache, which also does not refetch). This ordering is deterministic only because restoreHistoryStateSnapshot is synchronous while the async traverse always yields. That is a fragile, non-obvious contract carrying the whole feature and currently has no comment at the call site. Please document it where restoreHistoryStateSnapshot is invoked (and ideally note the staleOperation interaction).

return;
}
handlePopstate(event);
if (restoreHistoryStateSnapshot(event.state)) {
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 is the load-bearing seam of the fix and deserves a comment. The synchronous restoreHistoryStateSnapshot commit bumps visibleCommitVersion, which causes the in-flight async RSC traverse kicked off by handlePopstate (same navId) to be skipped as a staleOperation in resolvePendingNavigationCommitDispositionDecision (app-browser-state.ts:512). Net effect: the fresh server RSC payload is intentionally dropped and the cached client snapshot wins (bfcache-style, gated by bfcacheVersion).

This only works because restoreHistoryStateSnapshot runs synchronously in the same task while the async traverse always yields a microtask at await nextElements. That ordering invariant is invisible to future readers — a comment here (and a note that the snapshot path supersedes the in-flight traverse) would prevent someone from accidentally making this async or reordering it.

Separately: the hash-only early-return branch above never sets synchronousPopstateScrollRestoreNavigationId and skips both the snapshot restore and the consumed-scroll guard. That's correct (hash-only traversals need no tree snapshot), but a one-line note on that branch would save a future reader from thinking the snapshot path was forgotten there.

Comment thread knip.ts
"sass",

// Vite+ reporter name used outside CI in vite.config.ts.
"agent",
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.

Scope: the sass and agent knip-ignore entries (and the navigation-trace.ts export type -> type change) are unrelated to scroll restoration. They look like incidental vp check fixes. Not blocking, but per the repo's small-stacked-PR guidance these would be cleaner as a separate chore commit/PR so the scroll fix stays focused.

window.scrollTo(x, y);
if (!shouldContinue() || Math.abs(window.scrollY - y) <= 1 || attempts >= 60) {
const reachedTarget = Math.abs(window.scrollY - y) <= 1;
if (!shouldContinue() || (reachedTarget && attempts >= minFrames) || attempts >= 60) {
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.

Worth a brief comment on why minFrames exists: after a synchronous snapshot restore the document may still be short (the layout's client state hasn't grown the page back yet), so scrollTo(0, savedY) can momentarily satisfy reachedTarget at a clamped offset. minFrames: 1 forces at least one extra rAF so the retry survives until the restored content expands the scroll height. Without that context this condition reads like an arbitrary off-by-one.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Jun 8, 2026

Review submitted successfully. Here's a summary of my review of PR #1743:

Review summary

Verdict: The fix is correct and well-tested. No blocking correctness bugs. Main ask is documenting one subtle invariant, plus minor scope/clarity notes.

What I verified

  • Removing history.scrollRestoration = "manual" matches Next.js v16.2.6 App Router behavior.
  • The bfcache-epoch coupling (snapshot cache + in-flight server-action guard + reuseCurrentBfcacheIds merge flag) is a coherent correctness envelope, justified in the PR thread — not incidental cleanup.
  • The new test coverage addresses the prior review round well: reducer-level reuseCurrentBfcacheIds: false tests, HistoryStateSnapshotCache/RestorableClientStateController unit tests, the synchronous popstate scroll guard test, and the now-polled E2E (no longer reads pageYOffset immediately).
  • Ran vp test run tests/app-browser-entry.test.ts locally — 180 tests pass.

Concerns raised (3 inline comments)

  1. Undocumented load-bearing invariant (main ask): The fix works because the synchronous snapshot commit bumps visibleCommitVersion, causing the in-flight async RSC traverse (same navId) to be skipped as a staleOperation (app-browser-state.ts:512). This intentionally discards the fresh server RSC payload in favor of the cached snapshot — bfcache-style. It's deterministic only because the snapshot restore is synchronous while the traverse yields a microtask. This fragile, non-obvious contract carries the whole feature and has no comment at the call site.
  2. Scope creep: knip.ts (sass, agent) and the navigation-trace.ts export removal are unrelated to scroll restoration.
  3. minFrames clarity: Worth a comment explaining it forces an extra rAF so the scroll retry survives until the restored content expands the page height.

I also folded a note about the hash-only popstate branch (correctly skipping the snapshot path) into the main inline comment.

github run

@NathanDrake2406
Copy link
Copy Markdown
Contributor Author

@james-elicx Bonk workflow was cancelled.
View workflow run · To retry, trigger Bonk again.

10 mins cap broke it at the end

It died 2 minutes into its run and did nothing for the following 8 mins.

:| the log is now gone I saw it was about to post the review then got cut off at 10m, weird

@NathanDrake2406
Copy link
Copy Markdown
Contributor Author

@james-elicx Bonk workflow was cancelled.
View workflow run · To retry, trigger Bonk again.

10 mins cap broke it at the end

It died 2 minutes into its run and did nothing for the following 8 mins.

:| the log is now gone I saw it was about to post the review then got cut off at 10m, weird

https://github.com/cloudflare/vinext/actions/runs/27127062967

@james-elicx
Copy link
Copy Markdown
Member

/bigbonk review for issues

@james-elicx
Copy link
Copy Markdown
Member

cloudflare/vinext/actions/runs/27127062967

That one died after 5 mins

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: synchronous scroll snapshot restore on back

Re-reviewed the current head. The fix is sound and the prior review rounds have been addressed well:

  • history.scrollRestoration = "manual" removal matches Next.js v16.2.6 App Router behavior (browser-default restoration + focusAndScrollRef).
  • The load-bearing staleOperation invariant is now documented at the popstate call site (app-browser-entry.ts:388-407) and at resolvePendingNavigationCommitDispositionDecision (app-browser-state.ts:514-519). This was the main ask from the last round and it's now clear.
  • Test coverage is solid: reducer-level reuseCurrentBfcacheIds: false cases, HistoryStateSnapshotCache/RestorableClientStateController unit tests, the synchronous popstate scroll guard unit test, and the polled E2E (no longer reads pageYOffset immediately after the back click).
  • The bfcache-epoch coupling (snapshot cache + in-flight server-action guard + reuseCurrentBfcacheIds merge flag) is a coherent correctness envelope, and the thread justifies why it can't be split out.

No blocking correctness bugs. A few smaller observations inline — none are merge blockers.

historyIndex: currentHistoryTraversalIndex,
state: treeState,
});
}, [treeState]);
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 effect keys the snapshot by the module-level currentHistoryTraversalIndex but only depends on [treeState]. The ordering works today because commitHistoryTraversalIndex(...) runs inside the navigation commit effect before commitClientNavigationState(navId) triggers setTreeStateValue, so the index is already updated when this layout effect fires for the new treeState. That's correct, but it's an implicit cross-module ordering contract that's invisible here. A one-line comment noting that currentHistoryTraversalIndex is committed before the treeState update would prevent a future change to the commit ordering from silently mis-keying snapshots (which would cause resolveRestore to read the wrong index on back).

getActiveNavigationId: () => browserNavigationController.getActiveNavigationId(),
isCurrentNavigation: (navId) => browserNavigationController.isCurrentNavigation(navId),
markScrollRestoreConsumed: (navId) => {
synchronousPopstateScrollRestoreNavigationId = navId;
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.

synchronousPopstateScrollRestoreNavigationId is written here but never reset. It works as a single-slot marker because activeNavigationId is strictly monotonic (beginNavigation only ever increments), so shouldSkipScrollRestore(navId) can only match the most-recent synchronously-restored navigation. That's fine, but worth a brief comment that this is intentionally a latch on the latest navId rather than a per-navigation set — otherwise a future reader may think the missing reset is a leak.

(!restorableClientState.isCacheInvalidationGuarded() &&
restorableClientState.isCurrentBfcacheVersion(
activeTraversalIntent?.historyState ?? window.history.state,
));
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.

reuseCurrentBfcacheIds is computed once at navigation start and captured in the navigateRsc closure, then used for all three render paths (cached/optimistic/fresh). The async traverse can resolve much later; if the bfcache epoch changes or a server-action guard is released in between, this captured value is stale. This mirrors restoredBfcacheIds above (same window, same staleness), so it's consistent with existing behavior and not a regression — but since the prior review round explicitly worried about epoch/guard timing, a short note that both restoredBfcacheIds and reuseCurrentBfcacheIds are intentionally snapshotted at navigation-start (not re-read at commit) would close that loop for reviewers.

Comment thread knip.ts
"sass",

// Vite+ reporter name used outside CI in vite.config.ts.
"agent",
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.

Scope: the sass and agent knip-ignore entries (and the navigation-trace.ts export type -> type change) are unrelated vp check fixups. Not blocking, but per the repo's small-stacked-PR guidance these would read cleaner as a separate chore commit so the scroll fix stays focused. (Already flagged in a prior round; restating only because it's still present.)

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Jun 8, 2026

Review submitted successfully. Here's a summary of my review of PR #1743:

Review summary

Verdict: The fix is sound, prior review rounds have been addressed, and there are no blocking correctness bugs. I raised 4 small non-blocking observations.

What I verified against the code

  • history.scrollRestoration = "manual" removal matches Next.js v16.2.6 App Router behavior.
  • The load-bearing staleOperation invariant is now documented at both the popstate call site (app-browser-entry.ts:388-407) and resolvePendingNavigationCommitDispositionDecision (app-browser-state.ts:514-519) — this was the previous round's main ask and it's now in place.
  • Test coverage is comprehensive: reducer-level reuseCurrentBfcacheIds: false tests, HistoryStateSnapshotCache/RestorableClientStateController unit tests, the synchronous popstate scroll-guard unit test, and the polled E2E.
  • Confirmed minFrames defaults to 0, so the other retryScrollTo callers in navigation.ts/hash-scroll.ts are unaffected.
  • Confirmed navIds are strictly monotonic, so the single-slot synchronousPopstateScrollRestoreNavigationId latch is safe.
  • Traced the commit ordering: commitHistoryTraversalIndex runs before setTreeStateValue, so the snapshot-remember effect keys at the correct post-navigation index.

Inline comments (all non-blocking)

  1. Snapshot-remember effect keys by module-global currentHistoryTraversalIndex but depends only on [treeState] — correct via an implicit cross-module ordering contract that deserves a comment.
  2. synchronousPopstateScrollRestoreNavigationId is never reset — works as a monotonic latch; worth a clarifying comment.
  3. reuseCurrentBfcacheIds is snapshotted at navigation-start and captured in the closure (same staleness window as restoredBfcacheIds) — consistent, but worth a note given the prior epoch/guard timing concern.
  4. Scope: the knip.ts (sass, agent) and navigation-trace.ts export changes are unrelated vp check fixups; cleaner as a separate chore commit.

github run

@NathanDrake2406
Copy link
Copy Markdown
Contributor Author

cloudflare/vinext/actions/runs/27127062967

That one died after 5 mins

ah yeah you're right. that's even worse tho, hope cloudflare doesn't get billed for these failed runs

Adds comments for three load-bearing invariants identified in review:
- rememberHistoryStateSnapshot layout effect ordering
- synchronousPopstateScrollRestoreNavigationId single-slot latch
- restoredBfcacheIds/reuseCurrentBfcacheIds snapshot timing
@james-elicx
Copy link
Copy Markdown
Member

/bigbonk review for issues

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: synchronous scroll snapshot restore on back

Re-reviewed the current head (562fd40). This PR has been through several thorough rounds and the prior asks are now addressed — I verified the fix end-to-end rather than re-litigating settled points.

What I verified against the code

  • history.scrollRestoration = "manual" removal is correct parity — Next v16.2.6 App Router never sets it; relies on browser-default restoration + focusAndScrollRef.
  • The load-bearing staleOperation invariant is now documented at the popstate call site (app-browser-entry.ts:407-426) and at resolvePendingNavigationCommitDispositionDecision (app-browser-state.ts:514-519). This was the main ask last round and it is clearly captured now.
  • The commit-ordering contract for the snapshot-remember effect checks out: commitHistoryTraversalIndex(navId) runs before commitClientNavigationState(navId) (app-browser-entry.ts:697/702/711 then :717), which triggers setTreeStateValue and the [treeState] layout effect. So currentHistoryTraversalIndex is already current when the snapshot is keyed. The comment at :1183 documents this.
  • beforeCommit only runs after approval succeeds (app-browser-navigation-controller.ts:600-605), so stageClientParams(...) is never applied for a rejected restore — no leaked staged params.
  • The popstate navigate(...) positional call is correct: the 6th arg false maps to programmaticTransition (already the default), 7th to traversalIntent, matching NavigationRuntimeNavigate. reuseCurrentBfcacheIds is computed internally from the traversal intent, not passed positionally.
  • RestorableClientStateController / HistoryStateSnapshotCache are now unit-tested (LRU eviction, guard nesting/double-release, stale-bfcache eviction, fresh-document epoch), plus reducer-level reuseCurrentBfcacheIds: false coverage and a polled E2E. Ran vp test run tests/app-browser-entry.test.ts locally — 180 tests pass.

No blocking correctness bugs. Two small non-blocking notes inline.

"traverse",
undefined,
undefined,
false,
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 clarity: the 6th positional arg false here is programmaticTransition (already its default), not reuseCurrentBfcacheIds. reuseCurrentBfcacheIds for the async traverse is computed internally in navigateRsc from the traversal intent (app-browser-entry.ts:1826). Passing the default explicitly via positionals is easy to misread as forcing fresh bfcache ids on the async path. A one-line comment, or using a small options-object overload, would prevent a future reader from misattributing this arg. Not blocking — the call is correct as-is.

Comment thread knip.ts
"sass",

// Vite+ reporter name used outside CI in vite.config.ts.
"agent",
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.

Scope (already flagged in prior rounds, restating only because it's still here): the sass/agent knip-ignore entries and the navigation-trace.ts export type -> type change are unrelated vp check fixups. Per the repo's small-stacked-PR guidance these read cleaner as a separate chore commit so the scroll fix stays focused. Non-blocking.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Jun 8, 2026

Review submitted successfully. Here's a summary of my review of PR #1743:

Review summary

Verdict: The fix is sound, prior review rounds are fully addressed, and there are no blocking correctness bugs. I verified the fix end-to-end against the code rather than re-litigating settled points.

What I verified

  • history.scrollRestoration = "manual" removal matches Next v16.2.6 App Router behavior.
  • The load-bearing staleOperation invariant is now documented at both the popstate call site (app-browser-entry.ts:407-426) and resolvePendingNavigationCommitDispositionDecision (app-browser-state.ts:514-519) — the main ask from the previous round.
  • The snapshot-remember commit-ordering contract checks out: commitHistoryTraversalIndex runs before commitClientNavigationState (which triggers the [treeState] layout effect), so the index is correctly current when the snapshot is keyed.
  • beforeCommit only runs after approval succeeds, so stageClientParams is never applied on a rejected restore — no leaked staged params.
  • The popstate navigate(...) positional call is correct: the 6th false is programmaticTransition (the default), not reuseCurrentBfcacheIds (which is computed internally from the traversal intent).
  • The new unit/reducer/E2E coverage is comprehensive. Ran vp test run tests/app-browser-entry.test.ts locally — 180 tests pass.

Inline notes (both non-blocking)

  1. app-browser-popstate.ts — the explicit false positional arg reads like it might be forcing fresh bfcache ids, but it's just programmaticTransition's default. Worth a one-line comment or options-object overload to prevent misreading.
  2. knip.ts — the sass/agent ignores and navigation-trace.ts export change are unrelated vp check fixups; cleaner as a separate chore commit (restating a prior-round note since it's still present).

github run

@james-elicx james-elicx merged commit 6005541 into cloudflare:main Jun 8, 2026
42 checks passed
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.

App Router: restore popstate scroll after traversal content is restorable

2 participants