Skip to content

fix(app-router): keep usePathname in sync across back/forward around shallow routing - #2835

Open
sensei-woo wants to merge 2 commits into
cloudflare:mainfrom
sensei-woo:fix/shallow-history-entry-identity
Open

fix(app-router): keep usePathname in sync across back/forward around shallow routing#2835
sensei-woo wants to merge 2 commits into
cloudflare:mainfrom
sensei-woo:fix/shallow-history-entry-identity

Conversation

@sensei-woo

Copy link
Copy Markdown

Fixes #1541.

Problem

usePathname()/useSearchParams() freeze on a stale value after Back/Forward when the app uses shallow routing (native history.pushState/replaceState, per the Next.js SPA guide). Minimal repro and a vinext-vs-next dev step table are in this comment on #1541: the desync needs a pushState performed after a traversal, and then it's the second Back that loses sync — the URL moves while the hooks stay put.

Root cause — two defects

1. Externally written entries inherited the current entry's traversal index. The patched history.pushState/replaceState built the new entry's state with createExternalHistoryStatePreservingMetadata, which copied __vinext_historyIndex from the entry being pushed from. Two distinct history entries then shared one slot in the index-keyed restorable-snapshot cache (HistoryStateSnapshotCache), so a later traversal could resolve a "restore" against the other entry's remembered router state — committing a router tree whose navigationSnapshot carries the wrong URL, and short-circuiting the full traverse navigation that would have synced from location.

2. The same-route popstate fast path skipped the URL-state resync. With distinct indices in place, a second defect surfaced: when a traversal target's pathname+search already matches the committed router tree (isSameAppRoutePopstateTarget), the popstate handler returned after committing the traversal index — but an interleaved shallow pushState had moved the navigation shim's cached URL state elsewhere, and nothing brought it back. The tree matched; the hooks didn't.

Fix

  • shims/navigation.ts: the patched pushState/replaceState allocate a fresh traversal index for the externally written entry through a new allocateExternalHistoryTraversalIndex runtime function (skipped for vinext-internal suppressed writes, and absent-runtime callers — Pages Router, pre-hydration — keep the previous copy behavior). replaceState allocates too: its URL rewrite makes the snapshot remembered under the old index unrepresentative of the entry it now describes.
  • client/navigation-runtime.ts: the new optional runtime function, registered by the App Router browser entry and wired to AppBrowserHistoryController (allocateNavigationHistoryTraversalIndex("push") + commitHistoryTraversalIndex).
  • server/app-browser-entry.ts: the same-route popstate fast path now runs commitClientNavigationState() so usePathname()/useSearchParams() resync from the restored location.
  • server/app-history-state.ts: createExternalHistoryStatePreservingMetadata accepts the allocated index; undefined preserves the legacy copy.

