fix: align app router navigation parity#1705
Conversation
commit: |
App Router RSC requests now keep the route pathname shape when adding _rsc. Stripping a trailing slash made trailingSlash routes fetch a non-canonical URL and let the server redirect the client-side RSC request back to the slash form. The MPA navigation scheduler also keys pending hard navigations by both href and history mode so a later replace does not reuse an earlier push closure. Redirect handling keeps the global browser bridge out of passive server rendering while retaining client-side redirect recovery. Adds focused regression coverage for trailingSlash RSC requests, same-href push-to-replace hard navigation, and client redirect guard loops. Updates existing navigation observers to the canonical route URL plus _rsc transport.
…gation-parity # Conflicts: # tests/e2e/app-router/nextjs-compat/actions-revalidate.spec.ts
A bfcache-restored App Router document can keep the scheduler's last pending hard-navigation marker after the external navigation already left the page. Reissuing the same href and history mode then entered the suspended MPA path without scheduling another location change. Expose a scheduler reset for the browser lifecycle boundary and call it on persisted pageshow events. Cover the scheduler contract so the same external navigation can run again after reset.
Unhandled client redirect recovery currently lives inside every RedirectBoundary, while Pages Router params fallback can keep stale App Router params after an App-to-Pages transition. That makes passive boundaries own browser runtime wiring and lets stale app params win over live Pages Router params. Move the unhandled redirect bridge into BrowserRoot under AppRouterContext, keep RedirectBoundary passive, and prefer live Pages Router params when that compat context exists. Add an App-to-Pages navigation regression fixture.
- Add decodeRedirectError() to shims/navigation.ts as the single source of truth for parsing redirect error digests - Replace three private helpers in error-boundary.tsx with shared decodeRedirectError() call - Replace inline digest parsing in AppRouterRedirectBridge with shared decodeRedirectError() call (fixes unsafe decodeURIComponent) - Rename test that claimed to test redirect() but actually tests <Link> navigation
- isRedirectError: use in-narrowing instead of as-casts - AppRouterRedirectBridge: remove as after guard already narrows - RedirectErrorBoundary: inline digest access, use in for handled check
|
/bigbonk review |
There was a problem hiding this comment.
Thorough review of the navigation parity PR. The overall approach is solid — switching to canonical route URLs with _rsc query params for RSC requests matches Next.js behavior, the MPA navigation scheduler is well-designed, and the redirect boundary reset fix correctly avoids the infinite-loop issue. Test coverage is excellent across unit, integration, and E2E layers.
A few items below, mostly nits and one question worth checking.
Scroll restoration retry duplication
The retry-based scroll restoration (scrollTo with up to 60 rAF frames) is now duplicated in three places:
restoreScrollPositioninnavigation.ts(pending branch)restoreScrollPositioninnavigation.ts(no-pending branch)restorePopstateScrollPositioninapp-browser-entry.ts
Consider extracting a small retryScrollTo(x, y, opts?) helper to reduce duplication. Not blocking, but would make future scroll behavior changes safer.
noindex parity for Forbidden/Unauthorized client boundaries
The NotFoundBoundaryInner client render now correctly emits <meta name="robots" content="noindex" />. The server-side renderAppPageHttpAccessFallback already emits noindex for all HTTP error fallbacks (404/403/401). But the client-side ForbiddenBoundaryInner and UnauthorizedBoundaryInner don't emit noindex in their render methods. Worth checking whether Next.js's client-side 403/401 boundaries also emit noindex — if so, this is a parity gap to track.
|
|
||
| const token = this.#nextToken + 1; | ||
| this.#nextToken = token; | ||
| this.#pendingNavigation = { href, historyUpdateMode, token }; |
There was a problem hiding this comment.
Nit: #nextToken can overflow Number.MAX_SAFE_INTEGER with enough navigations. In practice this will never happen (it would take ~9 quadrillion calls), but wrapping with modular arithmetic would be more defensive. Not blocking.
| // mutation just enough for the old tree to commit the pending transition | ||
| // signal before unload. | ||
| mpaNavigationScheduler.navigate(window, href, historyUpdateMode); | ||
| } |
There was a problem hiding this comment.
Confirming my understanding of the strict-mode interaction: in dev, React's strict mode double-invokes the render function. The first invocation calls performMpaNavigation (scheduling a delayed location.assign/replace) and then throw unresolvedMpaNavigation. React discards that render. The second invocation calls performMpaNavigation again — but the scheduler's dedup check (pendingNavigation?.href === href && pendingNavigation.historyUpdateMode === historyUpdateMode) will no-op on the identical call, so this is safe. Good.
| deps.restorePopstateScrollPosition(event.state, { | ||
| shouldContinue: () => deps.isCurrentNavigation(popstateNavId), | ||
| }); | ||
| } |
There was a problem hiding this comment.
The early scroll restoration fires via scheduleAfterFrame (one rAF) and then retries up to 60 frames. Meanwhile the pendingNavigation.finally() branch (line 58) skips scroll restoration when shouldRestoreSavedScroll is true, so the early path is the only path for saved-position restores.
Is there a risk that the navigation completes and changes the route content (potentially shrinking document height), while the early rAF-based restore loop is still trying to scroll to a now-unreachable position? The shouldContinue guard checks isCurrentNavigation, which protects against superseded navigations, but not against the content having settled at a shorter document height. The attempts >= 60 cap prevents an infinite loop, but you'd get up to 60 frames of futile scrollTo calls.
Probably acceptable given the upstream parity goal (Next.js has similar behavior), but worth flagging.
| // redirect() in the first place. For deterministic redirects (e.g. | ||
| // auth guards), that creates an infinite redirect loop. | ||
| // Matches Next.js's HandleRedirect in redirect-boundary.tsx. | ||
| reset(); |
There was a problem hiding this comment.
The reset() call inside startTransition is the key fix for the redirect loop. By clearing the boundary state after dispatching the navigation, React won't re-mount the page component that threw redirect() during the next render pass. This matches Next.js's redirect-boundary.tsx behavior.
The reset callback from the parent render() is a fresh arrow per render, but since HandleRedirect only renders when redirect state is set (and reset() clears it, preventing re-render with the same props), this won't cause effect re-fires in practice. Looks correct.
| return this.props.fallback; | ||
| return ( | ||
| <> | ||
| <meta name="robots" content="noindex" /> |
There was a problem hiding this comment.
Good addition for Next.js parity. The exact content="noindex" string matches the upstream assertion at navigation.test.ts#L654-L660.
| } | ||
|
|
||
| const hasSameUrlRerenderPayload = isServerActionResult(result) && result.root !== undefined; | ||
| syncServerActionHttpFallbackHead(hasSameUrlRerenderPayload ? null : fetchResponse.status); |
There was a problem hiding this comment.
Good fix — moving syncServerActionHttpFallbackHead after the redirect-target check and conditioning it on hasSameUrlRerenderPayload. Previously this was called unconditionally before parsing the flight response, which would incorrectly inject noindex on 404 status even when the action returned a re-render payload. Now noindex is only injected when there's no re-render payload to replace the current page content.
| function getClientParamsSnapshot(): Record<string, string | string[]> { | ||
| const state = getClientNavigationState(); | ||
| const pagesCtx = _getPagesNavigationContext(); | ||
| if (pagesCtx) return pagesCtx.params; |
There was a problem hiding this comment.
The reordering (check pagesCtx first) is the correct fix for stable useParams() in Pages Router. When navigating from App Router to Pages Router, stale App Router client params could previously leak through because state.clientParams still had values from the App Router navigation. The early return on pagesCtx ensures Pages Router pages always use their own route params as the source of truth.
| void Router.prefetch(href); | ||
| }, | ||
| }), | ||
| [], |
There was a problem hiding this comment.
The useMemo deps array is [] (empty), so this adapter is created once. The methods capture Router (module-level singleton) which is correct — they're stable references.
bfcacheId is hardcoded to "0". In Next.js, bfcacheId detects bfcache restorations. Is the static value sufficient here? If the App Router entry also uses bfcacheId for bfcache detection, the static value might cause issues on Pages Router pages after a bfcache restore. Worth verifying but probably fine for initial parity.
| } else { | ||
| window.location.assign(href); | ||
| } | ||
| await new Promise<void>(() => {}); |
There was a problem hiding this comment.
Good addition — after location.replace/location.assign, the await new Promise<void>(() => {}) suspends the caller forever. This ensures the navigateClientSide promise never resolves, keeping useTransition's isPending true until the page unloads. Matches the MPA navigation suspension pattern used in the App Router's navigateExternal path.
| const url = new URL(request.url()); | ||
| return ( | ||
| url.pathname === pathname && url.searchParams.has("_rsc") && request.headers()["rsc"] === "1" | ||
| ); |
There was a problem hiding this comment.
Nice centralization. The _rsc search param + rsc: 1 header check is the canonical way to identify RSC requests in the new URL format. This replaces the fragile .rsc pathname suffix matching across all E2E tests.
|
Review posted on PR #1705. Here's a summary of the findings: Overall: Solid PR. The switch from Key inline comments (10 total):
Top-level observations:
|
|
/bigbonk review |
There was a problem hiding this comment.
Review: App Router navigation parity
Verified the implementation against the source. This is a solid, well-tested PR. The core change — switching client RSC requests from the legacy .rsc transport path to the canonical route URL + _rsc query param + rsc: 1 header — matches Next.js behavior and is consistently applied across createRscRequestUrl, redirect-location canonicalization, the E2E helpers, and the unit/integration assertions.
Things I verified as correct
- Trailing-slash preservation (
createRscRequestUrl/resolveHardNavigationTargetFromRscResponse):/docs/->/docs/?_rscis preserved sotrailingSlash: truenavigations don't trigger a server redirect. The newapp-rsc-handler.test.tscoverage proves the generated URL isn't redirected. Good. - MPA scheduler (
app-browser-mpa-navigation.ts): keying pending navigations by bothhrefandhistoryUpdateModeplus a monotonic token correctly lets a later same-hrefreplace()supersede an earlier pendingpush(). The token check in the delayednavigateclosure is the right guard. Dedup of identical pending navigations is also correct. - Redirect boundary reset (
error-boundary.tsx:87): callingreset()insidestartTransitionafter dispatching the navigation is the correct fix for the redirect loop and matches Next.js'sredirect-boundary.tsx. - Pages Router adapter (
router.ts:1545): theAppRouterContext.Providerwrapping letsuseRouter()fromnext/navigationwork inside Pages Router pages.bfcacheId: "0"is consistent with the App Router's own adapter innavigation.ts:1584, so this is not a divergence. useParamsstability: reorderinggetClientParamsSnapshotto checkpagesCtxfirst (navigation.ts:1140) is safe because the Pages accessor is only registered by the Pages Router provider — App Router renders returnnulland fall through tostate.clientParams. The pages context is URL-key cached (router.ts:432), so the params object is referentially stable across rerenders, satisfyinguseSyncExternalStore.
Items the prior automated review raised that I checked and consider resolved
- Scroll-restore duplication: now centralized in
retryScrollTo(hash-scroll.ts:34) and used by bothnavigation.tsandapp-browser-popstate.ts. The earlier "duplicated in 3 places" concern is addressed. noindexparity for Forbidden/Unauthorized client boundaries: not a gap —ForbiddenBoundaryInner.render(error-boundary.tsx:362) andUnauthorizedBoundaryInner.render(error-boundary.tsx:434) both already emit<meta name="robots" content="noindex" />, same asNotFoundBoundaryInner.
Two minor, non-blocking observations inline.
| deps.setPendingNavigation(pendingNavigation); | ||
| const shouldRestoreSavedScroll = hasSavedScrollPosition(event.state); | ||
| if (shouldRestoreSavedScroll) { | ||
| scheduleAfterFrame(() => { |
There was a problem hiding this comment.
Minor (non-blocking): the early saved-scroll path schedules retryScrollTo via scheduleAfterFrame and the retry loop runs up to 60 frames. The shouldContinue guard (isCurrentNavigation) protects against a superseded navigation, but not against the new route content settling at a shorter document height — in that case window.scrollTo(x, y) can never reach y and the loop burns all 60 frames clamping to the max scrollable position before giving up. This is acceptable for upstream parity (Next.js retries similarly), just flagging so it's a conscious choice rather than an oversight.
| if (!digest.startsWith("NEXT_REDIRECT;")) return null; | ||
|
|
||
| const parts = digest.split(";"); | ||
| const encodedTarget = parts.length >= 5 ? parts.slice(2, -2).join(";") : parts[2]; |
There was a problem hiding this comment.
The parts.slice(2, -2).join(";") rejoin for the 5-part format is effectively defensive-only: every redirect URL written into a digest is produced via encodeURIComponent (e.g. navigation.ts:1917, app-browser-entry.ts:699), and encodeURIComponent encodes ; as %3B, so a redirect URL can never contain a literal ;. Not a bug — the join(";") just can't actually be exercised. Worth a brief comment noting that the slice is purely belt-and-suspenders, or simplifying to parts[2] for the encoded-URL case, since the doc comment's mention of handling URLs with embedded segments is slightly misleading.
|
Review posted successfully on PR #1705. SummaryI reviewed the App Router navigation parity PR by reading the actual source for each change rather than just the diff. The PR is solid and well-tested. Key findings: Verified correct:
Disputed two prior automated-review claims (and noted as resolved in my review):
Two minor, non-blocking inline comments:
No blocking issues found. |
|
/bigbonk review |
|
@james-elicx Bonk workflow was cancelled. View workflow run · To retry, trigger Bonk again. |
|
/bigbonk review |
There was a problem hiding this comment.
Review: App Router navigation parity
Reviewed the full diff plus the surrounding source for each touched file. This is a solid, well-tested PR and the follow-up commits have addressed the earlier automated-review feedback (the retryScrollTo extraction, noindex on Forbidden/Unauthorized boundaries, and the decodeRedirectError comment clarification are all in place).
Verified correct
.rsctransport → canonical route URL +_rsc+rsc: 1header is applied consistently:toRscRequestPathnow returns the path verbatim (app-rsc-cache-busting.ts:285), redirect-location canonicalization flows through the samecreateRscRequestUrl(:316), and the E2E helperisAppRouterRscRequestForPath(tests/e2e/helpers.ts:4) centralizes detection. Unit + integration assertions were updated in lockstep.- Trailing-slash preservation:
new URL("/docs/", ...)keeps the slash, so/docs/?_rscis produced and the newapp-rsc-handler.test.tscase proves it is not redirected undertrailingSlash: true. Good. - MPA scheduler (
app-browser-mpa-navigation.ts): keying by(href, historyUpdateMode, token)correctly lets a later same-hrefreplace()supersede a pendingpush(), and the dedup check no-ops React strict-mode double-invocation. Thepageshow/event.persistedreset (app-browser-entry.ts:2165) correctly clears stale pending state after a bfcache restore. - Redirect boundary
reset()insidestartTransition(error-boundary.tsx:80) matches Next.js'sredirect-boundary.tsxand fixes the loop. The new globalAppRouterRedirectBridgeonly handles redirect errors that escape every boundary — React'sonCaughtError/onUncaughtErrorhooks (app-browser-entry.ts:1575) route boundary-caught errors to React's own handlers, notwindowevents, so there is no double-dispatch for the boundary-caught path. - Pages→App adapter (
router.ts:1546):bfcacheId: "0"is consistent with the App Router's own adapter, and theuseParamsreorder (navigation.ts:1140) is safe because the Pages accessor only registers under the Pages Router provider. noindexparity:NotFoundBoundaryInner,ForbiddenBoundaryInner, andUnauthorizedBoundaryInnerall emit<meta name="robots" content="noindex" />; the server fallback (app-page-boundary-render.ts:328) does too.
Two minor, non-blocking observations inline. No blocking issues.
| const result = decodeRedirectError(error.digest); | ||
| if (!result) return; | ||
|
|
||
| event.preventDefault(); |
There was a problem hiding this comment.
event.preventDefault() fires for every redirect error that reaches a global error/unhandledrejection listener. That is the intended recovery path for redirect errors that escape all boundaries, and React's onCaughtError/onUncaughtError hooks keep boundary-caught redirects off the window event channel, so there is no double-dispatch in the common case.
One edge worth a thought: this listener is registered once per AppRouterRedirectBridge mount and dispatches via router.push/router.replace. If two genuinely-unhandled redirect errors with different targets land in the same tick (e.g. concurrent rejected promises), both will dispatch inside startTransition and the last one wins — same as a user calling router.replace twice. Non-blocking, just noting the bridge has no per-event dedup; the reset()-driven boundary path is the one that guarantees single-dispatch, and this global path is a best-effort safety net.
| const restore = () => { | ||
| if (!shouldContinue()) return; | ||
| window.scrollTo(x, y); | ||
| if (!shouldContinue() || Math.abs(window.scrollY - y) <= 1 || attempts >= 60) { |
There was a problem hiding this comment.
The added Math.abs(window.scrollY - y) <= 1 early-exit is a nice improvement over the prior pure 60-frame loop — it stops as soon as the target is reached instead of burning all frames. Note this only short-circuits the vertical axis; if y is unreachable (content settled shorter) the loop still runs the full 60 frames clamping at max scroll. That matches the upstream parity goal, so this is fine as-is — just flagging that the x axis isn't part of the convergence check, which is harmless given vinext only ever passes a real x for horizontal-scroll restores.
|
Review posted successfully on PR #1705. SummaryI reviewed the App Router navigation parity PR by reading the full diff plus the surrounding source for each touched file (not just the diff hunks). The PR is solid and well-tested, and the 11 commits include follow-ups that already address the earlier automated-review feedback ( Verified correct:
Two minor, non-blocking inline notes:
No blocking issues found. |
Summary
_rsc, matching the route URL shape asserted by the Next.js navigation deploy suite.trailingSlash: truenavigations do not fetch a URL that the server has to redirect.replace()cannot be superseded by an earlier pendingpush()closure.notFound()andredirect()boundaries with Next.js: client fallbacks emitnoindex, redirect boundaries reset after navigation, and unhandled redirect errors are recovered globally without requiring an App Router context during passive SSR route renders.useRouter()fromnext/navigationworks inside Pages Router pages, while preserving stableuseParams()snapshots.Review follow-up
createRscRequestUrl("/docs/") -> "/docs/?_rsc"and a server RSC request test proving the generated URL is not redirected undertrailingSlash: true.push(external)followed byreplace(same external)now executes the replace path..rsctransport URLs to canonical route URLs carrying_rscplus thersc: 1request header.Upstream references
Verification
test/e2e/app-dir/navigation/navigation.test.ts: 39 passed / 11 failed.redirect-with-loadingrepeat rendering, upstream immediate scroll restoration, and earlier Pages Router params identity coverage before the adapter fix. Pages Router adapter behaviour is covered locally below.vp run vinext#buildvp test run tests/app-rsc-cache-busting.test.ts tests/error-boundary.test.ts tests/app-rsc-handler.test.ts tests/app-page-execution.test.ts tests/prefetch-cache.test.ts tests/app-page-boundary-render.test.ts tests/app-ssr-error-meta.test.ts tests/navigation-runtime.test.ts tests/app-browser-entry.test.tsvp test run tests/app-rsc-cache-busting.test.ts tests/app-rsc-handler.test.ts tests/app-browser-mpa-navigation.test.ts tests/app-browser-entry.test.tsvp test run tests/link-navigation.test.ts tests/app-page-route-wiring.test.tsvp test run tests/link-navigation.test.tsvp test run --project unitPLAYWRIGHT_PROJECT=app-router npx playwright test tests/e2e/app-router/nextjs-compat/navigation.spec.ts tests/e2e/app-router/nextjs-compat/hash-popstate-scroll.spec.ts tests/e2e/app-router/pages-router-use-params.spec.ts --retries=0PLAYWRIGHT_PROJECT=app-router npx playwright test tests/e2e/app-router/build-id-navigation.spec.ts tests/e2e/app-router/advanced.spec.ts tests/e2e/app-router/nextjs-compat/actions-revalidate.spec.ts tests/e2e/app-router/rsc-fetch-errors.spec.ts tests/e2e/app-router/nextjs-compat/navigation.spec.ts --retries=0vp checkknip.