fix(app-router): prevent repeated hard-navigation loops#1111
fix(app-router): prevent repeated hard-navigation loops#1111james-elicx merged 3 commits intocloudflare:mainfrom
Conversation
Root-boundary changes currently trigger a hard navigation without remembering the target. If an old client keeps receiving an incompatible new-server payload for the same URL, the recovery path can re-enter the same hard navigation instead of stopping the loop. Persist the attempted hard-navigation target in sessionStorage, block a repeated same-target attempt once the browser is already on that URL, and clear the guard after a successful initial RSC bootstrap. Add old-client/new-server future compatibility coverage so unknown metadata remains a render-fresh fallback, not reuse proof.
commit: |
|
/bigbonk review |
Review: fix(app-router): prevent repeated hard-navigation loopsThis is a clean, well-scoped implementation of The test assertion fixes for trace entry indices are a legitimate fix — All 125 tests pass locally ( Observations (none blocking)1. Ignored return value in Both hard-navigate paths in 2. Asymmetric sessionStorage-unavailable behavior ( When 3. Unused
4. Suggested additional test case The loop-guard test (line 1973) covers the core scenario well. One case that would strengthen coverage: verify a cross-page hard navigation to the same target still works. E.g., the browser is on SummarySolid work. The guard design mirrors the existing |
|
Review posted successfully. Here's a summary of the review: PR #1111 — fix(app-router): prevent repeated hard-navigation loops This is a well-scoped implementation of Key points from the review:
|
Bonk noted that the hard-navigation guard allows cross-URL recovery even when sessionStorage cannot persist the guard, and that same-URL server-action hard navigation intentionally ignores the guard result. Document those invariants in the controller and add coverage proving a stored guard target does not block a hard navigation when the browser is currently on a different URL.
|
/bigbonk review |
There was a problem hiding this comment.
Review: fix(app-router): prevent repeated hard-navigation loops
Clean implementation. The loop guard design is sound: sessionStorage-backed one-shot with persistence verification, cleared alongside clearReloadFlag() in both initial-RSC success paths. The DI seam via performHardNavigation keeps the controller testable without needing to mock window.location.assign separately from the guard logic.
The trace assertion fixes (entries[0] to entries[1]) are a legitimate catch. The future-schema compatibility test locks down the conservative fallback, which is a good addition.
The cross-page guard test (line 2040) directly addresses the earlier review feedback about verifying the guard does not over-block when currentHref !== targetHref. Good coverage.
One minor observation inline, otherwise LGTM.
|
Inline note for Nit: |
|
Review posted. Here's a summary: PR #1111 — fix(app-router): prevent repeated hard-navigation loops This is a well-scoped, clean implementation of
One minor observation posted about the intentional asymmetry between normalized URLs in the guard vs raw URLs in |
|
/bigbonk review |
There was a problem hiding this comment.
Review: fix(app-router): prevent repeated hard-navigation loops
Verified all 126 tests pass locally. The implementation is correct, well-bounded, and architecturally consistent with the existing recoverFromBadInitialRscResponse pattern.
What I verified
-
Guard logic is sound. The
performHardNavigationWithLoopGuardfunction correctly handles the four cases: (a) stored guard matches + same URL → block, (b) storage unavailable + same URL → block, (c) storage unavailable + different URL → allow (forward progress), (d) normal case → write guard + navigate. The two-conjunct check (storedTarget === targetHref && currentHref === targetHref) is the right predicate — it prevents over-blocking cross-page navigations to the same destination. -
Guard lifecycle is correct.
clearHardNavigationLoopGuard()is placed alongsideclearReloadFlag()in both initial-RSC success paths (embedded RSC at line 698, fetched RSC at line 762). The guard is not cleared inrecoverFromBadInitialRscResponse, which is correct — that path handles bad RSC fetches before the client starts doing client-side navigations. -
DI seam works well. The
performHardNavigationdependency injection keeps the controller testable. The tests correctly use the real guard implementation (viastubWindow) rather than mocking it away, which exercises the actual sessionStorage interaction. -
Outcome mapping is correct. The
renderNavigationPayloadhard-navigate path (line 509) mapsperformHardNavigationreturningfalseto"no-commit", which is the right degraded state — the current document stays visible. ThecommitSameUrlNavigatePayloadpaths (lines 581, 598) intentionally ignore the return value and returnundefined, matching the existing server-action return contract. -
Future-schema test is good. The
app-elements.test.tsaddition (line 726) correctly exercises the end-to-end path:parseArtifactCompatibilityEnvelopereturnsnullfor a futureschemaVersion(via thehasCurrentSchemaVersionscheck),readArtifactCompatibilityMetadatafalls back to the empty envelope, andevaluateArtifactCompatibilityproduces{ kind: "unknown", fallback: "renderFresh" }. This locks down the conservative behavior for forward compatibility. -
Cross-page guard test (line 2076) directly validates the guard doesn't over-block when
currentHref !== targetHref, which was the right additional test case to include.
No blocking issues. One minor inline note.
| if (mode === "replace") { | ||
| window.location.replace(href); | ||
| } else { | ||
| window.location.assign(href); | ||
| } | ||
| return true; |
There was a problem hiding this comment.
Extremely minor: location.assign(href) and location.replace(href) pass the original (potentially relative) href, while the guard stores normalizeBrowserHref(href). This is correct — the browser resolves relative URLs the same way new URL(href, location.href) does, so the guard comparison on the next page load will match. But if you ever add query-stripping or hash-stripping to normalizeBrowserHref, these would silently diverge. A one-line comment noting the intentional asymmetry would help a future reader:
| if (mode === "replace") { | |
| window.location.replace(href); | |
| } else { | |
| window.location.assign(href); | |
| } | |
| return true; | |
| // The guard stores the normalized targetHref, but we pass the original href | |
| // to the browser. The browser resolves relative URLs identically to | |
| // normalizeBrowserHref, so the guard comparison on the next page load | |
| // matches. If normalizeBrowserHref ever strips query/hash, this must be | |
| // revisited. | |
| if (mode === "replace") { | |
| window.location.replace(href); | |
| } else { | |
| window.location.assign(href); | |
| } | |
| return true; |
|
Review posted and approved. Here's the summary: PR #1111 — APPROVED All 126 tests pass. The implementation is correct and architecturally consistent with the existing
One minor suggestion: add a comment noting the intentional asymmetry between normalized URLs in the guard vs raw URLs passed to |
What this changes
Implements
#726-COMPAT-04/05from #726.This adds a browser-side hard-navigation loop guard for App Router root-boundary hard navigation. When a hard navigation has already been attempted for a target and the browser is already on that target URL, vinext now blocks the repeated attempt, logs the recovery decision, clears the guard, and leaves the current document in place instead of reloading forever.
It also adds explicit old-client/new-server coverage for future compatibility metadata. A payload with a future artifact compatibility schema still degrades to unknown compatibility and
renderFresh; it does not become positive reuse proof.Bonk: please read issue #726 for the big-picture architecture before reviewing this slice.
Why
Issue #726 calls out deployment compatibility as algebra rather than strict equality everywhere. From first principles, a router should only reuse or visibly commit work when it has proof that the work belongs to the same semantic world: compatible graph, compatible deployment, compatible root boundary, compatible payload schema, and a still-current lifecycle token. When that proof is missing, the fallback can be a hard navigation, but the fallback itself also needs a bounded safety contract.
A hard navigation is a recovery effect, not semantic proof. If the same client/server skew keeps producing the same hard-navigation decision for the same URL, repeating the effect no longer moves the system toward a safer state. It just burns the current document and starts over. The correct invariant is: try the hard navigation once for the target, then stop if the freshly loaded document reaches the same decision again for that same target.
For example, an old client on
/dashboardcan request RSC from a new server whose root-boundary metadata is incompatible with the visible tree. The client correctly chooses a hard navigation to/dashboard. If the loaded document still has the old client bundle and receives the same incompatible payload again, an unguardedwindow.location.assign('/dashboard')loops forever. With this guard, the first hard navigation is allowed, the repeated same-target hard navigation is blocked, and the server-rendered document remains visible.The existing root-boundary path had a direct
window.location.assign(...)side effect with no persisted memory of the attempted target. That preserved current root-boundary behavior, but it did not bound the recovery path if the same client/server skew kept producing the same hard-navigation decision for the same URL.Approach
sessionStorage.Non-goals for this PR match
#726-COMPAT-04/05: no full rolling deploy protocol, no skip transport, and no cache/reuse authority changes.Validation
Next.js reference checked:
test/e2e/app-dir/segment-cache/deployment-skew/deployment-skew.test.tsin the local Next.js canary clone, which covers deployment skew hard-navigation behavior.vp test run tests/app-browser-entry.test.ts tests/app-elements.test.tspassed: 126 tests.vp check tests/app-browser-entry.test.ts packages/vinext/src/server/app-browser-navigation-controller.tspassed with no warnings after the Bonk follow-up.vp check tests/app-browser-entry.test.ts tests/app-elements.test.ts packages/vinext/src/server/app-browser-navigation-controller.ts packages/vinext/src/server/app-browser-entry.tspassed with no warnings before the Bonk follow-up.vp checkexited successfully. It reported one existing warning outside this PR inpackages/vinext/src/server/request-pipeline.tsforunknown | undefined.