Skip to content

fix(app-router): hard navigate streamed redirects#1742

Merged
james-elicx merged 4 commits into
cloudflare:mainfrom
NathanDrake2406:nathan/nav-redirect-loading-once
Jun 7, 2026
Merged

fix(app-router): hard navigate streamed redirects#1742
james-elicx merged 4 commits into
cloudflare:mainfrom
NathanDrake2406:nathan/nav-redirect-loading-once

Conversation

@NathanDrake2406
Copy link
Copy Markdown
Contributor

Summary

  • port the Next.js redirect-once navigation coverage for App Router redirects with and without a loading boundary
  • detect Next.js-compatible streamed redirect meta markers before starting an App Router soft navigation
  • delegate those navigations to the MPA path so the original document does not also fire the delayed meta refresh

Root Cause

Vinext already emits the #__next-page-redirect meta marker for streamed App Router redirects. The client-side navigation shim still treated the redirect target as a normal App Router RSC navigation, so the source document stayed alive after rendering the destination. When the meta refresh fired, it hard-navigated to the same destination a second time and changed the rendered timestamp.

Next.js avoids this by checking for the streamed redirect marker before handling client navigation and completing the navigation through the hard-navigation path instead.

Next.js References

Validation

  • vp test run tests/app-browser-mpa-navigation.test.ts tests/shims.test.ts -t "streamed App Router redirect meta|hasPendingAppRouterPageRedirect"
  • vp test run tests/app-ssr-error-meta.test.ts tests/rsc-streaming.test.ts
  • vp run vinext#build
  • PLAYWRIGHT_PROJECT=app-router pnpm run test:e2e tests/e2e/app-router/redirect-once.spec.ts
  • vp check

Pre-fix, the ported e2e failed for /redirect/redirect-with-loading because the timestamp changed after the delayed meta refresh. After the fix it passes and records exactly one /redirect/result document request.

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Jun 4, 2026

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@1742
npm i https://pkg.pr.new/vinext@1742

commit: 9fb3282

@NathanDrake2406 NathanDrake2406 marked this pull request as draft June 4, 2026 15:24
@NathanDrake2406 NathanDrake2406 marked this pull request as ready for review June 6, 2026 07:38
@NathanDrake2406 NathanDrake2406 marked this pull request as draft June 6, 2026 07:38
@NathanDrake2406 NathanDrake2406 marked this pull request as ready for review June 6, 2026 07:50
@NathanDrake2406 NathanDrake2406 force-pushed the nathan/nav-redirect-loading-once branch from 729e8cf to 7b40ce7 Compare June 6, 2026 08:06
@james-elicx
Copy link
Copy Markdown
Member

/bigbonk review for issues

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

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

Review: hard navigate streamed redirects

The implementation is faithful to Next.js. The new guard mirrors navigate-reducer.ts exactly: after the external-URL check and before the segment-cache (RSC) navigation, Next.js checks document.getElementById('__next-page-redirect') and falls back to completeHardNavigation. This PR places hasPendingAppRouterPageRedirect(document) at the equivalent point in navigateClientSide (after hash-only handling, before appNavigate), and routes through navigateExternal (which triggers the mpa-navigation tree state + scheduler) with a window.location fallback that matches the existing external-URL path at navigation.ts:1695-1701.

Verified locally:

  • vp test run tests/app-browser-mpa-navigation.test.ts -t "hasPendingAppRouterPageRedirect" — 4 passed
  • vp test run tests/shims.test.ts -t "streamed App Router redirect meta" — 1 passed
  • vp check on both changed source files — format, lint, type all pass

Parity checks:

  • router.refresh() (navigation.ts:1889) correctly does NOT get this guard — Next.js's refresh-reducer has no __next-page-redirect check, only navigate-reducer. Correct.
  • The marker is App Router-specific and emitted into SSR'd HTML by app-ssr-error-meta.ts:65, so guarding only the App Router navigateClientSide path is the right scope.

Two minor, non-blocking observations below.

