nextjs - 💡(How to fix) Fix router.push() creates `replaceState` instead of `pushState` when PPR tree mismatch triggers `ACTION_SERVER_PATCH` [2 comments, 2 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
vercel/next.js#89802Fetched 2026-04-08 00:21:19
View on GitHub
Comments
2
Participants
2
Timeline
7
Reactions
0
Author
Timeline (top)
commented ×2labeled ×2closed ×1issue_type_added ×1

Root Cause

Current behavior: Back navigates to Home instead of Messages. history.length does not increment on the Messages → Message Room navigation because history.replaceState is called instead of history.pushState. The root cause is in :

// A retry should not create a new history entry.
const navigateType = 'replace'

When a PPR segment mismatch is detected (e.g., __PAGE__?{"_t":"T1"} from prefetch vs __PAGE__?{"_t":"T2"} from dynamic request), dispatchRetryDueToTreeMismatch dispatches ACTION_SERVER_PATCH. The serverPatchReducer unconditionally sets navigateType = 'replace', which overwrites the original router.push()'s pendingPush = true to false. This causes HistoryUpdater to call replaceState instead of pushState.

Fix Action

Fix / Workaround

https://github.com/ellemedit/next-server-patch-replace-state-bug

Current behavior: Back navigates to Home instead of Messages. history.length does not increment on the Messages → Message Room navigation because history.replaceState is called instead of history.pushState. The root cause is in :

// A retry should not create a new history entry.
const navigateType = 'replace'

When a PPR segment mismatch is detected (e.g., __PAGE__?{"_t":"T1"} from prefetch vs __PAGE__?{"_t":"T2"} from dynamic request), dispatchRetryDueToTreeMismatch dispatches ACTION_SERVER_PATCH. The serverPatchReducer unconditionally sets navigateType = 'replace', which overwrites the original router.push()'s pendingPush = true to false. This causes HistoryUpdater to call replaceState instead of pushState.

How the mismatch triggers: NextLink prefetches the route → PPR caches static shell with __PAGE__?{"_t":"T1"} User clicks → router.push() → navigateReducer creates tasks from prefetched tree Dynamic fetch fires → server responds with __PAGE__?{"_t":"T2"} (middleware changed the timestamp) writeDynamicDataIntoNavigationTaskmatchSegment() fails (segment keys differ) abortRemainingPendingTasks → task status === 0 (still pending) → exitStatus = 1 spawnDynamicRequestsdispatchRetryDueToTreeMismatchACTION_SERVER_PATCH serverPatchReducer hardcodes navigateType = 'replace'pendingPush becomes false HistoryUpdater calls replaceState → history entry lost

Code Example

// A retry should not create a new history entry.
const navigateType = 'replace'

---

Operating System:
  Platform: darwin
  Arch: arm64
  Version: Darwin Kernel Version 25.2.0: Tue Nov 18 21:09:56 PST 2025; root:xnu-12377.61.12~1/RELEASE_ARM64_T6041
  Available memory (MB): 49152
  Available CPU cores: 16
Binaries:
  Node: 20.20.0
  npm: 10.8.2
  Yarn: N/A
  pnpm: 10.28.1
Relevant Packages:
  next: 16.2.0-canary.33 // There is a newer canary version (16.2.0-canary.35) available, please upgrade! 
  eslint-config-next: N/A
  react: 19.2.4
  react-dom: 19.2.4
  typescript: 5.9.3
Next.js Config:
  output: N/A
There is a newer canary version (16.2.0-canary.35) available, please upgrade! 
   Please try the latest canary version (`npm install next@canary`) to confirm the issue still exists before creating a new issue.
   Read more - https://nextjs.org/docs/messages/opening-an-issue
RAW_BUFFERClick to expand / collapse

Link to the code that reproduces this issue

https://github.com/ellemedit/next-server-patch-replace-state-bug

To Reproduce

Create an app with two route groups that have different layouts (e.g., (tabs) and (detail)) Enable cacheComponents: true and experimental: { viewTransition: true } Add a middleware that rewrites a dynamic route with a varying search param (e.g., _t=<timestamp>), so the PAGE segment key differs between prefetch and navigation The dynamic route page must read searchParams (so the segment key includes the param) Use router.push() to navigate from a page in (tabs) to a page in (detail)

Current vs. Expected behavior

Current behavior: Back navigates to Home instead of Messages. history.length does not increment on the Messages → Message Room navigation because history.replaceState is called instead of history.pushState. The root cause is in :

// A retry should not create a new history entry.
const navigateType = 'replace'

When a PPR segment mismatch is detected (e.g., __PAGE__?{"_t":"T1"} from prefetch vs __PAGE__?{"_t":"T2"} from dynamic request), dispatchRetryDueToTreeMismatch dispatches ACTION_SERVER_PATCH. The serverPatchReducer unconditionally sets navigateType = 'replace', which overwrites the original router.push()'s pendingPush = true to false. This causes HistoryUpdater to call replaceState instead of pushState.

Expected behavior: Back should navigate to Messages (the previous page). history.pushState should be called since the original navigation was router.push(). The PPR retry should not override the original navigation intent.

Provide environment information

Operating System:
  Platform: darwin
  Arch: arm64
  Version: Darwin Kernel Version 25.2.0: Tue Nov 18 21:09:56 PST 2025; root:xnu-12377.61.12~1/RELEASE_ARM64_T6041
  Available memory (MB): 49152
  Available CPU cores: 16
Binaries:
  Node: 20.20.0
  npm: 10.8.2
  Yarn: N/A
  pnpm: 10.28.1
Relevant Packages:
  next: 16.2.0-canary.33 // There is a newer canary version (16.2.0-canary.35) available, please upgrade! 
  eslint-config-next: N/A
  react: 19.2.4
  react-dom: 19.2.4
  typescript: 5.9.3
Next.js Config:
  output: N/A
⚠ There is a newer canary version (16.2.0-canary.35) available, please upgrade! 
   Please try the latest canary version (`npm install next@canary`) to confirm the issue still exists before creating a new issue.
   Read more - https://nextjs.org/docs/messages/opening-an-issue

Which area(s) are affected? (Select all that apply)

cacheComponents, Dynamic Routes, Partial Prerendering (PPR)

Which stage(s) are affected? (Select all that apply)

Vercel (Deployed), next build (local), Other (Deployed)

Additional context

How the mismatch triggers: NextLink prefetches the route → PPR caches static shell with __PAGE__?{"_t":"T1"} User clicks → router.push() → navigateReducer creates tasks from prefetched tree Dynamic fetch fires → server responds with __PAGE__?{"_t":"T2"} (middleware changed the timestamp) writeDynamicDataIntoNavigationTaskmatchSegment() fails (segment keys differ) abortRemainingPendingTasks → task status === 0 (still pending) → exitStatus = 1 spawnDynamicRequestsdispatchRetryDueToTreeMismatchACTION_SERVER_PATCH serverPatchReducer hardcodes navigateType = 'replace'pendingPush becomes false HistoryUpdater calls replaceState → history entry lost

extent analysis

Fix: Preserve the original navigation intent when a PPR retry occurs

The bug is caused by serverPatchReducer always forcing navigateType = 'replace'.
When a segment‑key mismatch triggers a retry, the reducer must keep the navigation type that was originally requested (push for router.push).

1️⃣ Upgrade (preferred)

[email protected]‑canary.35 already contains the fix.

npm i next@canary   # or pnpm add next@canary

Run the repro again – the back button should now use pushState.


2️⃣ Patch the reducer in your own fork (if you must stay on 16.2.0‑canary.33)

  1. Create a local copy of the reducer (node_modules/next/dist/client/components/router-reducer/server-patch-reducer.ts).
  2. Replace the hard‑coded line with logic that respects the pending navigation flag.
// --- before -------------------------------------------------
export function serverPatchReducer(state, action) {
  // …
  // A retry should not create a new history entry.
  const navigateType = 'replace'   // <-- always replace
  // …
}

// --- after --------------------------------------------------
export function

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING