[PR #875] [MERGED] fix(app-router): hard-navigate to browser URL on non-ok RSC fetch #912

Closed
opened 2026-05-06 13:10:48 +02:00 by BreizhHardware · 0 comments

📋 Pull Request Information

Original PR: https://github.com/cloudflare/vinext/pull/875
Author: @NathanDrake2406
Created: 4/23/2026
Status: Merged
Merged: 4/24/2026
Merged by: @james-elicx

Base: mainHead: fix/rsc-fetch-not-ok-hard-nav


📝 Commits (10+)

  • 4ef63f0 fix(app-router): hard-navigate to browser URL on non-ok RSC fetch
  • 6071901 fix(app-router): break reload loop when initial RSC fetch is persistently non-ok
  • 97d2942 fix(app-router): harden initial RSC recovery and cover non-RSC content-types
  • 6d0f2e7 test(app-router): narrow RSC error filter to stream-parse diagnostics
  • 23796b6 fix(app-router): abort hydration bootstrap when recovery cannot restore RSC
  • 88e14e7 fix(app-router): tighten RSC error hygiene around hydration and unload
  • ff52909 test(app-router): tighten RSC fetch error suite
  • e711509 fix(app-router): reset isPageUnloading on pageshow for bfcache restore
  • 594fce4 refactor(app-router): unify recoveryFromBadInitialRscResponse return and fix test listener ordering
  • c0270f3 refactor(app-router): hard-nav to response URL and isolate hydration bootstrap

📊 Changes

2 files changed (+396 additions, -6 deletions)

View changed files

📝 packages/vinext/src/server/app-browser-entry.ts (+174 -6)
tests/e2e/app-router/rsc-fetch-errors.spec.ts (+222 -0)

📄 Description

What this changes

When a client-side RSC navigation fetch returns a non-ok response (404, 500, or any other HTTP error), the client now immediately hard-navigates to the browser-facing URL instead of trying to parse the error body as an RSC stream.

The same guard is added to the initial hydration fallback fetch in readInitialRscStream: a non-ok response triggers window.location.reload() so the server can render the correct error page as HTML.

Why

Without this fix, a non-ok RSC fetch (e.g. a server returning an HTML error page with status 500) is passed directly to createFromFetch. The RSC parser tries to read HTML as an RSC stream, throws a cryptic "Connection closed" error, and the outer catch logs "[vinext] RSC navigation error: ..." then hard-navigates to the same URL, which can loop.

The fix matches Next.js behavior from packages/next/src/client/components/router-reducer/fetch-server-response.ts:211:

if (!isFlightResponse || !res.ok || !res.body) {
  return doMpaNavigation(responseUrl.toString())
}

Approach

In the navigation path (inside navigateRsc): added !navResponse.ok check after the stale-navigation guard. On non-ok: settle pending router state, clear pending pathname, and window.location.href = href (the browser URL without .rsc suffix). The stale-navigation check at navId !== activeNavigationId runs first so superseded navigations never trigger a hard-nav to a stale URL.

In readInitialRscStream: added !rscResponse.ok check after the fallback fetch. On non-ok: window.location.reload() and return a never-resolving stream (so the caller does not proceed into createFromReadableStream during the brief window before the reload takes effect).

No changes to the prefetch path -- prefetchRscResponse already discards non-ok responses before storing snapshots.

Validation

New E2E tests in tests/e2e/app-router/rsc-fetch-errors.spec.ts:

  • RSC navigation to a non-existent route (returns 404 RSC, but with !ok) hard-navs to the non-.rsc URL
  • RSC navigation with a Playwright route intercept returning HTTP 500 HTML hard-navs without logging a stream-parse error
  • The final URL after hard-nav does not contain .rsc

All 3 new tests pass. Existing navigation, regression, and error-handling E2E test suites pass (1 pre-existing flaky test on retry, unrelated to this change).

Risks / follow-ups

The readInitialRscStream non-ok path uses window.location.reload(), which will loop if the server consistently returns non-ok for the page's RSC endpoint. This is the same behavior as a hard browser refresh on a broken server and is not worse than the pre-fix behavior (opaque parse failure).


🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.

## 📋 Pull Request Information **Original PR:** https://github.com/cloudflare/vinext/pull/875 **Author:** [@NathanDrake2406](https://github.com/NathanDrake2406) **Created:** 4/23/2026 **Status:** ✅ Merged **Merged:** 4/24/2026 **Merged by:** [@james-elicx](https://github.com/james-elicx) **Base:** `main` ← **Head:** `fix/rsc-fetch-not-ok-hard-nav` --- ### 📝 Commits (10+) - [`4ef63f0`](https://github.com/cloudflare/vinext/commit/4ef63f0bd77e6e3d7b6f5c456ab5abb224250d73) fix(app-router): hard-navigate to browser URL on non-ok RSC fetch - [`6071901`](https://github.com/cloudflare/vinext/commit/607190152019814578343e30b87fe26f0a603957) fix(app-router): break reload loop when initial RSC fetch is persistently non-ok - [`97d2942`](https://github.com/cloudflare/vinext/commit/97d2942e37897f8ed152048ac317f05b9db5d929) fix(app-router): harden initial RSC recovery and cover non-RSC content-types - [`6d0f2e7`](https://github.com/cloudflare/vinext/commit/6d0f2e72b6164b3345742434336c736b56324a9c) test(app-router): narrow RSC error filter to stream-parse diagnostics - [`23796b6`](https://github.com/cloudflare/vinext/commit/23796b627f5278263468cd7fc382c8b2c09fa3c5) fix(app-router): abort hydration bootstrap when recovery cannot restore RSC - [`88e14e7`](https://github.com/cloudflare/vinext/commit/88e14e7714e7f26aeaaae31a5e61477f431a2116) fix(app-router): tighten RSC error hygiene around hydration and unload - [`ff52909`](https://github.com/cloudflare/vinext/commit/ff52909506ab08789c161a181034c4f19c0e4678) test(app-router): tighten RSC fetch error suite - [`e711509`](https://github.com/cloudflare/vinext/commit/e711509ac4f4d77ffa811dc5b8bcfe304cfe6224) fix(app-router): reset isPageUnloading on pageshow for bfcache restore - [`594fce4`](https://github.com/cloudflare/vinext/commit/594fce453d0a55c6bde1ed607d95ec8e9361043d) refactor(app-router): unify recoveryFromBadInitialRscResponse return and fix test listener ordering - [`c0270f3`](https://github.com/cloudflare/vinext/commit/c0270f3ad1f321f458cc3c7baf77a4fb826e2436) refactor(app-router): hard-nav to response URL and isolate hydration bootstrap ### 📊 Changes **2 files changed** (+396 additions, -6 deletions) <details> <summary>View changed files</summary> 📝 `packages/vinext/src/server/app-browser-entry.ts` (+174 -6) ➕ `tests/e2e/app-router/rsc-fetch-errors.spec.ts` (+222 -0) </details> ### 📄 Description ## What this changes When a client-side RSC navigation fetch returns a non-ok response (404, 500, or any other HTTP error), the client now immediately hard-navigates to the browser-facing URL instead of trying to parse the error body as an RSC stream. The same guard is added to the initial hydration fallback fetch in `readInitialRscStream`: a non-ok response triggers `window.location.reload()` so the server can render the correct error page as HTML. ## Why Without this fix, a non-ok RSC fetch (e.g. a server returning an HTML error page with status 500) is passed directly to `createFromFetch`. The RSC parser tries to read HTML as an RSC stream, throws a cryptic `"Connection closed"` error, and the outer catch logs `"[vinext] RSC navigation error: ..."` then hard-navigates to the same URL, which can loop. The fix matches Next.js behavior from `packages/next/src/client/components/router-reducer/fetch-server-response.ts:211`: ```ts if (!isFlightResponse || !res.ok || !res.body) { return doMpaNavigation(responseUrl.toString()) } ``` ## Approach In the navigation path (inside `navigateRsc`): added `!navResponse.ok` check after the stale-navigation guard. On non-ok: settle pending router state, clear pending pathname, and `window.location.href = href` (the browser URL without `.rsc` suffix). The stale-navigation check at `navId !== activeNavigationId` runs first so superseded navigations never trigger a hard-nav to a stale URL. In `readInitialRscStream`: added `!rscResponse.ok` check after the fallback fetch. On non-ok: `window.location.reload()` and return a never-resolving stream (so the caller does not proceed into `createFromReadableStream` during the brief window before the reload takes effect). No changes to the prefetch path -- `prefetchRscResponse` already discards non-ok responses before storing snapshots. ## Validation New E2E tests in `tests/e2e/app-router/rsc-fetch-errors.spec.ts`: - RSC navigation to a non-existent route (returns 404 RSC, but with `!ok`) hard-navs to the non-`.rsc` URL - RSC navigation with a Playwright route intercept returning HTTP 500 HTML hard-navs without logging a stream-parse error - The final URL after hard-nav does not contain `.rsc` All 3 new tests pass. Existing navigation, regression, and error-handling E2E test suites pass (1 pre-existing flaky test on retry, unrelated to this change). ## Risks / follow-ups The `readInitialRscStream` non-ok path uses `window.location.reload()`, which will loop if the server consistently returns non-ok for the page's RSC endpoint. This is the same behavior as a hard browser refresh on a broken server and is not worse than the pre-fix behavior (opaque parse failure). --- <sub>🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.</sub>
BreizhHardware 2026-05-06 13:10:48 +02:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
starred/vinext#912
No description provided.