await page.waitForTimeout(400);
await expect(page.locator("#timestamp")).toHaveText(initialTimestamp ?? "");
}
expect(requestedPathnames.filter((pathname) => pathname === "/redirect/result")).toHaveLength(
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/possible-flake note: page.on("request") captures all requests, and the filter keys only on pathname === "/redirect/result". If vinext ever issues an RSC fetch whose URL pathname is also /redirect/result (e.g. a prefetch or .rsc request that preserves the pathname), this toHaveLength(1) would fail even when the user-visible behavior is correct. Next.js's original test counts full document loads rather than any request to the pathname. Since the fix routes through the hard-navigation/MPA path this currently holds, but consider narrowing to document requests (e.g. request.resourceType() === "document") to make the assertion robust against future RSC request shapes.

// Next.js treats a streamed redirect meta tag as an MPA-navigation marker.
// A soft RSC redirect would leave the source document alive long enough for
// the delayed meta refresh to fire and render the target a second time.
if (hasPendingAppRouterPageRedirect(typeof document === "undefined" ? undefined : document)) {
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 typeof document === "undefined" ? undefined : document guard is reasonable defensiveness, but note this branch only runs in navigateClientSide, which is already a client-only path (the function dereferences window.location unconditionally just above at line 1706). hasPendingAppRouterPageRedirect itself also handles a missing/partial document. So the inline ternary is belt-and-suspenders — fine to keep, just flagging that document is expected to exist here whenever window does.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Jun 6, 2026

Review posted successfully. Here's a summary of my analysis:

Review Summary

PR #1742 — fix(app-router): hard navigate streamed redirects is well-implemented and faithful to Next.js. I posted a COMMENT-level review (no blockers).

What the PR does

Adds hasPendingAppRouterPageRedirect(document) and wires it into navigateClientSide. When the source document contains the <meta id="__next-page-redirect"> refresh marker, client navigation is delegated to the MPA/hard-navigation path instead of a soft RSC navigation — preventing the delayed meta refresh from firing a second navigation.

Verification I ran

  • hasPendingAppRouterPageRedirect unit tests — 4 passed
  • streamed App Router redirect meta shim test — passed
  • vp check on both changed source files — format/lint/type all clean

Correctness/parity confirmed against Next.js

  • Placement matches navigate-reducer.ts exactly (after external-URL check, before segment-cache navigation).
  • router.refresh() correctly does not get the guard (Next.js's refresh-reducer has no such check).
  • The navigateExternal primary path and window.location fallback are consistent with the existing external-URL handling at navigation.ts:1695-1701.
  • Scope is correctly App Router-only (the marker is emitted by app-ssr-error-meta.ts).

Minor observations raised (non-blocking)

  1. e2e testpage.on("request") counts any request whose pathname equals /redirect/result, not just document loads. Suggested narrowing to request.resourceType() === "document" to be robust against future RSC request shapes.
  2. document guard — the inline typeof document === "undefined" ternary is belt-and-suspenders since navigateClientSide is already client-only; fine to keep.

github run

@james-elicx
Copy link
Copy Markdown
Member

/bigbonk review for issues

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

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

Review: hard navigate streamed redirects

The fix is correct and faithful to Next.js. hasPendingAppRouterPageRedirect detects the SSR-emitted <meta id="__next-page-redirect"> marker and routes the navigation through navigateExternal (the MPA/mpa-navigation tree-state path), with a window.location fallback that mirrors the existing external-URL branch at navigation.ts:1695-1701. Scope is correct: the marker is App-Router-only (emitted by app-ssr-error-meta.ts:65), the single chokepoint is navigateClientSide (both router.push/replace and Link clicks funnel through it), and router.refresh() correctly does not get the guard (Next.js's refresh-reducer has no such check).

Verified locally:

  • tests/app-browser-mpa-navigation.test.ts -t hasPendingAppRouterPageRedirect — 4 passed
  • tests/shims.test.ts -t "streamed App Router redirect meta" — passed
  • tests/app-ssr-error-meta.test.ts — 6 passed
  • vp check on both changed source files — format/lint/type clean

The new fixture routes (redirect/result, redirect/servercomponent, redirect-with-loading) are isolated and don't collide with the existing redirect-test/protected-loading fixtures referenced by app-router-dev-server.test.ts. The e2e test already narrows to request.resourceType() === 'document' (the prior review's suggestion was incorporated).

One parity note and a minor nit below — both non-blocking.

// Next.js treats a streamed redirect meta tag as an MPA-navigation marker.
// A soft RSC redirect would leave the source document alive long enough for
// the delayed meta refresh to fire and render the target a second time.
if (hasPendingAppRouterPageRedirect(typeof document === "undefined" ? undefined : document)) {
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.

Parity nit (non-blocking): in Next.js's navigate-reducer.ts the __next-page-redirect check is the very first thing in the reducer — it runs before hash-only handling and before the transition-start signal. Here it is placed after the hash-only short-circuit (line 1717) and after notifyAppRouterTransitionStart (line 1709). The practical impact is essentially nil: a server-side redirect() target that is a hash-only change of the redirecting document's own URL isn't a realistic scenario, so the hash-only branch can't plausibly swallow a real streamed redirect. Just flagging the ordering divergence in case you want exact navigate-reducer parity. If you keep the current placement, consider a one-line comment noting the deliberate ordering difference.

// Next.js treats a streamed redirect meta tag as an MPA-navigation marker.
// A soft RSC redirect would leave the source document alive long enough for
// the delayed meta refresh to fire and render the target a second time.
if (hasPendingAppRouterPageRedirect(typeof document === "undefined" ? undefined : document)) {
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: typeof document === "undefined" ? undefined : document is belt-and-suspenders here — navigateClientSide is already client-only (it dereferences window.location unconditionally at line 1706, which would have thrown long before reaching this point if window/document were absent), and hasPendingAppRouterPageRedirect itself already null-guards. Fine to keep for defensiveness; just noting document is expected to exist here whenever window does.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Jun 7, 2026

Review posted successfully. Summary of my analysis:

Review Summary

PR #1742 — fix(app-router): hard navigate streamed redirects is correct and faithful to Next.js. I posted a COMMENT-level review (no blockers).

What it does

Adds hasPendingAppRouterPageRedirect(document) and gates client navigation on the SSR-emitted <meta id="__next-page-redirect"> marker. When present, navigation is routed through navigateExternal (the MPA/hard-navigation path) so the source document is abandoned instead of staying alive to fire the delayed meta refresh a second time.

Verification I ran

  • hasPendingAppRouterPageRedirect unit tests — 4 passed
  • streamed App Router redirect meta shim test — passed
  • app-ssr-error-meta.test.ts — 6 passed
  • vp check on both changed source files — format/lint/type clean

Correctness/parity confirmed

  • navigateExternal + window.location fallback mirrors the existing external-URL branch at navigation.ts:1695-1701.
  • Single chokepoint: both router.push/replace and Link clicks funnel through navigateClientSide.
  • router.refresh() correctly does not get the guard (matches Next.js's refresh-reducer).
  • New fixtures are isolated — no collision with existing redirect-test/protected-loading fixtures.
  • The e2e test already narrows to request.resourceType() === 'document' (the prior review suggestion was incorporated).

Non-blocking observations raised

  1. Ordering parity — In Next.js's navigate-reducer.ts the redirect check is the first thing, before hash-only handling and the transition-start signal. Here it's placed after both. Practical impact is nil (a server-side redirect target can't realistically be a hash-only change of the redirecting page's own URL), but flagged for exact parity.
  2. document guard nit — the typeof document === "undefined" ? undefined : document ternary is belt-and-suspenders since this path is already client-only; fine to keep.

github run

@james-elicx james-elicx merged commit aab9240 into cloudflare:main Jun 7, 2026
41 checks passed
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