From 4ef63f0bd77e6e3d7b6f5c456ab5abb224250d73 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Thu, 23 Apr 2026 19:40:30 +1000 Subject: [PATCH 01/14] fix(app-router): hard-navigate to browser URL on non-ok RSC fetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Client-side RSC navigation fetch passes non-ok responses (404, 500, etc.) directly to createFromFetch. The HTML error body is parsed as an RSC stream, producing a cryptic "Connection closed" error, and the outer catch hard-navigates to the same URL that just failed — risking a reload loop and surfacing an opaque diagnostic. Non-ok responses are not RSC payloads and must not reach createFromFetch. Next.js handles this identically in fetch-server-response.ts:211 with a hard ("MPA") navigation to the browser-facing URL stripped of flight markers. Add a !navResponse.ok guard after the stale-navigation check in the navigation loop: hard-navigate to currentHref (the browser URL without .rsc suffix). The existing finally block handles settlePendingBrowserRouterState and clearPendingPathname on this return path, so no additional bookkeeping is required. Add a !rscResponse.ok guard in readInitialRscStream so the initial hydration fallback path reloads instead of attempting RSC parsing on an HTML error body. Return a never-resolving ReadableStream so the caller does not proceed into createFromReadableStream before the reload takes effect. E2E tests verify: 404 RSC nav hard-navs to the non-.rsc URL; 500 (simulated via page.route interception) hard-navs without logging an RSC parse error; the final URL never contains .rsc. --- .../vinext/src/server/app-browser-entry.ts | 23 ++++ tests/e2e/app-router/rsc-fetch-errors.spec.ts | 129 ++++++++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 tests/e2e/app-router/rsc-fetch-errors.spec.ts diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index 808b5d8ca..7759a8663 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -841,6 +841,16 @@ async function readInitialRscStream(): Promise> { const rscResponse = await fetch(toRscUrl(window.location.pathname + window.location.search)); + // A non-ok RSC response during initial hydration means the server cannot + // render an RSC payload for this URL (e.g. the page threw before streaming). + // Parsing the HTML error body as RSC causes an opaque parse failure. Reload + // so the server renders the correct error page as HTML, and return a + // never-resolving stream so the caller never proceeds into createFromReadableStream. + if (!rscResponse.ok) { + window.location.reload(); + return new ReadableStream(); + } + let params: Record = {}; const paramsHeader = rscResponse.headers.get("X-Vinext-Params"); if (paramsHeader) { @@ -1100,6 +1110,19 @@ async function main(): Promise { if (navId !== activeNavigationId) return; + // Non-ok RSC response (404, 500, etc.) means the server returned an HTML + // error page instead of an RSC payload. Parsing HTML as an RSC stream + // throws a cryptic "Connection closed" error. Match Next.js behavior + // (fetch-server-response.ts:211): hard-navigate to the browser URL so the + // server can render the correct error page (404.tsx, 500.tsx, etc.). + // currentHref is the browser-facing URL without the .rsc suffix. + // The outer finally handles settlePendingBrowserRouterState and + // clearPendingPathname on this return path. + if (!navResponse.ok) { + window.location.href = currentHref; + return; + } + const finalUrl = new URL(navResponseUrl ?? navResponse.url, window.location.origin); const requestedUrl = new URL(rscUrl, window.location.origin); diff --git a/tests/e2e/app-router/rsc-fetch-errors.spec.ts b/tests/e2e/app-router/rsc-fetch-errors.spec.ts new file mode 100644 index 000000000..6a2e1915f --- /dev/null +++ b/tests/e2e/app-router/rsc-fetch-errors.spec.ts @@ -0,0 +1,129 @@ +/** + * RSC fetch error handling tests. + * + * Verifies that when an RSC navigation fetch returns a non-ok response (404, + * 500), the client performs a clean hard navigation to the destination URL + * rather than trying to parse the HTML error body as an RSC stream. + * + * Without the fix: + * - fetch(url.rsc) returns 404 HTML + * - createFromFetch throws a cryptic stream-parse error + * - The catch block logs "[vinext] RSC navigation error: ..." and hard-navs + * to the same URL again, which can loop + * + * With the fix: + * - !response.ok is detected immediately after fetch + * - Client hard-navigates directly to the destination URL (no .rsc suffix) + * - No stream-parse error is logged + * + * Ported behavior from Next.js fetch-server-response.ts:211: + * if (!isFlightResponse || !res.ok || !res.body) { + * return doMpaNavigation(responseUrl.toString()) + * } + */ +import { test, expect } from "@playwright/test"; +import { waitForAppRouterHydration } from "../helpers"; + +const BASE = "http://localhost:4174"; + +test.describe("RSC fetch non-ok response handling", () => { + test("client navigation to a non-existent route hard-navs to the non-.rsc URL", async ({ + page, + }) => { + await page.goto(`${BASE}/about`); + await waitForAppRouterHydration(page); + + const consoleErrors: string[] = []; + page.on("console", (msg) => { + if (msg.type() === "error") { + consoleErrors.push(msg.text()); + } + }); + + // Trigger RSC navigation to a route that does not exist (returns 404 HTML). + // We need to wait for the hard navigation, so we listen for the URL to change. + const navigationPromise = page.waitForURL(`${BASE}/this-route-does-not-exist`, { + timeout: 10_000, + }); + await page.evaluate(() => { + void (window as any).__VINEXT_RSC_NAVIGATE__("/this-route-does-not-exist"); + }); + await navigationPromise; + + // The browser must land on the non-.rsc URL — never on the .rsc variant. + expect(page.url()).toBe(`${BASE}/this-route-does-not-exist`); + + // No RSC stream-parse error should be the first-class error logged. + // A navigation error caused by RSC stream parse failures contains "RSC navigation error" + // or stack frames from createFromFetch. The pre-fix path would log exactly this. + const rscParseError = consoleErrors.find( + (msg) => + msg.includes("RSC navigation error") || + msg.includes("createFromFetch") || + msg.includes("Failed to parse RSC"), + ); + expect(rscParseError).toBeUndefined(); + }); + + test("client navigation to a 500-route hard-navs to the destination URL without looping", async ({ + page, + }) => { + // The slow-route fixture introduces a server delay but returns valid RSC. + // We need a route that returns a true 5xx. We use a direct fetch intercept + // via route interception to simulate a 500 on an RSC request. + + // Intercept the .rsc request for /about and return a 500 error. + await page.route("**/about.rsc**", (route) => + route.fulfill({ + status: 500, + contentType: "text/html", + body: "

Internal Server Error

", + }), + ); + + await page.goto(`${BASE}/`); + await waitForAppRouterHydration(page); + + const consoleErrors: string[] = []; + page.on("console", (msg) => { + if (msg.type() === "error") { + consoleErrors.push(msg.text()); + } + }); + + // Trigger RSC navigation to /about, which will get a 500 from our intercept. + const navigationPromise = page.waitForURL(`${BASE}/about`, { timeout: 10_000 }); + await page.evaluate(() => { + void (window as any).__VINEXT_RSC_NAVIGATE__("/about"); + }); + await navigationPromise; + + // Should land on /about (not /about.rsc — the RSC URL) + expect(page.url()).toBe(`${BASE}/about`); + + // No RSC stream-parse error should be logged + const rscParseError = consoleErrors.find( + (msg) => + msg.includes("RSC navigation error") || + msg.includes("createFromFetch") || + msg.includes("Failed to parse RSC"), + ); + expect(rscParseError).toBeUndefined(); + }); + + test("navigation to non-existent route does not land on the .rsc URL", async ({ page }) => { + await page.goto(`${BASE}/about`); + await waitForAppRouterHydration(page); + + // After hard-nav, URL must not contain .rsc + const navigationPromise = page.waitForURL(`${BASE}/this-route-does-not-exist`, { + timeout: 10_000, + }); + await page.evaluate(() => { + void (window as any).__VINEXT_RSC_NAVIGATE__("/this-route-does-not-exist"); + }); + await navigationPromise; + + expect(page.url()).not.toContain(".rsc"); + }); +}); From 607190152019814578343e30b87fe26f0a603957 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Thu, 23 Apr 2026 19:53:07 +1000 Subject: [PATCH 02/14] fix(app-router): break reload loop when initial RSC fetch is persistently non-ok readInitialRscStream reloads on a non-ok RSC response so the server can render the correct error page as HTML. That assumes the next HTTP response will differ, which is not guaranteed: if the RSC endpoint is persistently broken while the HTML document is served successfully (mismatched server handler, upstream gating, intercepted requests in tests), the reload fetches the same HTML, the post-hydration RSC fetch gets the same non-ok, and the page reloads forever. Guard the reload with a sessionStorage key scoped to the current path. The first non-ok response sets the key and reloads. The second non-ok response for the same path clears the key and throws, letting the outer bootstrap catch surface the error instead of triggering another reload. A successful RSC response clears the key so a later unrelated failure on the same path still gets one reload attempt. Tighten the Playwright test that covers the 500 hard-nav path: after the hard navigation settles, wait and assert both URL stability and a bounded intercept hit count. A runaway reload loop would fail the hit count assertion. --- .../vinext/src/server/app-browser-entry.ts | 22 ++++++++++-- tests/e2e/app-router/rsc-fetch-errors.spec.ts | 36 ++++++++++++------- 2 files changed, 44 insertions(+), 14 deletions(-) diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index 7759a8663..1f62b04e1 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -844,12 +844,30 @@ async function readInitialRscStream(): Promise> { // A non-ok RSC response during initial hydration means the server cannot // render an RSC payload for this URL (e.g. the page threw before streaming). // Parsing the HTML error body as RSC causes an opaque parse failure. Reload - // so the server renders the correct error page as HTML, and return a - // never-resolving stream so the caller never proceeds into createFromReadableStream. + // once so the server has a chance to render the correct error page as HTML. + // + // Guard against reload loops: if a prior reload for this exact path also + // produced non-ok, the RSC endpoint is persistently broken and reloading + // again would loop forever. Surface a console error instead and rethrow so + // the outer bootstrap can fall back to showing the server-rendered HTML. if (!rscResponse.ok) { + const reloadKey = "__vinext_rsc_initial_reload__"; + const currentPath = window.location.pathname + window.location.search; + if (sessionStorage.getItem(reloadKey) === currentPath) { + sessionStorage.removeItem(reloadKey); + throw new Error( + `[vinext] Initial RSC fetch returned ${rscResponse.status} after reload; aborting hydration`, + ); + } + sessionStorage.setItem(reloadKey, currentPath); window.location.reload(); + // Return a never-resolving stream so the caller does not proceed into + // createFromReadableStream before the reload takes effect. return new ReadableStream(); } + // Clear the reload guard on success so a subsequent navigation to the same + // path is not wrongly flagged as a looped reload. + sessionStorage.removeItem("__vinext_rsc_initial_reload__"); let params: Record = {}; const paramsHeader = rscResponse.headers.get("X-Vinext-Params"); diff --git a/tests/e2e/app-router/rsc-fetch-errors.spec.ts b/tests/e2e/app-router/rsc-fetch-errors.spec.ts index 6a2e1915f..a1cac54c5 100644 --- a/tests/e2e/app-router/rsc-fetch-errors.spec.ts +++ b/tests/e2e/app-router/rsc-fetch-errors.spec.ts @@ -68,18 +68,19 @@ test.describe("RSC fetch non-ok response handling", () => { test("client navigation to a 500-route hard-navs to the destination URL without looping", async ({ page, }) => { - // The slow-route fixture introduces a server delay but returns valid RSC. - // We need a route that returns a true 5xx. We use a direct fetch intercept - // via route interception to simulate a 500 on an RSC request. - - // Intercept the .rsc request for /about and return a 500 error. - await page.route("**/about.rsc**", (route) => - route.fulfill({ + // Intercept the .rsc request for /about and return a 500 error. This + // intercept persists across navigations and reloads on this page, so if + // the fix is incomplete and a reload loop develops, the intercept hit + // count will grow without bound. + let aboutRscHits = 0; + await page.route("**/about.rsc**", (route) => { + aboutRscHits += 1; + return route.fulfill({ status: 500, contentType: "text/html", body: "

Internal Server Error

", - }), - ); + }); + }); await page.goto(`${BASE}/`); await waitForAppRouterHydration(page); @@ -91,17 +92,28 @@ test.describe("RSC fetch non-ok response handling", () => { } }); - // Trigger RSC navigation to /about, which will get a 500 from our intercept. const navigationPromise = page.waitForURL(`${BASE}/about`, { timeout: 10_000 }); await page.evaluate(() => { void (window as any).__VINEXT_RSC_NAVIGATE__("/about"); }); await navigationPromise; - // Should land on /about (not /about.rsc — the RSC URL) expect(page.url()).toBe(`${BASE}/about`); - // No RSC stream-parse error should be logged + // Stability check: the hard-nav must settle. Without the + // readInitialRscStream reload-loop guard, the initial RSC fetch on the + // freshly-loaded /about page hits the intercepted 500 and reloads + // indefinitely. Wait long enough for a loop to manifest, then verify + // the URL is stable and the intercept fired a bounded number of times. + await page.waitForTimeout(1500); + expect(page.url()).toBe(`${BASE}/about`); + + // Expected sequence: one hit from the client RSC nav fetch that triggered + // the hard-nav, plus at most one hit from the post-reload initial RSC + // fetch before the sessionStorage guard aborts further reloads. A runaway + // loop would produce many more. + expect(aboutRscHits).toBeLessThanOrEqual(3); + const rscParseError = consoleErrors.find( (msg) => msg.includes("RSC navigation error") || From 97d2942e37897f8ed152048ac317f05b9db5d929 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Thu, 23 Apr 2026 20:28:32 +1000 Subject: [PATCH 03/14] fix(app-router): harden initial RSC recovery and cover non-RSC content-types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five review follow-ups to the non-ok RSC fetch guard. sessionStorage.getItem / setItem / removeItem can throw SecurityError in strict-mode iframes, storage-disabled browsers, and some Safari private- browsing configurations. The unguarded calls in readInitialRscStream crashed hydration in exactly those environments — worse than the original bug. Wrap every access in readReloadFlag / writeReloadFlag / clearReloadFlag helpers that swallow the exception and fall through to the "no prior attempt" branch. Replace the throw after a looped reload with console.error plus a never-resolving ReadableStream. main() is invoked as `void main()` with no outer catch, so a throw here surfaced as an unhandled promise rejection and fired window.onerror reporting hooks instead of leaving the SSR'd DOM visible as the comment promised. Returning a stream that never produces data halts hydration cleanly; the server-rendered HTML stays on screen, client components simply never hydrate. Clear the reload flag when readInitialRscStream enters the embedded-RSC branch. The embed branch runs when the server successfully rendered the page, so any prior flag is stale — without this clear, the flag could persist across a hard-reload of the same path and skip the one legitimate recovery attempt the user should get. Cover the non-RSC content-type case on both the initial-hydration fetch and the client-side navigation fetch. Next.js's equivalent guard (fetch-server-response.ts:211) is `!isFlightResponse || !res.ok || !res.body`; the previous commit only covered `!res.ok`. A proxy or CDN that returns 200 with a rewritten Content-Type (`text/html` instead of `text/x-component`) would still reach createFromFetch and throw the exact stream-parse error these guards exist to prevent. Check for a Content-Type starting with `text/x-component` before parsing. Tighten the Playwright hit-count assertion from <= 3 to <= 2. With the guard in place the expected sequence is exactly two hits (the triggering client RSC nav fetch plus one post-reload fetch that aborts); any extra hit signals an unnecessary reload worth catching. --- .../vinext/src/server/app-browser-entry.ts | 113 +++++++++++++----- tests/e2e/app-router/rsc-fetch-errors.spec.ts | 11 +- 2 files changed, 86 insertions(+), 38 deletions(-) diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index 1f62b04e1..43c20023a 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -801,10 +801,63 @@ function restorePopstateScrollPosition(state: unknown): void { }); } +const RSC_RELOAD_KEY = "__vinext_rsc_initial_reload__"; + +// sessionStorage can throw SecurityError in strict-mode iframes, storage- +// disabled browsers, and some Safari private-browsing configurations. Wrap +// every access so a recovery path for one error does not crash hydration. +function readReloadFlag(): string | null { + try { + return sessionStorage.getItem(RSC_RELOAD_KEY); + } catch { + return null; + } +} +function writeReloadFlag(path: string): void { + try { + sessionStorage.setItem(RSC_RELOAD_KEY, path); + } catch {} +} +function clearReloadFlag(): void { + try { + sessionStorage.removeItem(RSC_RELOAD_KEY); + } catch {} +} + +// A non-ok or wrong-content-type RSC response during initial hydration means +// the server cannot deliver a valid RSC payload for this URL. Parsing the +// response as RSC causes an opaque parse failure. Reload once so the server +// has a chance to render the correct error page as HTML. Guard against +// reload loops: if a prior reload for this exact path produced the same +// failure, the endpoint is persistently broken — log and return a +// never-resolving stream so hydration halts without throwing (which would +// surface as an unhandled rejection). The SSR'd DOM stays visible. +function recoverFromBadInitialRscResponse(reason: string): ReadableStream { + const currentPath = window.location.pathname + window.location.search; + if (readReloadFlag() === currentPath) { + clearReloadFlag(); + console.error( + `[vinext] Initial RSC fetch ${reason} after reload; aborting hydration. ` + + "Server-rendered HTML remains visible; client components will not hydrate.", + ); + return new ReadableStream(); + } + writeReloadFlag(currentPath); + window.location.reload(); + // Never-resolving stream so the caller does not proceed into + // createFromReadableStream before the reload takes effect. + return new ReadableStream(); +} + async function readInitialRscStream(): Promise> { const vinext = getVinextBrowserGlobal(); if (vinext.__VINEXT_RSC__ || vinext.__VINEXT_RSC_CHUNKS__ || vinext.__VINEXT_RSC_DONE__) { + // Reaching the embedded-RSC branch means the server successfully rendered + // the page — any prior reload flag for this path is stale and must be + // cleared so a future failure gets its own fresh recovery attempt. + clearReloadFlag(); + if (vinext.__VINEXT_RSC__) { const embedData = vinext.__VINEXT_RSC__; delete vinext.__VINEXT_RSC__; @@ -841,33 +894,22 @@ async function readInitialRscStream(): Promise> { const rscResponse = await fetch(toRscUrl(window.location.pathname + window.location.search)); - // A non-ok RSC response during initial hydration means the server cannot - // render an RSC payload for this URL (e.g. the page threw before streaming). - // Parsing the HTML error body as RSC causes an opaque parse failure. Reload - // once so the server has a chance to render the correct error page as HTML. - // - // Guard against reload loops: if a prior reload for this exact path also - // produced non-ok, the RSC endpoint is persistently broken and reloading - // again would loop forever. Surface a console error instead and rethrow so - // the outer bootstrap can fall back to showing the server-rendered HTML. if (!rscResponse.ok) { - const reloadKey = "__vinext_rsc_initial_reload__"; - const currentPath = window.location.pathname + window.location.search; - if (sessionStorage.getItem(reloadKey) === currentPath) { - sessionStorage.removeItem(reloadKey); - throw new Error( - `[vinext] Initial RSC fetch returned ${rscResponse.status} after reload; aborting hydration`, - ); - } - sessionStorage.setItem(reloadKey, currentPath); - window.location.reload(); - // Return a never-resolving stream so the caller does not proceed into - // createFromReadableStream before the reload takes effect. - return new ReadableStream(); + return recoverFromBadInitialRscResponse(`returned ${rscResponse.status}`); + } + // Guard against proxies/CDNs that return 200 with a rewritten Content-Type + // (e.g. text/html instead of text/x-component). Such responses cannot be + // parsed as RSC and would throw the same opaque parse error this fallback + // exists to prevent. + const contentType = rscResponse.headers.get("content-type") ?? ""; + if (!contentType.startsWith("text/x-component")) { + return recoverFromBadInitialRscResponse( + `returned non-RSC content-type "${contentType || "(missing)"}"`, + ); } - // Clear the reload guard on success so a subsequent navigation to the same - // path is not wrongly flagged as a looped reload. - sessionStorage.removeItem("__vinext_rsc_initial_reload__"); + // Successful RSC response clears the guard so a subsequent reload of the + // same path after a transient failure still gets one recovery attempt. + clearReloadFlag(); let params: Record = {}; const paramsHeader = rscResponse.headers.get("X-Vinext-Params"); @@ -1128,15 +1170,20 @@ async function main(): Promise { if (navId !== activeNavigationId) return; - // Non-ok RSC response (404, 500, etc.) means the server returned an HTML - // error page instead of an RSC payload. Parsing HTML as an RSC stream + // Any response that isn't a valid RSC payload (non-ok status, + // missing/rewritten Content-Type, or missing body) means the server + // returned something we cannot parse — typically an HTML error page + // or a proxy-rewritten response. Parsing such a body as an RSC stream // throws a cryptic "Connection closed" error. Match Next.js behavior - // (fetch-server-response.ts:211): hard-navigate to the browser URL so the - // server can render the correct error page (404.tsx, 500.tsx, etc.). - // currentHref is the browser-facing URL without the .rsc suffix. - // The outer finally handles settlePendingBrowserRouterState and - // clearPendingPathname on this return path. - if (!navResponse.ok) { + // (fetch-server-response.ts:211, `!isFlightResponse || !res.ok || !res.body`): + // hard-navigate to the browser URL so the server can render the correct + // error page as HTML. currentHref is the browser-facing URL without + // the .rsc suffix. The outer finally handles + // settlePendingBrowserRouterState and clearPendingPathname on this + // return path. + const navContentType = navResponse.headers.get("content-type") ?? ""; + const isRscResponse = navContentType.startsWith("text/x-component"); + if (!navResponse.ok || !isRscResponse || !navResponse.body) { window.location.href = currentHref; return; } diff --git a/tests/e2e/app-router/rsc-fetch-errors.spec.ts b/tests/e2e/app-router/rsc-fetch-errors.spec.ts index a1cac54c5..d0ea0065f 100644 --- a/tests/e2e/app-router/rsc-fetch-errors.spec.ts +++ b/tests/e2e/app-router/rsc-fetch-errors.spec.ts @@ -108,11 +108,12 @@ test.describe("RSC fetch non-ok response handling", () => { await page.waitForTimeout(1500); expect(page.url()).toBe(`${BASE}/about`); - // Expected sequence: one hit from the client RSC nav fetch that triggered - // the hard-nav, plus at most one hit from the post-reload initial RSC - // fetch before the sessionStorage guard aborts further reloads. A runaway - // loop would produce many more. - expect(aboutRscHits).toBeLessThanOrEqual(3); + // Expected sequence: exactly two hits — one from the client RSC nav + // fetch that triggered the hard-nav, and one from the post-reload + // initial RSC fetch that the sessionStorage guard recognises as a + // looped reload and aborts. A runaway loop would produce many more; + // any extra hit signals an unnecessary reload that would regress later. + expect(aboutRscHits).toBeLessThanOrEqual(2); const rscParseError = consoleErrors.find( (msg) => From 6d0f2e72b6164b3345742434336c736b56324a9c Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Thu, 23 Apr 2026 20:38:16 +1000 Subject: [PATCH 04/14] test(app-router): narrow RSC error filter to stream-parse diagnostics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test's console-error filter matched any message containing "RSC navigation error", which also captures an unrelated pre-existing hydration race: when page.evaluate calls __VINEXT_RSC_NAVIGATE__ before the AppRouter useLayoutEffect has committed, getBrowserRouterState throws "Browser router state is not initialized". That race has nothing to do with the stream-parse bug this PR fixes — the outer catch still hard-navigates to the target URL correctly — but the broad filter treated it as a failure, producing a flaky test that only passed on retry (seen on commit 60719015: failed first attempt, passed retry #1). My earlier commit's tighter hit-count assertion did not mask the flakiness; it just happened to hit the race on more retries. Replace the string list with an isRscStreamParseError helper that matches only the concrete diagnostics createFromFetch emits when handed an HTML body (Connection closed, createFromFetch / createFromReadableStream stack frames, Failed to parse RSC, Unexpected token). The pre-fix path produces exactly these messages, so the test still proves the fix; the hydration race no longer false-positives. --- tests/e2e/app-router/rsc-fetch-errors.spec.ts | 36 +++++++++++-------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/tests/e2e/app-router/rsc-fetch-errors.spec.ts b/tests/e2e/app-router/rsc-fetch-errors.spec.ts index d0ea0065f..323890618 100644 --- a/tests/e2e/app-router/rsc-fetch-errors.spec.ts +++ b/tests/e2e/app-router/rsc-fetch-errors.spec.ts @@ -26,6 +26,21 @@ import { waitForAppRouterHydration } from "../helpers"; const BASE = "http://localhost:4174"; +// Stream-parse errors thrown by createFromFetch / createFromReadableStream when +// handed a non-RSC payload (HTML error body, wrong content-type, empty stream). +// The pre-fix code path produces exactly these messages; filtering the console +// error list to only these strings keeps the assertion specific to the bug +// this PR fixes and immune to unrelated diagnostics logged by other code paths. +function isRscStreamParseError(msg: string): boolean { + return ( + msg.includes("Connection closed") || + msg.includes("createFromFetch") || + msg.includes("createFromReadableStream") || + msg.includes("Failed to parse RSC") || + msg.includes("Unexpected token") + ); +} + test.describe("RSC fetch non-ok response handling", () => { test("client navigation to a non-existent route hard-navs to the non-.rsc URL", async ({ page, @@ -53,15 +68,11 @@ test.describe("RSC fetch non-ok response handling", () => { // The browser must land on the non-.rsc URL — never on the .rsc variant. expect(page.url()).toBe(`${BASE}/this-route-does-not-exist`); - // No RSC stream-parse error should be the first-class error logged. - // A navigation error caused by RSC stream parse failures contains "RSC navigation error" - // or stack frames from createFromFetch. The pre-fix path would log exactly this. - const rscParseError = consoleErrors.find( - (msg) => - msg.includes("RSC navigation error") || - msg.includes("createFromFetch") || - msg.includes("Failed to parse RSC"), - ); + // The bug this PR fixes surfaces as one of a small set of RSC-stream + // parse errors when createFromFetch is handed an HTML body. Match only + // those diagnostics so an unrelated console error (e.g. a hydration- + // timing race that pre-existed this PR) does not false-positive here. + const rscParseError = consoleErrors.find((msg) => isRscStreamParseError(msg)); expect(rscParseError).toBeUndefined(); }); @@ -115,12 +126,7 @@ test.describe("RSC fetch non-ok response handling", () => { // any extra hit signals an unnecessary reload that would regress later. expect(aboutRscHits).toBeLessThanOrEqual(2); - const rscParseError = consoleErrors.find( - (msg) => - msg.includes("RSC navigation error") || - msg.includes("createFromFetch") || - msg.includes("Failed to parse RSC"), - ); + const rscParseError = consoleErrors.find((msg) => isRscStreamParseError(msg)); expect(rscParseError).toBeUndefined(); }); From 23796b627f5278263468cd7fc382c8b2c09fa3c5 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Thu, 23 Apr 2026 20:52:00 +1000 Subject: [PATCH 05/14] fix(app-router): abort hydration bootstrap when recovery cannot restore RSC Address the half-hydrated-state side effect of the previous commit's recovery path. When readInitialRscStream hits a persistent failure, the returned never-resolving stream let main() continue past the await and still assign window.__VINEXT_RSC_ROOT__, window.__VINEXT_HYDRATED_AT, and window.__VINEXT_RSC_NAVIGATE__. External probes (test helpers, analytics, Sentry hydration hooks) saw a hydrated page, user clicks on Links triggered __VINEXT_RSC_NAVIGATE__, and the resulting fetch rendered into a root that was suspended on a never-resolving initial-elements promise, so every navigation also hung. None of those globals should exist when hydration was deliberately aborted. Change readInitialRscStream's return type to ReadableStream | null and have recoverFromBadInitialRscResponse return null on the abort path (second attempt on the same path). main() now early-returns when the stream is null, leaving only the server-rendered HTML visible and no hydration globals for external code to observe. Add a console.warn on the first attempt before window.location.reload(). One-shot (the sessionStorage flag keeps it from repeating) but makes production reload behavior traceable rather than a mystery. Pin the invariant that makes the hard-nav destination correct after a server-side redirect hop: the inline redirect branch keeps currentHref in sync with history across hops, so window.location.href = currentHref on a !ok / !isFlight response is safe. Comment flags this for future refactors. Tighten the test's RSC stream-parse filter to require an RSC-context co-marker for generic strings ("Connection closed", "Unexpected token"), so a benign third-party JSON.parse diagnostic cannot false-positive. Rewrite the hit-count comment to match the assertion (<= 2 is the real bound; hydration timing can make the prefetch race yield 1 hit). --- .../vinext/src/server/app-browser-entry.ts | 41 ++++++++++++++----- tests/e2e/app-router/rsc-fetch-errors.spec.ts | 31 ++++++++------ 2 files changed, 49 insertions(+), 23 deletions(-) diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index 43c20023a..79f7bf66a 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -826,13 +826,14 @@ function clearReloadFlag(): void { // A non-ok or wrong-content-type RSC response during initial hydration means // the server cannot deliver a valid RSC payload for this URL. Parsing the -// response as RSC causes an opaque parse failure. Reload once so the server -// has a chance to render the correct error page as HTML. Guard against -// reload loops: if a prior reload for this exact path produced the same -// failure, the endpoint is persistently broken — log and return a -// never-resolving stream so hydration halts without throwing (which would -// surface as an unhandled rejection). The SSR'd DOM stays visible. -function recoverFromBadInitialRscResponse(reason: string): ReadableStream { +// response as RSC causes an opaque parse failure. On the first attempt, +// reload once so the server has a chance to render the correct error page +// as HTML. On the second attempt (detected via the sessionStorage flag), the +// endpoint is persistently broken — return null so the caller aborts the +// whole hydration bootstrap (see main()). The server-rendered HTML stays +// visible; no `__VINEXT_RSC_*` globals are registered, so external probes +// do not see a half-hydrated page to interact with. +function recoverFromBadInitialRscResponse(reason: string): ReadableStream | null { const currentPath = window.location.pathname + window.location.search; if (readReloadFlag() === currentPath) { clearReloadFlag(); @@ -840,16 +841,21 @@ function recoverFromBadInitialRscResponse(reason: string): ReadableStream(); + return null; } writeReloadFlag(currentPath); + // One-shot diagnostic so a production reload is traceable. Only fires once + // per broken path thanks to the sessionStorage flag above; not noisy. + console.warn( + `[vinext] Initial RSC fetch ${reason}; reloading once to let the server render the HTML error page`, + ); window.location.reload(); // Never-resolving stream so the caller does not proceed into // createFromReadableStream before the reload takes effect. return new ReadableStream(); } -async function readInitialRscStream(): Promise> { +async function readInitialRscStream(): Promise | null> { const vinext = getVinextBrowserGlobal(); if (vinext.__VINEXT_RSC__ || vinext.__VINEXT_RSC_CHUNKS__ || vinext.__VINEXT_RSC_DONE__) { @@ -1009,6 +1015,13 @@ async function main(): Promise { registerServerActionCallback(); const rscStream = await readInitialRscStream(); + // null signals that readInitialRscStream aborted hydration (persistent RSC + // failure, post-reload). Do not continue — leaving the server-rendered + // HTML in place is the intended fallback, and we must not assign + // __VINEXT_RSC_ROOT__ / __VINEXT_RSC_NAVIGATE__ because that would make + // the page look hydrated to external probes and leave user clicks wired + // up to a navigateRsc that renders into an unmounted root. + if (rscStream === null) return; const root = normalizeAppElementsPromise(createFromReadableStream(rscStream)); const initialNavigationSnapshot = createClientNavigationRenderSnapshot( window.location.href, @@ -1177,10 +1190,16 @@ async function main(): Promise { // throws a cryptic "Connection closed" error. Match Next.js behavior // (fetch-server-response.ts:211, `!isFlightResponse || !res.ok || !res.body`): // hard-navigate to the browser URL so the server can render the correct - // error page as HTML. currentHref is the browser-facing URL without - // the .rsc suffix. The outer finally handles + // error page as HTML. The outer finally handles // settlePendingBrowserRouterState and clearPendingPathname on this // return path. + // + // Invariant: `currentHref` is the browser-facing URL without the .rsc + // suffix, kept in sync with `history` across redirect hops in the + // redirect branch below (it reassigns `currentHref = destinationPath` + // before `continue`). A future refactor that breaks this invariant + // (e.g. follows a redirect without updating `currentHref`) would send + // the user to a stale hard-nav destination here. const navContentType = navResponse.headers.get("content-type") ?? ""; const isRscResponse = navContentType.startsWith("text/x-component"); if (!navResponse.ok || !isRscResponse || !navResponse.body) { diff --git a/tests/e2e/app-router/rsc-fetch-errors.spec.ts b/tests/e2e/app-router/rsc-fetch-errors.spec.ts index 323890618..9d2978646 100644 --- a/tests/e2e/app-router/rsc-fetch-errors.spec.ts +++ b/tests/e2e/app-router/rsc-fetch-errors.spec.ts @@ -26,18 +26,22 @@ import { waitForAppRouterHydration } from "../helpers"; const BASE = "http://localhost:4174"; -// Stream-parse errors thrown by createFromFetch / createFromReadableStream when -// handed a non-RSC payload (HTML error body, wrong content-type, empty stream). -// The pre-fix code path produces exactly these messages; filtering the console -// error list to only these strings keeps the assertion specific to the bug -// this PR fixes and immune to unrelated diagnostics logged by other code paths. +// Stream-parse errors thrown by createFromFetch / createFromReadableStream +// when handed a non-RSC payload (HTML error body, wrong content-type, empty +// stream). The pre-fix failure path produces one of these diagnostics; the +// filter here stays narrow on purpose so unrelated console errors (hydration +// timing, third-party scripts, JSON.parse in fixture code) never +// false-positive. Generic strings ("Connection closed", "Unexpected token") +// are gated on an RSC-context co-marker so a benign third-party JSON.parse +// diagnostic cannot satisfy them. function isRscStreamParseError(msg: string): boolean { + const hasRscContext = msg.includes("RSC") || msg.includes("vinext"); return ( - msg.includes("Connection closed") || msg.includes("createFromFetch") || msg.includes("createFromReadableStream") || msg.includes("Failed to parse RSC") || - msg.includes("Unexpected token") + (hasRscContext && msg.includes("Connection closed")) || + (hasRscContext && msg.includes("Unexpected token")) ); } @@ -119,11 +123,14 @@ test.describe("RSC fetch non-ok response handling", () => { await page.waitForTimeout(1500); expect(page.url()).toBe(`${BASE}/about`); - // Expected sequence: exactly two hits — one from the client RSC nav - // fetch that triggered the hard-nav, and one from the post-reload - // initial RSC fetch that the sessionStorage guard recognises as a - // looped reload and aborts. A runaway loop would produce many more; - // any extra hit signals an unnecessary reload that would regress later. + // Expected trajectory: up to two hits — one from the home-page Link + // prefetch of /about.rsc (which the prefetch-cache discards because the + // response is !ok), and one from the client RSC nav fetch that triggers + // the hard-nav. Hydration timing can race the prefetch, in which case + // the count is 1. After the hard navigation to /about, the embedded-RSC + // branch in readInitialRscStream handles hydration without a fallback + // .rsc fetch, so no post-reload hits occur. A runaway reload loop would + // produce many more. expect(aboutRscHits).toBeLessThanOrEqual(2); const rscParseError = consoleErrors.find((msg) => isRscStreamParseError(msg)); From 88e14e7714e7f26aeaaae31a5e61477f431a2116 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Apr 2026 14:20:05 +0000 Subject: [PATCH 06/14] fix(app-router): tighten RSC error hygiene around hydration and unload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - recoverFromBadInitialRscResponse now returns null from both branches so main() aborts the hydration bootstrap in the reload-pending case too. The never-resolving ReadableStream sentinel would let main() proceed past readInitialRscStream and register __VINEXT_RSC_ROOT__ / __VINEXT_RSC_NAVIGATE__ during the brief window before the reload takes effect, briefly exposing a half-hydrated surface to external probes. - Track isPageUnloading via a pagehide listener and suppress the "[vinext] RSC navigation error" diagnostic when the catch fires because a hard-nav or anchor click aborted an in-flight RSC fetch. Mirrors the Next.js isPageUnloading pattern — the page is already going away, so the log is just noise. --- .../vinext/src/server/app-browser-entry.ts | 42 ++++++++++++------- 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index 79f7bf66a..fd4fc0132 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -801,6 +801,10 @@ function restorePopstateScrollPosition(state: unknown): void { }); } +// Set on pagehide so the RSC navigation catch block can distinguish expected +// fetch aborts (triggered by the unload itself) from real errors worth logging. +let isPageUnloading = false; + const RSC_RELOAD_KEY = "__vinext_rsc_initial_reload__"; // sessionStorage can throw SecurityError in strict-mode iframes, storage- @@ -829,11 +833,11 @@ function clearReloadFlag(): void { // response as RSC causes an opaque parse failure. On the first attempt, // reload once so the server has a chance to render the correct error page // as HTML. On the second attempt (detected via the sessionStorage flag), the -// endpoint is persistently broken — return null so the caller aborts the -// whole hydration bootstrap (see main()). The server-rendered HTML stays -// visible; no `__VINEXT_RSC_*` globals are registered, so external probes -// do not see a half-hydrated page to interact with. -function recoverFromBadInitialRscResponse(reason: string): ReadableStream | null { +// endpoint is persistently broken. Both branches return null so main() aborts +// the hydration bootstrap without registering `__VINEXT_RSC_*` globals — +// including during the brief window between reload() firing and the page +// actually unloading — so external probes never see a half-hydrated page. +function recoverFromBadInitialRscResponse(reason: string): null { const currentPath = window.location.pathname + window.location.search; if (readReloadFlag() === currentPath) { clearReloadFlag(); @@ -850,9 +854,7 @@ function recoverFromBadInitialRscResponse(reason: string): ReadableStream(); + return null; } async function readInitialRscStream(): Promise | null> { @@ -1015,12 +1017,13 @@ async function main(): Promise { registerServerActionCallback(); const rscStream = await readInitialRscStream(); - // null signals that readInitialRscStream aborted hydration (persistent RSC - // failure, post-reload). Do not continue — leaving the server-rendered - // HTML in place is the intended fallback, and we must not assign - // __VINEXT_RSC_ROOT__ / __VINEXT_RSC_NAVIGATE__ because that would make - // the page look hydrated to external probes and leave user clicks wired - // up to a navigateRsc that renders into an unmounted root. + // null signals that readInitialRscStream aborted hydration — either because + // a reload is in flight (first-attempt recovery) or the endpoint is + // persistently broken (post-reload). In both cases we must not assign + // __VINEXT_RSC_ROOT__ / __VINEXT_RSC_NAVIGATE__: the persistent-failure + // case would leave user clicks wired to a navigateRsc that renders into + // an unmounted root, and the reload-pending case would briefly expose a + // half-hydrated surface to external probes before the page unloads. if (rscStream === null) return; const root = normalizeAppElementsPromise(createFromReadableStream(rscStream)); const initialNavigationSnapshot = createClientNavigationRenderSnapshot( @@ -1307,7 +1310,13 @@ async function main(): Promise { // Don't hard-navigate to a stale URL if this navigation was superseded by // a newer one — the newer navigation is already in flight and would be clobbered. if (navId !== activeNavigationId) return; - console.error("[vinext] RSC navigation error:", error); + // Suppress the diagnostic when the page is unloading: a hard-nav or anchor + // click tears down the document and aborts any in-flight RSC fetch, which + // surfaces here as an error. The page is already going away, so the log + // is just noise. Mirrors Next.js' isPageUnloading pattern. + if (!isPageUnloading) { + console.error("[vinext] RSC navigation error:", error); + } window.location.href = currentHref; } finally { // Single settlement site: covers normal return, early returns on stale-id @@ -1389,5 +1398,8 @@ async function main(): Promise { } if (typeof document !== "undefined") { + window.addEventListener("pagehide", () => { + isPageUnloading = true; + }); void main(); } From ff52909506ab08789c161a181034c4f19c0e4678 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Apr 2026 14:20:14 +0000 Subject: [PATCH 07/14] test(app-router): tighten RSC fetch error suite - Replace the fixed 1500ms stability wait with waitForLoadState("networkidle"). Tracking actual request activity avoids flaky wall-clock behavior in CI; a reload loop keeps the network busy so the wait times out and still surfaces the regression. - Drop the third test. Its "URL does not contain .rsc" assertion is strictly weaker than the first test's exact URL match on the non-.rsc destination. --- tests/e2e/app-router/rsc-fetch-errors.spec.ts | 23 ++++--------------- 1 file changed, 4 insertions(+), 19 deletions(-) diff --git a/tests/e2e/app-router/rsc-fetch-errors.spec.ts b/tests/e2e/app-router/rsc-fetch-errors.spec.ts index 9d2978646..a4eaa58f4 100644 --- a/tests/e2e/app-router/rsc-fetch-errors.spec.ts +++ b/tests/e2e/app-router/rsc-fetch-errors.spec.ts @@ -118,9 +118,10 @@ test.describe("RSC fetch non-ok response handling", () => { // Stability check: the hard-nav must settle. Without the // readInitialRscStream reload-loop guard, the initial RSC fetch on the // freshly-loaded /about page hits the intercepted 500 and reloads - // indefinitely. Wait long enough for a loop to manifest, then verify - // the URL is stable and the intercept fired a bounded number of times. - await page.waitForTimeout(1500); + // indefinitely — networkidle would never fire and the default timeout + // catches that. Tracking actual request activity avoids flaky wall-clock + // waits in CI. + await page.waitForLoadState("networkidle"); expect(page.url()).toBe(`${BASE}/about`); // Expected trajectory: up to two hits — one from the home-page Link @@ -136,20 +137,4 @@ test.describe("RSC fetch non-ok response handling", () => { const rscParseError = consoleErrors.find((msg) => isRscStreamParseError(msg)); expect(rscParseError).toBeUndefined(); }); - - test("navigation to non-existent route does not land on the .rsc URL", async ({ page }) => { - await page.goto(`${BASE}/about`); - await waitForAppRouterHydration(page); - - // After hard-nav, URL must not contain .rsc - const navigationPromise = page.waitForURL(`${BASE}/this-route-does-not-exist`, { - timeout: 10_000, - }); - await page.evaluate(() => { - void (window as any).__VINEXT_RSC_NAVIGATE__("/this-route-does-not-exist"); - }); - await navigationPromise; - - expect(page.url()).not.toContain(".rsc"); - }); }); From e711509ac4f4d77ffa811dc5b8bcfe304cfe6224 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Apr 2026 14:53:32 +0000 Subject: [PATCH 08/14] fix(app-router): reset isPageUnloading on pageshow for bfcache restore Without a pageshow handler, a bfcache-restored document resumes with isPageUnloading stuck at true, causing the navigation catch to silently swallow every subsequent RSC error for the lifetime of that tab. Pair the listeners to match Next.js' fetch-server-response.ts. Also tighten the RSC fetch-errors spec with a >= 1 lower bound on the .rsc hit count so a future regression that skips the client nav fetch entirely still fails the test instead of trivially satisfying <= 2. --- packages/vinext/src/server/app-browser-entry.ts | 7 +++++++ tests/e2e/app-router/rsc-fetch-errors.spec.ts | 5 +++++ 2 files changed, 12 insertions(+) diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index fd4fc0132..fc094344b 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -1401,5 +1401,12 @@ if (typeof document !== "undefined") { window.addEventListener("pagehide", () => { isPageUnloading = true; }); + // Reset on pageshow so a bfcache-restored document does not resume with + // the flag stuck at true, which would silently swallow every subsequent + // RSC navigation error for the lifetime of that tab. Matches Next.js' + // fetch-server-response.ts handler pair. + window.addEventListener("pageshow", () => { + isPageUnloading = false; + }); void main(); } diff --git a/tests/e2e/app-router/rsc-fetch-errors.spec.ts b/tests/e2e/app-router/rsc-fetch-errors.spec.ts index a4eaa58f4..51fbff894 100644 --- a/tests/e2e/app-router/rsc-fetch-errors.spec.ts +++ b/tests/e2e/app-router/rsc-fetch-errors.spec.ts @@ -132,6 +132,11 @@ test.describe("RSC fetch non-ok response handling", () => { // branch in readInitialRscStream handles hydration without a fallback // .rsc fetch, so no post-reload hits occur. A runaway reload loop would // produce many more. + // Lower bound: at minimum, the client nav fetch that triggers the + // hard-nav must have fired. A value of 0 would mean the navigation + // skipped the RSC fetch entirely and the test is no longer exercising + // the !ok-guard path. + expect(aboutRscHits).toBeGreaterThanOrEqual(1); expect(aboutRscHits).toBeLessThanOrEqual(2); const rscParseError = consoleErrors.find((msg) => isRscStreamParseError(msg)); From 594fce453d0a55c6bde1ed607d95ec8e9361043d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Apr 2026 15:09:46 +0000 Subject: [PATCH 09/14] refactor(app-router): unify recoveryFromBadInitialRscResponse return and fix test listener ordering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collapse the two branches of recoverFromBadInitialRscResponse onto a single return null at the end of the function, so the "always returns null" invariant is structural rather than duplicated. A future refactor that wanted to return a sentinel from one branch would have to move the return, which is a visible edit — not a silent divergence. Register the Playwright console listener before page.goto in both rsc-fetch-errors specs so errors during initial page hydration are captured, matching the invariant the tests rely on elsewhere. --- .../vinext/src/server/app-browser-entry.ts | 20 +++++++++---------- tests/e2e/app-router/rsc-fetch-errors.spec.ts | 12 +++++------ 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index fc094344b..0a14ad00a 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -833,8 +833,8 @@ function clearReloadFlag(): void { // response as RSC causes an opaque parse failure. On the first attempt, // reload once so the server has a chance to render the correct error page // as HTML. On the second attempt (detected via the sessionStorage flag), the -// endpoint is persistently broken. Both branches return null so main() aborts -// the hydration bootstrap without registering `__VINEXT_RSC_*` globals — +// endpoint is persistently broken. Returns null so main() aborts the +// hydration bootstrap without registering `__VINEXT_RSC_*` globals — // including during the brief window between reload() firing and the page // actually unloading — so external probes never see a half-hydrated page. function recoverFromBadInitialRscResponse(reason: string): null { @@ -845,15 +845,15 @@ function recoverFromBadInitialRscResponse(reason: string): null { `[vinext] Initial RSC fetch ${reason} after reload; aborting hydration. ` + "Server-rendered HTML remains visible; client components will not hydrate.", ); - return null; + } else { + writeReloadFlag(currentPath); + // One-shot diagnostic so a production reload is traceable. Only fires once + // per broken path thanks to the sessionStorage flag above; not noisy. + console.warn( + `[vinext] Initial RSC fetch ${reason}; reloading once to let the server render the HTML error page`, + ); + window.location.reload(); } - writeReloadFlag(currentPath); - // One-shot diagnostic so a production reload is traceable. Only fires once - // per broken path thanks to the sessionStorage flag above; not noisy. - console.warn( - `[vinext] Initial RSC fetch ${reason}; reloading once to let the server render the HTML error page`, - ); - window.location.reload(); return null; } diff --git a/tests/e2e/app-router/rsc-fetch-errors.spec.ts b/tests/e2e/app-router/rsc-fetch-errors.spec.ts index 51fbff894..825b9d7d9 100644 --- a/tests/e2e/app-router/rsc-fetch-errors.spec.ts +++ b/tests/e2e/app-router/rsc-fetch-errors.spec.ts @@ -49,9 +49,6 @@ test.describe("RSC fetch non-ok response handling", () => { test("client navigation to a non-existent route hard-navs to the non-.rsc URL", async ({ page, }) => { - await page.goto(`${BASE}/about`); - await waitForAppRouterHydration(page); - const consoleErrors: string[] = []; page.on("console", (msg) => { if (msg.type() === "error") { @@ -59,6 +56,9 @@ test.describe("RSC fetch non-ok response handling", () => { } }); + await page.goto(`${BASE}/about`); + await waitForAppRouterHydration(page); + // Trigger RSC navigation to a route that does not exist (returns 404 HTML). // We need to wait for the hard navigation, so we listen for the URL to change. const navigationPromise = page.waitForURL(`${BASE}/this-route-does-not-exist`, { @@ -97,9 +97,6 @@ test.describe("RSC fetch non-ok response handling", () => { }); }); - await page.goto(`${BASE}/`); - await waitForAppRouterHydration(page); - const consoleErrors: string[] = []; page.on("console", (msg) => { if (msg.type() === "error") { @@ -107,6 +104,9 @@ test.describe("RSC fetch non-ok response handling", () => { } }); + await page.goto(`${BASE}/`); + await waitForAppRouterHydration(page); + const navigationPromise = page.waitForURL(`${BASE}/about`, { timeout: 10_000 }); await page.evaluate(() => { void (window as any).__VINEXT_RSC_NAVIGATE__("/about"); From c0270f3ad1f321f458cc3c7baf77a4fb826e2436 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Apr 2026 15:43:45 +0000 Subject: [PATCH 10/14] refactor(app-router): hard-nav to response URL and isolate hydration bootstrap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On an RSC nav that hits a non-ok/wrong-content-type response, prefer the post-redirect response URL (navResponseUrl ?? navResponse.url) over the original currentHref so a redirect-to-error chain lands the user directly on the failing destination instead of bouncing off the redirect source and re-following the same 3xx. Matches Next.js' doMpaNavigation(responseUrl.toString()). Falls back to currentHref when no response URL is available. Extract the post-null-check body of main() into a synchronous bootstrapHydration helper. The null-branch structurally cannot reach any __VINEXT_RSC_* assignment now — a future refactor that interposes async work between the stream read and the globals is a visible change to the helper or its caller, not a silent invariant break. Tighten the /about.rsc intercept glob to an anchored regex so a hypothetical future self-prefetch from /about cannot inflate aboutRscHits past the <= 2 bound. Note the dual-guard coverage (status 500 + text/html) on the fulfill body so maintainers don't drop either half. --- .../vinext/src/server/app-browser-entry.ts | 36 ++++++++++++------- tests/e2e/app-router/rsc-fetch-errors.spec.ts | 5 ++- 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index 0a14ad00a..c6dbdb37b 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -1019,12 +1019,14 @@ async function main(): Promise { const rscStream = await readInitialRscStream(); // null signals that readInitialRscStream aborted hydration — either because // a reload is in flight (first-attempt recovery) or the endpoint is - // persistently broken (post-reload). In both cases we must not assign - // __VINEXT_RSC_ROOT__ / __VINEXT_RSC_NAVIGATE__: the persistent-failure - // case would leave user clicks wired to a navigateRsc that renders into - // an unmounted root, and the reload-pending case would briefly expose a - // half-hydrated surface to external probes before the page unloads. + // persistently broken (post-reload). Bootstrap is a separate synchronous + // helper so the null-branch structurally cannot reach any __VINEXT_RSC_* + // global assignment, even if a future refactor interposes async work here. if (rscStream === null) return; + bootstrapHydration(rscStream); +} + +function bootstrapHydration(rscStream: ReadableStream): void { const root = normalizeAppElementsPromise(createFromReadableStream(rscStream)); const initialNavigationSnapshot = createClientNavigationRenderSnapshot( window.location.href, @@ -1192,21 +1194,29 @@ async function main(): Promise { // or a proxy-rewritten response. Parsing such a body as an RSC stream // throws a cryptic "Connection closed" error. Match Next.js behavior // (fetch-server-response.ts:211, `!isFlightResponse || !res.ok || !res.body`): - // hard-navigate to the browser URL so the server can render the correct + // hard-navigate to the response URL so the server can render the correct // error page as HTML. The outer finally handles // settlePendingBrowserRouterState and clearPendingPathname on this // return path. // - // Invariant: `currentHref` is the browser-facing URL without the .rsc - // suffix, kept in sync with `history` across redirect hops in the - // redirect branch below (it reassigns `currentHref = destinationPath` - // before `continue`). A future refactor that breaks this invariant - // (e.g. follows a redirect without updating `currentHref`) would send - // the user to a stale hard-nav destination here. + // Prefer the post-redirect response URL over `currentHref`: on a + // redirect chain like `/old` → 307 → `/new` → 500, the browser's + // fetch already followed the redirect, so `navResponse.url` is the + // failing `/new` destination. Hard-navigating there directly avoids + // bouncing off `/old` just to re-follow the same 307, which would + // flash the wrong URL in the address bar and mis-key analytics. + // Matches Next.js' `doMpaNavigation(responseUrl.toString())`. Falls + // back to `currentHref` when no response URL is available. const navContentType = navResponse.headers.get("content-type") ?? ""; const isRscResponse = navContentType.startsWith("text/x-component"); if (!navResponse.ok || !isRscResponse || !navResponse.body) { - window.location.href = currentHref; + const responseUrl = navResponseUrl ?? navResponse.url; + let hardNavTarget = currentHref; + if (responseUrl) { + const parsed = new URL(responseUrl, window.location.origin); + hardNavTarget = parsed.pathname.replace(/\.rsc$/, "") + parsed.search; + } + window.location.href = hardNavTarget; return; } diff --git a/tests/e2e/app-router/rsc-fetch-errors.spec.ts b/tests/e2e/app-router/rsc-fetch-errors.spec.ts index 825b9d7d9..31bc3ee9a 100644 --- a/tests/e2e/app-router/rsc-fetch-errors.spec.ts +++ b/tests/e2e/app-router/rsc-fetch-errors.spec.ts @@ -88,10 +88,13 @@ test.describe("RSC fetch non-ok response handling", () => { // the fix is incomplete and a reload loop develops, the intercept hit // count will grow without bound. let aboutRscHits = 0; - await page.route("**/about.rsc**", (route) => { + await page.route(/\/about\.rsc(\?|$)/, (route) => { aboutRscHits += 1; return route.fulfill({ status: 500, + // status 500 + text/html exercises both the !ok guard and the + // content-type guard at the nav site; editing either value in + // isolation drops the combined-guard coverage this test targets. contentType: "text/html", body: "

Internal Server Error

", }); From 039c9524ad631f0e24b0091ba82b4d5c7440071f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Apr 2026 19:07:28 +0000 Subject: [PATCH 11/14] fix(app-router): add !body parity guard and preserve trailingSlash on hard-nav Add the missing `!rscResponse.body` branch to readInitialRscStream so the initial-hydration path guards on all three conditions Next.js checks in fetch-server-response.ts (`!ok || !isFlightResponse || !body`), not just the first two. A 200 with valid text/x-component but a null body (e.g. 204-shaped responses, edge workers that return headers without piping) now triggers the reload/abort recovery instead of parsing an empty stream and throwing downstream. Preserve the trailing slash when stripping .rsc from the hard-nav target. toRscUrl normalizes `/foo/` to `/foo.rsc` before the fetch, so the response URL loses the slash; stripping `.rsc` gave `/foo` and sites with trailingSlash:true incurred an extra 308 to the canonical `/foo/` form. Now restored when currentHref had one. --- packages/vinext/src/server/app-browser-entry.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index c6dbdb37b..cc6e4581e 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -915,6 +915,12 @@ async function readInitialRscStream(): Promise | null `returned non-RSC content-type "${contentType || "(missing)"}"`, ); } + // Missing body (e.g. 204 No Content, or an edge worker that returned ok + // headers without piping the stream) fails the same way downstream. + // Matches Next.js' `!res.body` branch in fetch-server-response.ts. + if (!rscResponse.body) { + return recoverFromBadInitialRscResponse("returned empty body"); + } // Successful RSC response clears the guard so a subsequent reload of the // same path after a transient failure still gets one recovery attempt. clearReloadFlag(); @@ -1214,7 +1220,16 @@ function bootstrapHydration(rscStream: ReadableStream): void { let hardNavTarget = currentHref; if (responseUrl) { const parsed = new URL(responseUrl, window.location.origin); - hardNavTarget = parsed.pathname.replace(/\.rsc$/, "") + parsed.search; + let pathname = parsed.pathname.replace(/\.rsc$/, ""); + // toRscUrl strips trailing slash before appending .rsc, so the + // response URL loses it on the round-trip. Restore it when the + // original href had one so sites with trailingSlash:true don't + // incur an extra 308 to the canonical form on the error path. + const origPathname = new URL(currentHref, window.location.origin).pathname; + if (origPathname.length > 1 && origPathname.endsWith("/") && !pathname.endsWith("/")) { + pathname += "/"; + } + hardNavTarget = pathname + parsed.search; } window.location.href = hardNavTarget; return; From 0a71894af7074288f2440180d24d3b3851e964cd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Apr 2026 19:12:53 +0000 Subject: [PATCH 12/14] feat(app-router): preserve hash on hard-nav and cover redirect-chain path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preserve the fragment from the user's clicked href on the hard-nav target — a .rsc response URL never carries a fragment, so dropping it would silently strip /foo#section down to /foo. Small ergonomic win over Next.js' doMpaNavigation, which has the same gap. Add an e2e test for the redirect-chain hard-nav path (/redirect-src → 307 → /about.rsc → 500). Verifies the address bar lands on /about (the post-redirect URL) rather than /redirect-src (the original request), exercising the navResponseUrl ?? navResponse.url branch the previous round introduced. Also pin the embedded-RSC assumption in the existing 500-route test by snapshotting the .rsc hit count before and after networkidle and asserting no additional hits — so a future change that makes the embed path conditional surfaces as a count change rather than a networkidle timeout. --- .../vinext/src/server/app-browser-entry.ts | 5 ++ tests/e2e/app-router/rsc-fetch-errors.spec.ts | 59 +++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index cc6e4581e..13693fa93 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -1230,6 +1230,11 @@ function bootstrapHydration(rscStream: ReadableStream): void { pathname += "/"; } hardNavTarget = pathname + parsed.search; + // Preserve the hash from the user's clicked href — a .rsc response + // URL never carries a fragment, so dropping it would silently strip + // `/foo#section` down to `/foo`. + const origHash = new URL(currentHref, window.location.origin).hash; + if (origHash) hardNavTarget += origHash; } window.location.href = hardNavTarget; return; diff --git a/tests/e2e/app-router/rsc-fetch-errors.spec.ts b/tests/e2e/app-router/rsc-fetch-errors.spec.ts index 31bc3ee9a..54b9e7f23 100644 --- a/tests/e2e/app-router/rsc-fetch-errors.spec.ts +++ b/tests/e2e/app-router/rsc-fetch-errors.spec.ts @@ -124,8 +124,15 @@ test.describe("RSC fetch non-ok response handling", () => { // indefinitely — networkidle would never fire and the default timeout // catches that. Tracking actual request activity avoids flaky wall-clock // waits in CI. + const hitsBeforeNetworkIdle = aboutRscHits; await page.waitForLoadState("networkidle"); expect(page.url()).toBe(`${BASE}/about`); + // Pin the embedded-RSC assumption: after the hard-nav lands on /about, + // hydration must come from the HTML-embedded RSC branch and issue no + // further .rsc fetches. If a future change makes the embed path + // conditional and falls back to a fetch, this count would grow and the + // test would flag it rather than silently relying on networkidle timing. + expect(aboutRscHits).toBe(hitsBeforeNetworkIdle); // Expected trajectory: up to two hits — one from the home-page Link // prefetch of /about.rsc (which the prefetch-cache discards because the @@ -145,4 +152,56 @@ test.describe("RSC fetch non-ok response handling", () => { const rscParseError = consoleErrors.find((msg) => isRscStreamParseError(msg)); expect(rscParseError).toBeUndefined(); }); + + test("redirect chain to a non-ok endpoint hard-navs to the post-redirect URL", async ({ + page, + }) => { + // Chain: client nav to /redirect-src → fetch /redirect-src.rsc → + // 307 Location /about.rsc → 500. The hard-nav target must be /about + // (the post-redirect URL), not /redirect-src (the original request). + // Without the navResponseUrl ?? navResponse.url branch in the nav-site + // guard, the browser would bounce off /redirect-src and the server + // would re-issue the 307, flashing the wrong URL in the address bar + // and mis-keying analytics. + let srcRscHits = 0; + let aboutRscHits = 0; + await page.route(/\/redirect-src\.rsc(\?|$)/, (route) => { + srcRscHits += 1; + return route.fulfill({ + status: 307, + headers: { Location: `${BASE}/about.rsc` }, + }); + }); + await page.route(/\/about\.rsc(\?|$)/, (route) => { + aboutRscHits += 1; + return route.fulfill({ + status: 500, + contentType: "text/html", + body: "

Internal Server Error

", + }); + }); + + const consoleErrors: string[] = []; + page.on("console", (msg) => { + if (msg.type() === "error") { + consoleErrors.push(msg.text()); + } + }); + + await page.goto(`${BASE}/`); + await waitForAppRouterHydration(page); + + const navigationPromise = page.waitForURL(`${BASE}/about`, { timeout: 10_000 }); + await page.evaluate(() => { + void (window as any).__VINEXT_RSC_NAVIGATE__("/redirect-src"); + }); + await navigationPromise; + + expect(page.url()).toBe(`${BASE}/about`); + expect(srcRscHits).toBeGreaterThanOrEqual(1); + expect(aboutRscHits).toBeGreaterThanOrEqual(1); + + const rscParseError = consoleErrors.find((msg) => isRscStreamParseError(msg)); + expect(rscParseError).toBeUndefined(); + }); }); From 017f2e0a193a40ccd97300d49b344d0d8b1a1502 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Apr 2026 19:20:26 +0000 Subject: [PATCH 13/14] chore(app-router): drop dead !body throw and tighten redirect-chain test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the unreachable `if (!rscResponse.body) throw new Error(...)` at the bottom of readInitialRscStream. The new !body guard added earlier in the function returns via recoverFromBadInitialRscResponse before this branch is reached, so the throw was stale belt-and-suspenders. Tighten the redirect-chain e2e test: - Capture every main-frame navigation URL via framenavigated and assert /redirect-src never appears, so a regression that dropped the navResponseUrl ?? navResponse.url branch is caught even though the final URL would still converge to /about via the server's 307 replay. - Pin the Playwright redirect-intercept assumption — the test relies on Playwright re-entering the route table on the 307 follow-up so both handlers fire in sequence; a future mocker migration that follows redirects transparently would silently skip the /about.rsc intercept. --- packages/vinext/src/server/app-browser-entry.ts | 4 ---- tests/e2e/app-router/rsc-fetch-errors.spec.ts | 15 +++++++++++++++ 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index 13693fa93..1303ce743 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -938,10 +938,6 @@ async function readInitialRscStream(): Promise | null restoreHydrationNavigationContext(window.location.pathname, window.location.search, params); - if (!rscResponse.body) { - throw new Error("[vinext] Initial RSC response had no body"); - } - return rscResponse.body; } diff --git a/tests/e2e/app-router/rsc-fetch-errors.spec.ts b/tests/e2e/app-router/rsc-fetch-errors.spec.ts index 54b9e7f23..9db054f03 100644 --- a/tests/e2e/app-router/rsc-fetch-errors.spec.ts +++ b/tests/e2e/app-router/rsc-fetch-errors.spec.ts @@ -165,6 +165,11 @@ test.describe("RSC fetch non-ok response handling", () => { // and mis-keying analytics. let srcRscHits = 0; let aboutRscHits = 0; + // Intercept chain depends on Playwright re-entering the route table on + // the 307 follow-up so both handlers fire in sequence (browser fetch + // defaults to redirect:follow). Migrating to a mocker that follows + // redirects without re-dispatching would silently skip the /about.rsc + // intercept and the test would fall through to the real backend. await page.route(/\/redirect-src\.rsc(\?|$)/, (route) => { srcRscHits += 1; return route.fulfill({ @@ -188,6 +193,15 @@ test.describe("RSC fetch non-ok response handling", () => { } }); + // Capture the document URL at every main-frame navigation so we can + // assert the address bar never flashes /redirect-src en route to /about. + // Without this, a regression that dropped `navResponseUrl ?? navResponse.url` + // would still pass because the server's 307 converges to /about eventually. + const frameUrls: string[] = []; + page.on("framenavigated", (frame) => { + if (frame === page.mainFrame()) frameUrls.push(frame.url()); + }); + await page.goto(`${BASE}/`); await waitForAppRouterHydration(page); @@ -198,6 +212,7 @@ test.describe("RSC fetch non-ok response handling", () => { await navigationPromise; expect(page.url()).toBe(`${BASE}/about`); + expect(frameUrls.some((url) => url.includes("/redirect-src"))).toBe(false); expect(srcRscHits).toBeGreaterThanOrEqual(1); expect(aboutRscHits).toBeGreaterThanOrEqual(1); From 46454b51f685fcc6435915eded6f53710d7194dc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Apr 2026 19:29:27 +0000 Subject: [PATCH 14/14] fix(app-router): abort hydration when sessionStorage cannot persist reload guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If sessionStorage is denied (strict-mode iframe, enterprise-locked browser policy), the reload-loop guard's setItem silently no-ops and every subsequent getItem returns null — turning the recovery path into an infinite reload loop on a persistently broken .rsc endpoint. Verify the write by reading it back and, if it didn't persist, abort hydration immediately so the user sees the server-rendered HTML instead. Hoist `new URL(currentHref, window.location.origin)` to a single `origUrl` binding in the hard-nav target builder — the same href was parsed twice for trailing-slash detection and hash extraction. --- .../vinext/src/server/app-browser-entry.ts | 38 +++++++++++++------ 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index 1303ce743..7019c89b7 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -845,15 +845,28 @@ function recoverFromBadInitialRscResponse(reason: string): null { `[vinext] Initial RSC fetch ${reason} after reload; aborting hydration. ` + "Server-rendered HTML remains visible; client components will not hydrate.", ); - } else { - writeReloadFlag(currentPath); - // One-shot diagnostic so a production reload is traceable. Only fires once - // per broken path thanks to the sessionStorage flag above; not noisy. - console.warn( - `[vinext] Initial RSC fetch ${reason}; reloading once to let the server render the HTML error page`, + return null; + } + writeReloadFlag(currentPath); + // Verify the write persisted. In storage-denied environments (strict-mode + // iframes, locked-down enterprise policies), every getItem returns null and + // every setItem silently no-ops, so the reload-loop guard cannot survive + // the reload — the page would loop forever. Abort instead so the user at + // least sees the server-rendered HTML. + if (readReloadFlag() !== currentPath) { + console.error( + `[vinext] Initial RSC fetch ${reason}; sessionStorage unavailable so the ` + + "reload-loop guard cannot persist — aborting hydration. " + + "Server-rendered HTML remains visible; client components will not hydrate.", ); - window.location.reload(); + return null; } + // One-shot diagnostic so a production reload is traceable. Only fires once + // per broken path thanks to the sessionStorage flag above; not noisy. + console.warn( + `[vinext] Initial RSC fetch ${reason}; reloading once to let the server render the HTML error page`, + ); + window.location.reload(); return null; } @@ -1216,21 +1229,24 @@ function bootstrapHydration(rscStream: ReadableStream): void { let hardNavTarget = currentHref; if (responseUrl) { const parsed = new URL(responseUrl, window.location.origin); + const origUrl = new URL(currentHref, window.location.origin); let pathname = parsed.pathname.replace(/\.rsc$/, ""); // toRscUrl strips trailing slash before appending .rsc, so the // response URL loses it on the round-trip. Restore it when the // original href had one so sites with trailingSlash:true don't // incur an extra 308 to the canonical form on the error path. - const origPathname = new URL(currentHref, window.location.origin).pathname; - if (origPathname.length > 1 && origPathname.endsWith("/") && !pathname.endsWith("/")) { + if ( + origUrl.pathname.length > 1 && + origUrl.pathname.endsWith("/") && + !pathname.endsWith("/") + ) { pathname += "/"; } hardNavTarget = pathname + parsed.search; // Preserve the hash from the user's clicked href — a .rsc response // URL never carries a fragment, so dropping it would silently strip // `/foo#section` down to `/foo`. - const origHash = new URL(currentHref, window.location.origin).hash; - if (origHash) hardNavTarget += origHash; + if (origUrl.hash) hardNavTarget += origUrl.hash; } window.location.href = hardNavTarget; return;