Tests

  • e2e (tests/e2e/app-router/advanced.spec.ts): drives the aliasing sequence — push /shallow-test/sub, push ?filter=active, Back, push again, Back, Forward — asserting usePathname/useSearchParams match the URL at every settle point. Fails on main (frozen pathname at the second Back), passes with the fix. The fixture gains a real /shallow-test/sub route so the traverse-fallback RSC navigation resolves instead of 404ing (that page's pathname was already being shallow-pushed by the existing fixture buttons).
  • unit (tests/app-browser-history-controller.test.ts): allocated index is stamped (including onto metadata-less entries), legacy copy preserved without allocation, and the aliasing scenario at the controller level — an externally pushed entry gets its own snapshot slot, so restoring the original entry yields the original state.

pnpm test, PLAYWRIGHT_PROJECT=app-router pnpm run test:e2e -- tests/e2e/app-router/advanced.spec.ts (38/38), and pnpm run check are green.


Found while root-causing a glyph-soup animation bug in a production app whose account tabs shallow-route with native pushState; the frozen usePathname compounds with cacheComponents' hidden-Activity keep-alive (stale state keeps morphing offscreen). Happy to iterate on review feedback.

https://claude.ai/code/session_01Y4w3KwfeSpsDQWFeLnZMXd

…shallow routing

Two defects conspired to freeze usePathname()/useSearchParams() on a stale
value after Back/Forward when the app uses shallow routing (native
history.pushState/replaceState), cloudflare#1541:

1. The patched history.pushState/replaceState copied the current entry's
   __vinext_historyIndex onto the externally written entry. Two distinct
   entries then shared one restorable-snapshot slot, so a later traversal
   could restore the OTHER entry's router state. Externally written entries
   now get their own traversal index, allocated and committed through a new
   allocateExternalHistoryTraversalIndex runtime function wired to
   AppBrowserHistoryController. Without the App Router runtime (Pages
   Router, pre-hydration) the previous copy behavior is unchanged.
   replaceState also allocates: its URL rewrite makes the snapshot
   remembered under the old index unrepresentative of the entry.

2. The same-route popstate fast path (committed router tree already matches
   the target URL) returned before resyncing the navigation shim's cached
   URL state, which an interleaved shallow pushState had moved elsewhere.
   It now runs commitClientNavigationState() so the hooks resync from the
   restored location.

The e2e regression test drives the aliasing sequence (push, push, back,
push, back, forward) and fails on both defects without the fix; the
/shallow-test/sub fixture route now exists as a real route so the
traverse fallback RSC navigation resolves instead of 404ing.

Fixes cloudflare#1541

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

Copy link
Copy Markdown

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: 6dfe3a7c5f

ℹ️ 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 thread packages/vinext/src/shims/navigation.ts Outdated
Comment on lines +3040 to +3043
createExternalHistoryStatePreservingMetadata(
data,
window.history.state,
allocateExternalHistoryTraversalIndex(),

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 Commit the traversal index only after the history write succeeds

When an app calls history.pushState or replaceState with a cross-origin URL or an uncloneable state value, the native method throws without changing the browser entry, but this callback has already committed the newly allocated index. The history controller then disagrees with the browser, so a later Back/Forward traversal or router replace can infer direction and resolve snapshots using the wrong current index. Allocate the candidate before the native call if needed, but commit it only after originalPushState/originalReplaceState returns successfully.

Useful? React with 👍 / 👎.

Comment on lines +2398 to +2399
const index = historyController.allocateNavigationHistoryTraversalIndex("push");
historyController.commitHistoryTraversalIndex(index);

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 Preserve the index for state-only replaceState calls

When an app uses history.replaceState(data, "") without changing the URL, this callback still allocates as a push and assigns the existing entry a fresh traversal index. Its valid router snapshot remains stored under the old index, and because the router tree did not change, no layout effect records a replacement snapshot under the new one; navigating away and then back therefore misses client restoration and falls back to an RSC navigation, potentially resetting client state unnecessarily. Keep the current index when replaceState leaves the effective route unchanged, and allocate a fresh identity only for URL-changing replacements.

Useful? React with 👍 / 👎.

Comment on lines +1 to +2
---
"vinext": patch

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Remove the manually authored changeset

This commit adds a regular named changeset even though this repository generates changesets automatically from Conventional Commit subjects and reserves manually committed changesets for SHA-named retroactive reclassification. Keeping this file can duplicate or conflict with the CI-generated release entry, so delete it and rely on the existing fix(app-router): ... commit subject.

AGENTS.md reference: AGENTS.md:L348-L350

Useful? React with 👍 / 👎.

Address Codex review on cloudflare#2835:

- Commit the allocated traversal index only after the native
  pushState/replaceState succeeds. Allocation is now a pure peek through
  the runtime seam and a separate commitExternalHistoryTraversalIndex
  runs after the write, so a throwing call (cross-origin URL,
  uncloneable state) leaves traversal bookkeeping untouched.
- Keep the entry's index on state-only and hash-only replaceState. Only
  a pathname/search change makes the remembered snapshot
  unrepresentative; App Router state does not include hashes.
- Drop the manually authored changeset; this repo generates changesets
  from Conventional Commit subjects.
@sensei-woo

Copy link
Copy Markdown
Author

Addressed all three Codex findings in b1198f3:

  • Commit after write succeeds (P2): allocateExternalHistoryTraversalIndex is now a pure peek; a separate commitExternalHistoryTraversalIndex runs only after the native pushState/replaceState returns, so a throwing call (cross-origin URL, uncloneable state) leaves the traversal bookkeeping untouched — the peeked index is simply re-allocated by the next write.
  • State-only replaceState keeps its index (P2): replaceState now allocates only when the target URL changes the app-route identity (pathname/search — App Router state does not include hashes, matching isSameAppRoutePopstateTarget semantics). history.replaceState(data, "") and hash-only replaces keep the entry's index and its remembered snapshot.
  • Manual changeset removed (P1).

pnpm test (29), PLAYWRIGHT_PROJECT=app-router advanced.spec (38/38), and pnpm run check are green.

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: history.pushState with new pathname not reflected after back/forward

1 participant