Skip to content

fix: align app router navigation parity#1705

Merged
james-elicx merged 11 commits into
cloudflare:mainfrom
NathanDrake2406:nathan/app-dir-navigation-parity
Jun 1, 2026
Merged

fix: align app router navigation parity#1705
james-elicx merged 11 commits into
cloudflare:mainfrom
NathanDrake2406:nathan/app-dir-navigation-parity

Conversation

@NathanDrake2406

@NathanDrake2406 NathanDrake2406 commented May 31, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Switch App Router client RSC requests to the canonical route URL plus _rsc, matching the route URL shape asserted by the Next.js navigation deploy suite.
  • Preserve trailing-slash route path shape when generating canonical RSC URLs so trailingSlash: true navigations do not fetch a URL that the server has to redirect.
  • Key delayed MPA hard navigations by both href and history mode, so a later same-href replace() cannot be superseded by an earlier pending push() closure.
  • Align client notFound() and redirect() boundaries with Next.js: client fallbacks emit noindex, redirect boundaries reset after navigation, and unhandled redirect errors are recovered globally without requiring an App Router context during passive SSR route renders.
  • Provide the Pages Router to App Router adapter context so useRouter() from next/navigation works inside Pages Router pages, while preserving stable useParams() snapshots.
  • Retry saved popstate scroll restoration while the traversal remains current so delayed route content can still restore the intended position.

Review follow-up

  • Added focused trailing-slash coverage for createRscRequestUrl("/docs/") -> "/docs/?_rsc" and a server RSC request test proving the generated URL is not redirected under trailingSlash: true.
  • Added unit coverage for external hard navigation ordering: pending push(external) followed by replace(same external) now executes the replace path.
  • Added a deterministic client redirect guard fixture and E2E assertion that the guard navigates once to login and does not loop.
  • Updated stale E2E and unit observers from legacy .rsc transport URLs to canonical route URLs carrying _rsc plus the rsc: 1 request header.

Upstream references

Verification

  • Initial upstream deploy-suite run for test/e2e/app-dir/navigation/navigation.test.ts: 39 passed / 11 failed.
  • End upstream deploy-suite run after the main navigation fixes: 45 passed / 5 failed. The remaining file-scope failures were exact hash offset scrolling, redirect-with-loading repeat 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#build
  • vp 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.ts
  • vp 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.ts
  • vp test run tests/link-navigation.test.ts tests/app-page-route-wiring.test.ts
  • vp test run tests/link-navigation.test.ts
  • vp test run --project unit
  • PLAYWRIGHT_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=0
  • PLAYWRIGHT_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=0
  • vp check
  • Commit hook also ran formatting, type/lint checks, and knip.

@pkg-pr-new

pkg-pr-new Bot commented May 31, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/vinext@1705

