Repository navigation
router.push after an awaited Server Action commits as replaceState, but only in deployed environments (16.2.10 and 16.3.5)
#98822
Replies: 9 comments 4 replies
|
Note: co-written with claude but it all checks out and should pretty readable |
|
updated with a clarification around |
|
Your analysis looks solid. This seems much more like a Next.js retry/history issue than an application-level As a temporary workaround, I’d move the navigation into the Server Action itself: import { redirect } from 'next/navigation'
// after the mutation
redirect(nextRoute)
Your Next 15 vs Next 16 test on the same infrastructure is also strong evidence that this is framework-related. If If this helps, please consider marking it as the accepted answer. |
|
I cannot reproduce the production-only Next 16 history bug from here, but your Next 15 vs 16 matrix on the same deploy is already the strongest evidence this is framework history, not the click handler. For a mutation that should become a new history entry, the App Router pattern that is actually specified is to navigate inside the Server Action: 'use server'
import { redirect } from 'next/navigation'
export async function updateSubscription(...) {
// ...write...
redirect(nextRoute) // history push from the action
}If If the next route depends on the action result, keep that logic in the action and I have not run 16.2.10 or 16.3.5 on a Vercel-like deploy. If |
|
Your reading of Why only in deployed environmentsIn Because Answering your questions
'use server'
export async function updateSubscription(id: string, variantId: string) {
// mutate...
const nextRoute = computeRoute(...)
redirect(nextRoute) // Next handles the 303 redirect with a clean push navigation
}If you must trigger navigation client-side, refreshing the router cache before pushing avoids the mismatch: startTransition(async () => {
await updateSubscription(subscriptionId, variantId)
router.refresh()
router.push(nextRoute)
}) |
|
I've been trying to repro this, as a means to investigate further of course. My agents falsified a bunch of possible triggers, and I've got some follow ups:
|
Why this happensWhen an awaited Server Action completes, Next.js automatically triggers a client-side reconciliation / soft-refresh to sync the UI with updated Server Components. If you call Solution 1: Use
|
|
Thanks for the input guys - i'm going to test a couple of the ideas here:
@icyJoseph Thanks for the questions - i'll answer what I can but I'll also need to check a couple of things with the devops guys before i do - should be tomorrow |
|
Thanks for digging into this @icyJoseph — answers to all of your questions below, plus two new results since I posted, one of which rules out a workaround that's been suggested a few times in this thread. Answers1. How is HTTP/2 terminated? h2 terminates at Cloudflare, and it's HTTP/1.1 from there on. Our platform team confirmed this from the load balancer logs rather than just the config:
So no, Envoy is not speaking h2 to Node — and 2. Any Not in the action itself — it's a plain There is one nearby that I think is worth your attention though. The destination page has a mount effect that can call a server action which sets a cookie and calls 3. Is it the first navigation, or has the user already navigated within the app? I tested both, and it fails either way:
So "first navigation in the document" is not the factor. 4. Does the action return early with No. It returns a normal response. The target route has no 5. Custom No. Plain 6. Proxy in play (rewrites, cookies-mutating auth)? Yes, and this is the answer I'd most want a second opinion on. We run a middleware chain on every matching request: CSP headers, affiliate capture, tracking, auth, a second auth pass, subscription, and a coupon resolver. Three of those set cookies on the response, and the coupon one also passes a modified request down the chain. Auth can redirect. So: cookie-mutating middleware runs on the RSC requests involved in these navigations. 7. Parallel or intercepted routes on the target? No. No 8. How often does this reproduce? Deterministic — 100% in every deployed environment, 0% locally. Not intermittent. My "when the heuristic goes the wrong way" phrasing in the original post was me reading the source, not describing flakiness. Sorry for the ambiguity. 9. Network: document, fetch, or stalled RSC?
10. Incognito / extensions off? Same behaviour. Tested in a Chrome incognito window with extensions disabled, and separately in Firefox — the history entry is replaced rather than pushed in both. So it isn't extension interference, and it isn't Chrome-specific. Two new resultsThree consecutive mutating navigations all lose their history entry. On a freshly loaded page I went through three steps, each one awaiting a server action and then calling Wrapping the push in async function handleContinue() {
await updateSubscription(...) // Server Action, awaited
startTransition(() => {
router.push(nextRoute) // sync callback, after the await
})
}to the affected environment and it still committed as That also makes me doubt the "the push collides with the action's pending reconciliation" explanation offered upthread, since deferring the push until after the action settles is exactly what that variant does. On
|
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Summary
A programmatic
router.push()that runs after an awaited Server Action commits to browser history as a replace instead of a push, so the Back button skips that page. It only happens in deployed environments — it never reproduces locally in any configuration we tried, including a local production build.We could not produce a minimal public reproduction, which is why this is a discussion rather than an issue. What we do have is a controlled environment matrix, a same-infrastructure Next 15 control, and what we believe is the exact line responsible. It matches withdrawn issue #89802 almost exactly; that reporter closed their own issue saying their repro didn't work, so as far as we can tell this has never been triaged.
Symptom
In a Next.js App Router page, a click handler does roughly this:
Deployed, the navigation happens and the URL is correct, but no history entry is created. Pressing Back skips the page entirely and lands on whatever preceded it.
Measured with a Navigation API listener rather than
history.length(which is capped at 50 in Chrome and can't distinguish push from replace):type: push, thentype: replaceto the same URL (normal), and Back producestype: traverseback to the previous page.type: replace,type: replace. No push at all. Back skips the page.The broken navigation also issues three RSC fetches for a single navigation, where the working one issues one.
Evidence matrix
Same application code, same user journey, same click, varying only the environment and framework version:
next dev, directnext dev, behind local reverse proxynext build+next start, directnext build+next start, behind local reverse proxyThe Next 15 row is the important control: same deployment infrastructure, same Kubernetes setup, same reverse proxy, same latency — only the framework version differs.
Also unaffected everywhere, including on the same broken page:
startTransition(() => router.push(url))in a click handler that performs no mutation beforehand.next/linknavigation.We first assumed the shape of the dispatch mattered, and it does not. We deployed a build in which the failing handler was rewritten to
awaitthe Server Action and then callstartTransition(() => router.push(url))— the same form as the working call sites above — and it still committed asreplace. We also tested the originalstartTransition(async () => { await …; router.push() })form in the same environment, with the same result.So the distinguishing factor is not the transition style, the route, or the handler shape. It is whether a mutation happens between the destination being prefetched and the navigation being made.
Suspected cause
dispatchRetryDueToTreeMismatchinclient/components/router-reducer/ppr-navigations.jsdecides the history intent for a retry with:Next's own comment above it reads:
When that heuristic goes the wrong way, the original
pushintent is discarded,ACTION_SERVER_PATCHis dispatched withnavigateType: 'replace',pushRef.pendingPushends upfalse, andHistoryUpdaterinapp-router.jstakes theelsebranch and callsreplaceState.Why an awaited Server Action would trigger it: the destination is prefetched before the mutation and requested dynamically after it, so the prefetched tree and the dynamic response genuinely differ and the retry path runs. That also explains the three RSC fetches (the retry, plus
invalidateRouteCacheEntriesre-prefetching visible links) and why it never reproduces locally, sincenext devdoesn't prefetch the same way.We have not directly observed the mismatch firing — we inferred it from the retry's fetch signature plus the code path — so treat this part as a strong hypothesis rather than confirmed.
Version check
We diffed
dispatchRetryDueToTreeMismatchbetween 16.2.10 and 16.3.5. 16.3.5 adds aretryFreshnessPolicyparameter and changesdiscoverKnownRoute's signature, but theretryNavigateTypeline is byte-identical. Upgrading does not fix it.Ruled out by experiment
startTransitionusage around the push — async and sync forms both tested in the affected deployment, both replace.Environment
Notably
cacheComponentsis not enabled here, whereas #89802's reproduction required it. If the retry path is reachable without Cache Components, that would widen the scope beyond what that issue described.What would help
retryNavigateTypefalling back to'replace'the expected behaviour when the originalpushhas not yet committed, and should the original intent be preserved unconditionally?dispatchRetryDueToTreeMismatchfires, so we can confirm rather than infer? A dev-only warning would have saved us a lot of time here.Happy to run further instrumentation on the affected deployment, or to try a patched build, if that helps narrow it down.
Related
replaceStateinstead ofpushStatewhen PPR tree mismatch triggersACTION_SERVER_PATCH#89802 —router.push()createsreplaceStateinstead ofpushStatewhen PPR tree mismatch triggersACTION_SERVER_PATCH. Same symptom and same suspected line; closed by its own reporter because their reproduction stopped working, so it was never triaged.All reactions