commit: 7044cc3

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
@NathanDrake2406
NathanDrake2406 marked this pull request as ready for review June 1, 2026 08:22
@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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:

  1. restoreScrollPosition in navigation.ts (pending branch)
  2. restoreScrollPosition in navigation.ts (no-pending branch)
  3. restorePopstateScrollPosition in app-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 };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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),
});
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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" />

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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);
},
}),
[],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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>(() => {});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Comment thread tests/e2e/helpers.ts
const url = new URL(request.url());
return (
url.pathname === pathname && url.searchParams.has("_rsc") && request.headers()["rsc"] === "1"
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

@ask-bonk

ask-bonk Bot commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

Review posted on PR #1705. Here's a summary of the findings:

Overall: Solid PR. The switch from .rsc suffix URLs to canonical route URLs with _rsc query param matches Next.js behavior. The MPA navigation scheduler, redirect boundary reset fix, and Pages Router adapter are all well-implemented with thorough test coverage.

Key inline comments (10 total):

  • app-browser-mpa-navigation.ts:38 — Minor: token overflow is theoretically possible but practically irrelevant
  • app-browser-entry.ts:922 — Confirmed strict-mode double-render is safe due to scheduler dedup
  • app-browser-popstate.ts:53 — Flagged early scroll restoration retry loop: could futilely scroll for 60 frames if content shrinks after navigation
  • error-boundary.tsx:87 — Confirmed the reset() inside startTransition correctly fixes the redirect loop
  • error-boundary.tsx:286 — Noted good noindex parity for NotFound
  • app-browser-entry.ts:1496 — Noted good fix for conditional syncServerActionHttpFallbackHead
  • navigation.ts:1141 — Confirmed params reordering fix is correct
  • router.ts:1569 — Questioned whether bfcacheId: "0" is sufficient
  • navigation.ts:1480 — Noted good forever-pending promise pattern for MPA navigation
  • helpers.ts:8 — Praised centralized RSC request detection

Top-level observations:

  • Scroll restoration retry logic is duplicated in 3 places — suggested extracting a helper
  • Client-side ForbiddenBoundaryInner/UnauthorizedBoundaryInner don't emit noindex like NotFoundBoundaryInner now does — potential parity gap to check against Next.js

github run

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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/?_rsc is preserved so trailingSlash: true navigations don't trigger a server redirect. The new app-rsc-handler.test.ts coverage proves the generated URL isn't redirected. Good.
  • MPA scheduler (app-browser-mpa-navigation.ts): keying pending navigations by both href and historyUpdateMode plus a monotonic token correctly lets a later same-href replace() supersede an earlier pending push(). The token check in the delayed navigate closure is the right guard. Dedup of identical pending navigations is also correct.
  • Redirect boundary reset (error-boundary.tsx:87): calling reset() inside startTransition after dispatching the navigation is the correct fix for the redirect loop and matches Next.js's redirect-boundary.tsx.
  • Pages Router adapter (router.ts:1545): the AppRouterContext.Provider wrapping lets useRouter() from next/navigation work inside Pages Router pages. bfcacheId: "0" is consistent with the App Router's own adapter in navigation.ts:1584, so this is not a divergence.
  • useParams stability: reordering getClientParamsSnapshot to check pagesCtx first (navigation.ts:1140) is safe because the Pages accessor is only registered by the Pages Router provider — App Router renders return null and fall through to state.clientParams. The pages context is URL-key cached (router.ts:432), so the params object is referentially stable across rerenders, satisfying useSyncExternalStore.

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 both navigation.ts and app-browser-popstate.ts. The earlier "duplicated in 3 places" concern is addressed.
  • noindex parity for Forbidden/Unauthorized client boundaries: not a gap — ForbiddenBoundaryInner.render (error-boundary.tsx:362) and UnauthorizedBoundaryInner.render (error-boundary.tsx:434) both already emit <meta name="robots" content="noindex" />, same as NotFoundBoundaryInner.

Two minor, non-blocking observations inline.

deps.setPendingNavigation(pendingNavigation);
const shouldRestoreSavedScroll = hasSavedScrollPosition(event.state);
if (shouldRestoreSavedScroll) {
scheduleAfterFrame(() => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

@ask-bonk

ask-bonk Bot commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully on PR #1705.

Summary

I 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:

  • The core .rsc → canonical-route-URL + _rsc + rsc: 1 header switch is applied consistently across createRscRequestUrl, redirect canonicalization, E2E helpers, and assertions.
  • Trailing-slash preservation prevents redirect loops under trailingSlash: true.
  • The MPA scheduler's (href, historyUpdateMode, token) keying correctly lets a later same-href replace() supersede a pending push().
  • The redirect boundary reset() inside startTransition matches Next.js and fixes the loop.
  • The Pages→App router adapter is consistent (bfcacheId: "0" matches App Router's own adapter).
  • The useParams reorder is safe — the Pages accessor only registers under Pages Router, and the context is URL-key cached for referential stability.

Disputed two prior automated-review claims (and noted as resolved in my review):

  • The "scroll-restore duplicated in 3 places" concern is now addressed via the centralized retryScrollTo helper.
  • The "Forbidden/Unauthorized boundaries lack noindex parity" claim is not a real gap — both ForbiddenBoundaryInner and UnauthorizedBoundaryInner already emit noindex.

Two minor, non-blocking inline comments:

  • The popstate early-scroll retry can burn up to 60 frames if content shrinks (acceptable for parity, flagged as a conscious choice).
  • The parts.slice(2, -2).join(";") in decodeRedirectError is defensive-only since encodeURIComponent encodes ;; suggested clarifying the comment.

No blocking issues found.

github run

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review

@ask-bonk

ask-bonk Bot commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

@james-elicx Bonk workflow was cancelled.

View workflow run · To retry, trigger Bonk again.

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

  • .rsc transport → canonical route URL + _rsc + rsc: 1 header is applied consistently: toRscRequestPath now returns the path verbatim (app-rsc-cache-busting.ts:285), redirect-location canonicalization flows through the same createRscRequestUrl (:316), and the E2E helper isAppRouterRscRequestForPath (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/?_rsc is produced and the new app-rsc-handler.test.ts case proves it is not redirected under trailingSlash: true. Good.
  • MPA scheduler (app-browser-mpa-navigation.ts): keying by (href, historyUpdateMode, token) correctly lets a later same-href replace() supersede a pending push(), and the dedup check no-ops React strict-mode double-invocation. The pageshow/event.persisted reset (app-browser-entry.ts:2165) correctly clears stale pending state after a bfcache restore.
  • Redirect boundary reset() inside startTransition (error-boundary.tsx:80) matches Next.js's redirect-boundary.tsx and fixes the loop. The new global AppRouterRedirectBridge only handles redirect errors that escape every boundary — React's onCaughtError/onUncaughtError hooks (app-browser-entry.ts:1575) route boundary-caught errors to React's own handlers, not window events, 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 the useParams reorder (navigation.ts:1140) is safe because the Pages accessor only registers under the Pages Router provider.
  • noindex parity: NotFoundBoundaryInner, ForbiddenBoundaryInner, and UnauthorizedBoundaryInner all 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();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

@ask-bonk

ask-bonk Bot commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully on PR #1705.

Summary

I 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 (retryScrollTo extraction, noindex on Forbidden/Unauthorized boundaries, decodeRedirectError comment).

Verified correct:

  • The .rsc → canonical-route-URL + _rsc + rsc: 1 header switch is applied consistently across createRscRequestUrl, redirect canonicalization, the E2E helper, and all updated assertions.
  • Trailing-slash preservation prevents trailingSlash: true redirect loops (proven by the new app-rsc-handler.test.ts case).
  • The MPA scheduler's (href, historyUpdateMode, token) keying correctly lets a later same-href replace() supersede a pending push(), with strict-mode dedup and a bfcache pageshow reset.
  • The redirect boundary reset() inside startTransition matches Next.js, and the new global AppRouterRedirectBridge does not double-dispatch boundary-caught redirects because React's onCaughtError/onUncaughtError hooks keep those off the window event channel.
  • The Pages→App adapter (bfcacheId: "0") and the useParams reorder are both safe.
  • noindex parity is now complete across NotFound/Forbidden/Unauthorized client boundaries.

Two minor, non-blocking inline notes:

  • AppRouterRedirectBridge has no per-event dedup for the rare case of two distinct unhandled redirect errors in the same tick (best-effort safety net; the boundary path is the guaranteed single-dispatch one).
  • retryScrollTo's new convergence check only covers the vertical axis (harmless given usage).

No blocking issues found.

github run

@james-elicx
james-elicx merged commit 560f270 into cloudflare:main Jun 1, 2026
30 checks passed
@NathanDrake2406
NathanDrake2406 deleted the nathan/app-dir-navigation-parity branch June 13, 2026 13:25
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.

2 participants