fix(app-router): hard-navigate to browser URL on non-ok RSC fetch#875
Conversation
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.
636ae0b to
4ef63f0
Compare
commit: |
There was a problem hiding this comment.
Pull request overview
Updates the App Router client to immediately fall back to an MPA navigation when an RSC fetch returns a non-OK HTTP status, avoiding attempts to parse non-RSC (e.g. HTML error pages) as an RSC stream.
Changes:
- Add a
!navResponse.okguard in client-side RSC navigation to hard-navigate to the browser URL on non-OK responses. - Add a
!rscResponse.okguard during initial hydration fallback RSC fetch to reload the page on non-OK responses. - Add Playwright E2E coverage for 404/500 non-OK RSC fetch scenarios to ensure the final URL is the non-
.rscdestination and no stream-parse error is logged.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| tests/e2e/app-router/rsc-fetch-errors.spec.ts | Adds E2E tests validating hard-navigation behavior on non-OK RSC navigation fetches. |
| packages/vinext/src/server/app-browser-entry.ts | Adds non-OK response guards for navigation RSC fetches and initial hydration fallback RSC fetches. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| test("client navigation to a 500-route hard-navs to the destination URL without looping", async ({ | ||
| page, | ||
| }) => { |
There was a problem hiding this comment.
The test title says “without looping”, but the assertions only verify the final URL and absence of specific console errors. This could still pass even if the page immediately reloads/redirects again after landing on /about. Consider adding an explicit assertion that the URL remains stable for a short period (or that the hard navigation only happens once) to actually cover the “no loop” behavior.
…ntly 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.
|
Addressed the review comment. Two changes:
Both changes in 6071901. |
|
/bigbonk review |
There was a problem hiding this comment.
Nice fix, and good match with Next.js' fetch-server-response.ts behavior (!isFlightResponse || !res.ok || !res.body → doMpaNavigation). The follow-up commit tightening the 500-route test and adding the reload-loop guard in readInitialRscStream is a solid improvement over v1.
A few things worth addressing before merge:
1. Behavioral parity: does the RSC content-type still need to be checked? Next.js guards on !isFlightResponse || !res.ok || !res.body. This PR only adds !res.ok on the nav path. That's defensible (HTML error bodies are usually !ok), but an RSC response with content-type text/html and status 200 (e.g. a proxy strips/rewrites the content-type) would still be fed into createFromFetch and throw the cryptic parse error this PR was meant to fix. Worth at least a follow-up issue to also check content-type.startsWith('text/x-component') and navResponse.body != null before parsing.
2. sessionStorage can throw. In strict-mode iframes, Safari private browsing on older versions, or with storage blocked, sessionStorage.getItem/setItem throws SecurityError. The initial-hydration path is the one place you really don't want to throw before the app boots. Wrap the storage access in try/catch so storage-denied environments degrade to "reload once per fetch and hope for the best", not "crash hydration".
3. Stale reload-guard key across paths. The guard is cleared on line 870 only on a successful RSC fetch for the current path. If the reload happens but the user navigates elsewhere (back button, link in the inline HTML) before hydration re-runs readInitialRscStream, the key remains set for the original path. Re-visiting that path later will trip the "after reload" branch on the very first failure and throw instead of attempting a recovery reload. Minor, but you could either clear the key on any successful readInitialRscStream entry (embed path included) or include a timestamp so stale keys expire.
4. Unhandled rejection on the "after reload" throw. main() is called via void main() with no top-level catch. Throwing from readInitialRscStream produces an unhandledrejection that surfaces as a bare red error in devtools and may trigger window.onerror / reporting hooks. The comment says "the outer bootstrap can fall back to showing the server-rendered HTML" — in practice there is no bootstrap catch; the SSR'd HTML stays visible by accident (hydration just never runs). Either add a .catch(err => console.error(...)) to the void main() call, or console.error then return instead of throwing. The latter is closer to what the comment describes.
5. Test: the 500-route spec is good, but aboutRscHits <= 3 is loose. With the new guard, the expected trajectory is exactly 2 hits (one from the client nav, one from the post-reload initial fetch which then aborts). <= 3 tolerates an extra reload that shouldn't happen. Consider tightening to <= 2 so a future regression that adds one unnecessary reload is caught.
6. Test leaks console listener across tests. page.on('console', ...) is registered per-test but page is per-test in Playwright, so this is fine in practice. Ignore.
Overall looks good — happy to approve once (2) is addressed; the rest are follow-ups.
| window.location.reload(); | ||
| // Return a never-resolving stream so the caller does not proceed into | ||
| // createFromReadableStream before the reload takes effect. | ||
| return new ReadableStream<Uint8Array>(); |
There was a problem hiding this comment.
sessionStorage.getItem / setItem can throw SecurityError (strict-mode iframes, storage-disabled browsers, some Safari private-browsing configurations). If that happens here the initial hydration crashes before the app boots, which is worse than the bug this PR fixes.
Suggest wrapping the storage calls:
| return new ReadableStream<Uint8Array>(); | |
| if (!rscResponse.ok) { | |
| const reloadKey = "__vinext_rsc_initial_reload__"; | |
| const currentPath = window.location.pathname + window.location.search; | |
| let priorAttempt: string | null = null; | |
| try { | |
| priorAttempt = sessionStorage.getItem(reloadKey); | |
| } catch { | |
| // sessionStorage can throw in strict iframes / private-mode Safari. | |
| // Fall through to the "no prior attempt" branch. | |
| } | |
| if (priorAttempt === currentPath) { | |
| try { | |
| sessionStorage.removeItem(reloadKey); | |
| } catch {} | |
| throw new Error( | |
| `[vinext] Initial RSC fetch returned ${rscResponse.status} after reload; aborting hydration`, | |
| ); | |
| } | |
| try { | |
| sessionStorage.setItem(reloadKey, currentPath); | |
| } catch {} | |
| window.location.reload(); | |
| // Return a never-resolving stream so the caller does not proceed into | |
| // createFromReadableStream before the reload takes effect. | |
| return new ReadableStream<Uint8Array>(); | |
| } | |
| // Clear the reload guard on success so a subsequent navigation to the same | |
| // path is not wrongly flagged as a looped reload. | |
| try { | |
| sessionStorage.removeItem("__vinext_rsc_initial_reload__"); | |
| } catch {} |
| sessionStorage.removeItem(reloadKey); | ||
| throw new Error( | ||
| `[vinext] Initial RSC fetch returned ${rscResponse.status} after reload; aborting hydration`, | ||
| ); |
There was a problem hiding this comment.
Throwing here becomes an unhandled promise rejection because main() is invoked as void main() at line 1326 with no top-level catch. The resulting red error in devtools plus any window.onerror/error reporting hook fire even though the comment says "the outer bootstrap can fall back to showing the server-rendered HTML" — in practice there is no bootstrap catch and the SSR'd HTML stays visible only by accident (hydration just never starts).
Consider one of:
- Replace
throwwithconsole.error(...)+return new ReadableStream()so the failure is reported without triggering unhandledrejection. - Add a
.catch(err => console.error('[vinext] hydration aborted:', err))to thevoid main()call at line 1326.
The first option matches the intent of the comment most closely.
| if (!navResponse.ok) { | ||
| window.location.href = currentHref; | ||
| return; | ||
| } |
There was a problem hiding this comment.
Parity note (non-blocking): Next.js' equivalent guard (fetch-server-response.ts in canary) is !isFlightResponse || !res.ok || !res.body. This PR only covers !res.ok.
The !res.body case is mostly theoretical, but !isFlightResponse covers a real scenario: a 200 response from a proxy/CDN that stripped or rewrote the Content-Type header (e.g. returned text/html instead of text/x-component). That would still be passed to createFromFetch and throw the exact stream-parse error this PR was added to fix.
Worth a follow-up to also check the content type before parsing:
const ct = navResponse.headers.get("content-type") ?? "";
const isFlight = ct.startsWith("text/x-component");
if (!navResponse.ok || !isFlight || !navResponse.body) {
window.location.href = currentHref;
return;
}| } | ||
| // 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__"); |
There was a problem hiding this comment.
Stale-key edge case: this only clears the key on the success path for the current path. If the post-reload hydration navigates elsewhere before re-entering readInitialRscStream (e.g. the SSR'd HTML contains a link the user clicks, or the back button), the key remains set for the original path. A later return to that path will skip the recovery-reload on the first failure and throw immediately.
Consider clearing the key unconditionally on any successful entry into readInitialRscStream (including the embedded-RSC branch at line 807), or storing a timestamp alongside the path so stale keys expire after e.g. 60 seconds.
|
|
||
| const rscParseError = consoleErrors.find( | ||
| (msg) => | ||
| msg.includes("RSC navigation error") || |
There was a problem hiding this comment.
With the guard in place the expected hit count is exactly 2 (one from the client RSC nav fetch that triggers the hard-nav, one from the post-reload initial RSC fetch that then aborts via the sessionStorage guard). <= 3 leaves room for one extra reload that shouldn't happen. Tightening to <= 2 would catch future regressions that add one unnecessary reload.
| msg.includes("RSC navigation error") || | |
| expect(aboutRscHits).toBeLessThanOrEqual(2); |
|
Review posted successfully at #875 (review). SummaryPosted a batched review with 5 inline comments covering:
Overall the fix correctly matches Next.js' |
…t-types 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.
|
All five review points addressed in 97d2942. 1. sessionStorage SecurityError (line 866). Wrapped every 2. Unhandled promise rejection (line 860). Replaced the 3. Content-type parity with Next.js (line 1142). Next.js guards on 4. Stale reload key (line 870). Added 5. Hit count tightening (line 119). Also extracted the recovery logic into a |
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 6071901: 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.
|
/bigbonk review |
There was a problem hiding this comment.
The fix matches Next.js' !isFlightResponse || !res.ok || !res.body guard from fetch-server-response.ts, and the follow-ups from the prior review are all addressed — sessionStorage is wrapped in try/catch, the content-type check is added on both paths, the reload flag is cleared on the embedded-RSC branch, the throw-after-reload is replaced with console.error + never-resolving stream, and the test hit count is tightened to <= 2.
One substantive concern about the "after reload" abort path below — after recoverFromBadInitialRscResponse returns a never-resolving stream, main() continues to hydrateRoot(...) with a suspended promise, which still sets window.__VINEXT_RSC_ROOT__, window.__VINEXT_HYDRATED_AT, and window.__VINEXT_RSC_NAVIGATE__. To any external observer (including waitForAppRouterHydration and user interactions), the app looks hydrated, but every router click will suspend forever. Worth considering whether main() should bail before hydrateRoot on this path.
The rest are small nits / test-comment cleanups, not blockers.
| window.location.reload(); | ||
| // Never-resolving stream so the caller does not proceed into | ||
| // createFromReadableStream before the reload takes effect. | ||
| return new ReadableStream<Uint8Array>(); |
There was a problem hiding this comment.
Subtle side effect worth flagging: recoverFromBadInitialRscResponse returns a never-resolving stream, so main() will resolve past await readInitialRscStream() and continue on to hydrateRoot(document, ..., { initialElements: <promise that never resolves> }) at line 1023. React will suspend hydration indefinitely (which is fine — SSR'd HTML stays visible, matching the comment), but main() still:
- assigns
window.__VINEXT_RSC_ROOT__ - sets
window.__VINEXT_HYDRATED_AT = performance.now() - registers
window.__VINEXT_RSC_NAVIGATE__
So to external probes (waitForAppRouterHydration, analytics, Sentry's hydration-timing hooks, user clicks on Links), the page looks hydrated. Any subsequent __VINEXT_RSC_NAVIGATE__ call will then fire an RSC fetch that succeeds but renders into a root that is still suspended on the initial elements — navigation will also hang.
Consider early-returning from main() on this path so __VINEXT_RSC_NAVIGATE__ is never registered and the page is an obvious no-interaction fallback:
const rscStream = await readInitialRscStream();
if (rscStream === HYDRATION_ABORTED_SENTINEL) return;or have recoverFromBadInitialRscResponse resolve a sentinel that the caller checks. This keeps the "SSR'd HTML stays visible" guarantee without leaving a half-hydrated global surface that misleads observers.
| return new ReadableStream<Uint8Array>(); | ||
| } | ||
| writeReloadFlag(currentPath); | ||
| window.location.reload(); |
There was a problem hiding this comment.
Nit: the first-attempt branch silently calls reload() with no diagnostic. When this fires in production it will look like a spurious page reload with no clue in the console about why. A one-line console.warn before writeReloadFlag(...) would make the behavior traceable without being noisy (it only happens once per broken path):
| window.location.reload(); | |
| writeReloadFlag(currentPath); | |
| 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<Uint8Array>(); |
| if (!navResponse.ok || !isRscResponse || !navResponse.body) { | ||
| window.location.href = currentHref; | ||
| return; | ||
| } |
There was a problem hiding this comment.
Minor: currentHref is the pre-redirect URL captured at navigation start. If a server-side redirect has already updated history via replaceHistoryStateWithoutNotify earlier in this while (true) loop (the redirect branch at ~L1194-1210), the redirect loop writes history to the new path but the outer currentHref variable has also been updated on L1205, so this should be correct. Flagging it only so a future refactor that breaks the redirect-loop invariant (e.g. not updating currentHref after a redirect) doesn't silently send users to the wrong hard-nav destination. A comment pinning the invariant ("currentHref is kept in sync with history across redirect hops above, so hard-nav on !ok is safe") would help.
| 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 |
There was a problem hiding this comment.
The comment says "exactly two hits" but the assertion is <= 2, and the actual count is either 1 or 2 depending on whether the home-page <Link href="/about"> prefetch races with the __VINEXT_RSC_NAVIGATE__ call. The current assertion is correct; it's the comment that needs a tweak.
| // fetch that triggered the hard-nav, and one from the post-reload | |
| // Expected trajectory: up to two hits — one from the home-page Link | |
| // prefetch of /about.rsc (which is discarded on !ok), and one from the | |
| // client RSC nav fetch that triggers the hard-nav. If hydration timing | |
| // races, the prefetch may not fire and 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); |
| msg.includes("createFromReadableStream") || | ||
| msg.includes("Failed to parse RSC") || | ||
| msg.includes("Unexpected token") | ||
| ); |
There was a problem hiding this comment.
Nit: "Unexpected token" is broad enough to match any JSON.parse failure anywhere in the page (including unrelated third-party code, e.g. a prefetched fixture that logs a JSON parse diagnostic). Tightening to co-occurrence with an RSC-specific marker avoids false positives:
| ); | |
| function isRscStreamParseError(msg: string): boolean { | |
| return ( | |
| msg.includes("createFromFetch") || | |
| msg.includes("createFromReadableStream") || | |
| msg.includes("Failed to parse RSC") || | |
| (msg.includes("Connection closed") && msg.includes("RSC")) || | |
| (msg.includes("Unexpected token") && msg.includes("RSC")) | |
| ); | |
| } |
Given the PR's pre-fix failure mode is [vinext] RSC navigation error: Error: Connection closed, the Connection closed strand is the important one — and that already contains RSC via the [vinext] prefix context in the real error path.
|
Review posted successfully at #875 (review). SummaryPosted a batched review with 5 inline comments acknowledging that the previous round's feedback was all addressed (sessionStorage try/catch, content-type parity, reload-loop guard,
No blockers — the fix correctly mirrors Next.js' |
…re 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).
|
All five new points addressed in 23796b6. 1. Half-hydrated-state side effect (line 849 / abort path). Right — the never-resolving stream let 2. Silent reload diagnostic (line 846). Added a one-shot 3. 4. Test comment vs 5. |
Belongs to the separate rsc-fetch-errors branch (cloudflare#875), accidentally picked up by a prior `git add -A tests/` in this worktree. Remove it from this PR's branch; the file remains in place on its own branch.
|
/bigbonk review |
There was a problem hiding this comment.
The fix is correct and matches Next.js' !isFlightResponse || !res.ok || !res.body guard from fetch-server-response.ts (verified against canary). The review iteration has been thorough — sessionStorage is guarded, content-type parity is covered on both the nav and the initial-hydration paths, the reload-loop guard in readInitialRscStream is bounded and clears the flag on every successful embed-path entry, and the null-sentinel early return from main() cleanly avoids the half-hydrated global surface. Prefetch path is also safe: prefetchRscResponse only snapshots when response.ok, and snapshotRscResponse preserves the original content-type, so a 200-with-text/html response that is later consumed via consumePrefetchResponse will still be rejected by the new content-type check at the nav site.
A few non-blocking follow-ups worth considering — none of them need to hold this PR:
-
First-attempt still registers hydration globals before
reload()fires. WhenrecoverFromBadInitialRscResponsereturns a never-resolving stream (first attempt),main()falls through to assignwindow.__VINEXT_RSC_ROOT__,__VINEXT_HYDRATED_AT, and__VINEXT_RSC_NAVIGATE__in the tiny window beforewindow.location.reload()unloads the page. In practice this is harmless (the page is about to unload andhydrateRootsuspends on the never-resolving initial-elements promise), but the comment on thenullbranch explicitly says "we must not assign__VINEXT_RSC_ROOT__/__VINEXT_RSC_NAVIGATE__" as the rationale — that invariant only holds on the second attempt today. If you want symmetry, the first-attempt branch could returnnulltoo and letmain()early-exit before the reload fires from within the helper (the reload is scheduled before the return either way). -
isPageUnloadingparity with Next.js. Next.js tracks apagehide/pageshow-gatedisPageUnloadingflag and suppressesconsole.errorfor fetch errors during unload (fetch-server-response.ts:345). Vinext's outer catch at line 1310 (console.error("[vinext] RSC navigation error:", error)) will fire during the unloading interval after this PR's hard-nav on line 1206 if any other in-flight RSC fetch is aborted by the browser. Not introduced by this PR, but now more noticeable because hard-nav is the happy path for a broken response. Worth filing as a follow-up. -
Test: the third test is largely redundant.
"navigation to non-existent route does not land on the .rsc URL"duplicates the URL-suffix assertion from the first test. Could be folded in, or kept as a focused smoke-check — minor. -
Test flakiness:
waitForTimeout(1500)is a timer-based stability check. On a slow CI runner, a legitimate slow-path reload could land outside the 1.5s window and still fail the test on count. If you see flakes, consider replacing withpage.waitForLoadState('networkidle')after the hard-nav plus a short settle, which is less sensitive to absolute timing.
Approving the approach — nothing above is a blocker.
| window.location.reload(); | ||
| // Never-resolving stream so the caller does not proceed into | ||
| // createFromReadableStream before the reload takes effect. | ||
| return new ReadableStream<Uint8Array>(); |
There was a problem hiding this comment.
Symmetry nit: the comment on the null branch (line 1018-1023) says we must not assign the __VINEXT_RSC_* globals because doing so leaves a half-hydrated surface. That rationale also applies during the brief window between window.location.reload() firing here and the page actually unloading. In practice it is harmless (the never-resolving stream keeps hydrateRoot suspended, no user can click anything before unload), but for invariant symmetry both branches could return null and rely on main()'s early exit:
| return new ReadableStream<Uint8Array>(); | |
| 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 so main() early-exits immediately rather than registering | |
| // hydration globals in the tiny window before the reload unloads the page. | |
| // hydrateRoot on a never-resolving stream would suspend anyway, so no | |
| // interaction is possible, but bypassing the globals keeps the invariant | |
| // ("broken RSC response => no __VINEXT_RSC_* registered") uniform across | |
| // both attempts. | |
| return null; |
Non-blocking — pointing it out only because the comment on line 1018-1023 describes this as an invariant.
| const navContentType = navResponse.headers.get("content-type") ?? ""; | ||
| const isRscResponse = navContentType.startsWith("text/x-component"); | ||
| if (!navResponse.ok || !isRscResponse || !navResponse.body) { | ||
| window.location.href = currentHref; |
There was a problem hiding this comment.
Follow-up suggestion (not for this PR): Next.js' equivalent call site at fetch-server-response.ts tracks a pagehide/pageshow-gated isPageUnloading flag and suppresses console.error for fetch errors during unload. After this PR, hard-nav via window.location.href = currentHref is the expected happy path for non-ok/non-RSC responses, which increases the surface where in-flight RSC fetches can be aborted by the browser during unload and surface as errors through the outer catch at line 1310.
A future change could adopt the same isPageUnloading pattern around that console.error to avoid noisy diagnostics in the expected path. Not a blocker — calling it out so it can be filed as a follow-up.
| // 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); |
There was a problem hiding this comment.
Minor: waitForTimeout(1500) is a fixed-duration stability check. On a slow CI runner, a legitimate late reload could land just outside the 1.5s window and satisfy the URL assertion while still representing broken behavior, or a transient pause in Playwright could make a healthy hit arrive after the timer. Consider replacing with page.waitForLoadState('networkidle') (optionally with a short waitForTimeout(200) after) — that tracks actual request activity instead of a wall-clock window and is less sensitive to CI timing.
Non-blocking; leaving it in is fine and the aboutRscHits <= 2 assertion provides the real bound.
| await navigationPromise; | ||
|
|
||
| expect(page.url()).not.toContain(".rsc"); | ||
| }); |
There was a problem hiding this comment.
Nit: this third test is largely redundant with the first (page.url() already equals /this-route-does-not-exist there, which transitively satisfies "no .rsc suffix"). Could be folded into the first test as an additional assertion, or deleted, to keep the suite tight. Not a blocker.
|
Review posted successfully at #875 (review). SummaryPosted a batched review with 4 inline comments. Overall assessment: the fix is correct and matches Next.js' Non-blocking observations raised:
Confirmed prefetch safety: |
- 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.
- 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.
|
/bigbonk review |
There was a problem hiding this comment.
Fix is correct and matches Next.js' fetch-server-response.ts guard (!isFlightResponse || !res.ok || !res.body → doMpaNavigation). The iteration across prior review rounds covered the hard concerns: sessionStorage is guarded against SecurityError, the content-type check is mirrored on both the nav and initial-hydration paths, the reload-loop guard is bounded and cleared on every successful embed-path entry, and main() early-exits on null so no hydration globals leak onto the window in the unload window.
One substantive finding worth addressing before merge, plus two small nits:
1. (substantive) isPageUnloading is never reset on pageshow — diverges from Next.js and silences real errors after bfcache restore. Next.js' equivalent registers handlers on both pagehide and pageshow; vinext only has pagehide. When the user navigates away and returns via the back/forward cache, the JS execution context is restored with isPageUnloading === true, so the outer catch at line 1317 silently swallows every subsequent RSC navigation error for the lifetime of that tab. Fix is one listener.
2. (nit) First-attempt branch in recoverFromBadInitialRscResponse leaves hydration globals in place for the pre-unload window. Not a new concern — acknowledged in the PR thread — but since I was pointing out #1 anyway, reiterating here with a suggestion that unifies the branches.
3. (nit) Test comment vs assertion bound. Minor readability.
Prefetch path is confirmed safe: prefetchRscResponse only snapshots when response.ok, and snapshotRscResponse preserves content-type, so a cached 200-with-text/html response is still rejected by the new nav-site content-type guard.
| if (typeof document !== "undefined") { | ||
| window.addEventListener("pagehide", () => { | ||
| isPageUnloading = true; | ||
| }); |
There was a problem hiding this comment.
Substantive: isPageUnloading is set on pagehide but never reset on pageshow. When the browser restores this document from bfcache (back/forward cache), the JS execution context is resumed with isPageUnloading === true, and the outer navigation catch at line 1317 will silently drop every subsequent error — real errors, not just unload-aborts. Next.js explicitly pairs the two handlers for this exact reason (fetch-server-response.ts, canary):
window.addEventListener('pagehide', () => { isPageUnloading = true })
window.addEventListener('pageshow', () => { isPageUnloading = false })vinext also documents the pattern on line 1316 as "Mirrors Next.js' isPageUnloading pattern", so the divergence is not intentional. Fix:
| }); | |
| 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(); |
User-visible symptom without this: after a user navigates away from a vinext app and hits Back, any genuine RSC navigation failure (stream-parse error, 500 from backend, etc.) produces a silent hard-nav with no [vinext] RSC navigation error: diagnostic in the console, making production debugging significantly harder.
| console.warn( | ||
| `[vinext] Initial RSC fetch ${reason}; reloading once to let the server render the HTML error page`, | ||
| ); | ||
| window.location.reload(); |
There was a problem hiding this comment.
Invariant-symmetry nit (non-blocking): the comment on lines 836-839 says 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". Today that is strictly true: both branches do return null and main() exits on line 1026, so no globals are registered in either case. Good.
However, the current code ordering (reload() then return null) relies on main()'s synchronous early-return to protect the invariant. If a future refactor inlines or restructures recoverFromBadInitialRscResponse — e.g. a caller that checks for a sentinel and does fallback rendering instead of early-exit — the first-attempt branch silently becomes half-hydrated during the unload window, whereas the second-attempt branch remains safe.
Calling the reload from inside the helper before the return already gives the right behavior; one way to make the invariant locally obvious would be to factor the reload schedule and the return null together so both branches read identically at the call site. Current code is fine — flagging only because the comment calls this out as a load-bearing invariant.
| await page.goto(`${BASE}/about`); | ||
| await waitForAppRouterHydration(page); | ||
|
|
||
| const consoleErrors: string[] = []; |
There was a problem hiding this comment.
Nit: the console listener is registered after page.goto(/about) and waitForAppRouterHydration, so any RSC stream-parse error fired during the initial /about hydration would not be captured. For this test the navigation target is /this-route-does-not-exist, so it does not matter in practice, but the symmetry with the second test (which has the same pattern) is a bit accidental. Consider registering the listener before page.goto and filtering by page.url() if you want to scope — or leave as-is and add a note that initial-page errors are out of scope for this spec.
| // 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); |
There was a problem hiding this comment.
Nit: the comment above says "up to two hits" and the assertion is <= 2, which is correct. But it's worth noting that with the current recoverFromBadInitialRscResponse behavior, the post-reload initial-fetch is also intercepted (500), triggering the after reload; aborting hydration branch — which does not fire another .rsc request. So the bound is genuinely tight.
However, if a future refactor moves the initial RSC fetch behind any retry or revalidation logic, the <= 2 bound could silently stop catching the regression this test was designed for. Consider adding a lower bound too:
| expect(aboutRscHits).toBeLessThanOrEqual(2); | |
| // 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); |
|
Review posted successfully at #875 (review). SummaryThe fix is correct and matches Next.js's Posted a batched review with 4 inline comments:
|
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.
…and fix test listener ordering 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.
|
/bigbonk review |
There was a problem hiding this comment.
Reviewed the current state after all iterations. The fix is correct and matches Next.js' !isFlightResponse || !res.ok || !res.body → doMpaNavigation guard from fetch-server-response.ts. Prior review rounds covered the major concerns thoroughly:
sessionStorageaccess guarded againstSecurityError- Content-type parity (
text/x-component) on both nav and initial-hydration paths - Bounded reload-loop guard with fresh-attempt clear on successful embed-path entry
null-sentinel early return frommain()so no__VINEXT_RSC_*globals leak during the reload windowpagehide/pageshowpair correctly restoresisPageUnloadingafter bfcache restoreconsole.errorsuppression during unload mirrors Next.js
Verified against Next.js canary packages/next/src/client/components/router-reducer/fetch-server-response.ts — the behavior is equivalent.
Prefetch path is safe: prefetchRscResponse only snapshots when response.ok (shims/navigation.ts:448), and snapshotRscResponse preserves the original content-type (shims/navigation.ts:389), so a 200-with-text/html response that reached the prefetch cache (via status-ok-but-wrong-ct) will still be rejected by the new content-type check at the nav site.
One substantive non-blocking finding below about the hard-nav target URL differing from Next.js on redirect-to-error chains, plus two small test nits. None are blockers — approving the approach.
| const navContentType = navResponse.headers.get("content-type") ?? ""; | ||
| const isRscResponse = navContentType.startsWith("text/x-component"); | ||
| if (!navResponse.ok || !isRscResponse || !navResponse.body) { | ||
| window.location.href = currentHref; |
There was a problem hiding this comment.
Non-blocking parity observation: Next.js' equivalent path hard-navigates to the response URL (the post-redirect destination), not the original request URL:
// fetch-server-response.ts, canary
if (!isFlightResponse || !res.ok || !res.body) {
// ...
return doMpaNavigation(responseUrl.toString()) // uses res.url, not the original
}This PR uses window.location.href = currentHref, which is the original request URL. For the common case (no server-side redirect) these are the same and behavior matches. For a redirect chain that lands on a non-ok response — e.g. /old-page → 307 → /new-page → 500 — the browser's fetch follows the redirect, so navResponse.url is /new-page.rsc, but vinext hard-navs back to /old-page. The server then re-issues the 307 and the user lands on /new-page showing the HTML error page, which is correct eventually, but:
- The user briefly sees the URL bar flash
/old-pagebefore the redirect follows - Any analytics/error-reporting that keys off URL sees the redirect-source URL, not the failing destination
- One extra HTTP round-trip (the redirect replay)
The fix is to strip the .rsc suffix from the response URL when available:
| window.location.href = currentHref; | |
| const navContentType = navResponse.headers.get("content-type") ?? ""; | |
| const isRscResponse = navContentType.startsWith("text/x-component"); | |
| if (!navResponse.ok || !isRscResponse || !navResponse.body) { | |
| // Prefer the response URL (post-redirect) so we don't bounce the | |
| // user off the original request URL just to re-follow the same | |
| // redirect; matches Next.js' doMpaNavigation(responseUrl.toString()). | |
| // Strip the .rsc suffix so the browser lands on the user-facing URL. | |
| const responseUrl = navResponseUrl ?? navResponse.url; | |
| const hardNavTarget = responseUrl | |
| ? new URL(responseUrl, window.location.origin).pathname.replace(/\.rsc$/, "") + | |
| new URL(responseUrl, window.location.origin).search | |
| : currentHref; | |
| window.location.href = hardNavTarget; | |
| return; | |
| } |
Filing as a follow-up is fine — it's an edge case and the current behavior is not broken, just slightly wasteful.
| // 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) => { |
There was a problem hiding this comment.
Nit: the **/about.rsc** glob matches /about.rsc, /about.rsc?foo=1, and — because of the trailing ** — anything that happens to contain /about.rsc as a substring. The intercept stays active for the lifetime of the page, including after the hard-nav lands on /about. If the /about page has any <Link href="/about?..."> self-links or prefetches that re-emit /about.rsc, those hits would count toward aboutRscHits and could push the assertion over <= 2.
In practice the test fixture probably doesn't have self-prefetches on /about, but tightening the glob to anchor-match would make the test robust against future fixture changes:
| await page.route("**/about.rsc**", (route) => { | |
| await page.route(/\/about\.rsc(\?|$)/, (route) => { |
Not a blocker; flagging because the <= 2 bound is the load-bearing assertion for the "no loop" claim.
| contentType: "text/html", | ||
| body: "<html><body><h1>Internal Server Error</h1></body></html>", | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Minor: the intercept returns contentType: "text/html", which is important because it exercises the content-type guard in addition to the !ok guard. Worth a comment so a future maintainer tightening the body to "" or swapping to text/x-component doesn't silently stop exercising the combined guard path:
| }); | |
| return route.fulfill({ | |
| status: 500, | |
| // The combination (status 500, text/html body) exercises both the | |
| // !ok guard and the content-type guard at the nav site. Preserve | |
| // both when editing — a 500 with a valid RSC content-type would | |
| // only hit the !ok path, and a 200 with text/html would only hit | |
| // the content-type path. | |
| contentType: "text/html", | |
| body: "<html><body><h1>Internal Server Error</h1></body></html>", | |
| }); |
| ); | ||
| window.location.reload(); | ||
| } | ||
| return null; |
There was a problem hiding this comment.
Nit (not a blocker, and earlier reviews touched this): the first-attempt branch schedules window.location.reload() then returns null. The comment on the function header (lines 836-839) describes the null return as the mechanism that prevents the half-hydrated-globals invariant violation. That invariant holds only because main() happens to be synchronous after await readInitialRscStream() — there's no microtask yield between the null check at line 1025 and the global-assign lines below. If a future refactor interposes any async work (e.g. an await for React preloading) between those two points, the first-attempt branch starts leaking globals during the pre-unload window.
One way to pin the invariant at the call site is to hoist the if (rscStream === null) return; check to immediately after await readInitialRscStream() (it already is — good) and add a comment there referencing the synchronous-tail requirement. Or, more robustly, push the global-assignment into a helper that the null-branch cannot reach.
Fine as-is; flagging because the invariant is load-bearing and currently only enforced by code-placement convention.
|
Review posted successfully at #875 (review). SummaryThe fix is correct and matches Next.js' Posted a batched review with 4 inline comments — no blockers:
Prefetch safety confirmed: |
…bootstrap 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.
|
/bigbonk review |
There was a problem hiding this comment.
Reviewed the current state. The fix is correct and matches Next.js' !isFlightResponse || !res.ok || !res.body → doMpaNavigation guard from fetch-server-response.ts. Prior review rounds have been thorough and every substantive concern has been addressed:
sessionStorageaccess guarded againstSecurityErrorvia the three helper functions- Content-type parity (
text/x-component) applied to both the nav path and the initial-hydration path - Bounded reload-loop guard with fresh-attempt clear on successful embed-path entry
null-sentinel early return frommain(), withbootstrapHydrationextracted as a separate synchronous helper so the null branch structurally cannot reach the global assignmentspagehide/pageshowpair correctly resetsisPageUnloadingafter bfcache restore- Hard-nav target uses the post-redirect
navResponse.urlwith.rscstripped, matching Next.js'doMpaNavigation(responseUrl.toString()) - Test uses
waitForLoadState("networkidle")+ upper/lower bounds on hit count,/\/about\.rsc(\?|$)/regex for anchored matching
Two small non-blocking observations below — neither needs to hold this PR.
Prefetch path is safe: prefetchRscResponse only snapshots when response.ok, and snapshotRscResponse preserves the original content-type, so a 200-with-text/html response that reached the prefetch cache is still rejected by the new content-type check at the nav site.
| // (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") ?? ""; |
There was a problem hiding this comment.
Parity nit (non-blocking): the client-side nav path at line 1212 guards on all three conditions Next.js checks — !navResponse.ok || !isRscResponse || !navResponse.body. The initial-hydration path only checks !ok and content-type; there is no !rscResponse.body branch.
The !body case is rare in practice (a 200 with valid text/x-component content-type but a null body) but it is specifically called out in Next.js' guard and is the cheapest possible addition here for exact parity:
| const contentType = rscResponse.headers.get("content-type") ?? ""; | |
| if (!rscResponse.ok) { | |
| 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)"}"`, | |
| ); | |
| } | |
| // Missing body (e.g. 204 No Content, or an edge worker that returned ok | |
| // headers without piping the stream) would fail the same way downstream. | |
| // Matches Next.js' `!res.body` branch in fetch-server-response.ts. | |
| if (!rscResponse.body) { | |
| return recoverFromBadInitialRscResponse("returned empty body"); | |
| } |
| let hardNavTarget = currentHref; | ||
| if (responseUrl) { | ||
| const parsed = new URL(responseUrl, window.location.origin); | ||
| hardNavTarget = parsed.pathname.replace(/\.rsc$/, "") + parsed.search; |
There was a problem hiding this comment.
Minor trailing-slash edge case (non-blocking): toRscUrl at shims/navigation.ts:267 strips any trailing slash before appending .rsc, so /foo/ becomes /foo.rsc. Stripping .rsc here with /\.rsc$/ produces /foo, not /foo/.
For sites configured with trailingSlash: true, the hard-nav target then differs from the user's original href by one character, and the server will issue an extra 308 to the canonical /foo/ form. The user-visible effect is one additional HTTP round-trip on the error path and a brief URL flash in the address bar, which mirrors the redirect-chain concern the navResponseUrl ?? navResponse.url change was meant to avoid.
Not worth blocking on — the error path is already the slow path, and most apps do not use trailingSlash: true. Worth filing as a follow-up, or restoring the trailing slash when currentHref had one to avoid the extra hop.
| 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`); |
There was a problem hiding this comment.
Nit: this test is largely redundant with the first test — the first test already asserts page.url() ends without .rsc (toBe("${BASE}/this-route-does-not-exist")), which transitively covers the same invariant. Consider folding this assertion into the first test and dropping the third test() block to keep the suite tight, or adding a comment that explains what this third test uniquely exercises.
Non-blocker — a small bit of focused coverage is also fine if it documents the URL-shape invariant explicitly.
|
Review posted successfully at #875 (review). SummaryPosted a batched review with 3 inline comments acknowledging the thorough iteration and surfacing 3 non-blocking observations:
Overall assessment: the fix is correct, matches Next.js behavior exactly (verified against canary |
… 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.
|
/bigbonk re-review for issues |
There was a problem hiding this comment.
Fix matches Next.js' !isFlightResponse || !res.ok || !res.body → doMpaNavigation guard from fetch-server-response.ts, and the iteration across prior review rounds is thorough — sessionStorage is guarded, content-type parity is on both paths, the reload-loop guard is bounded and cleared on success, bootstrapHydration is factored out as a synchronous helper so the null-branch structurally cannot leak __VINEXT_RSC_* globals, pagehide/pageshow pair handles bfcache, isPageUnloading suppresses unload-aborted fetch errors, and the hard-nav target prefers navResponseUrl ?? navResponse.url with trailing-slash preservation. Prefetch path is confirmed safe (snapshot only on response.ok, content-type preserved).
A few small non-blocking observations below — none are blockers.
| if (origPathname.length > 1 && origPathname.endsWith("/") && !pathname.endsWith("/")) { | ||
| pathname += "/"; | ||
| } | ||
| hardNavTarget = pathname + parsed.search; |
There was a problem hiding this comment.
Hash fragment is dropped on hard-nav: hardNavTarget = pathname + parsed.search builds the target from the response URL, which — for a .rsc fetch — does not carry the browser's hash fragment. If the user clicked a <Link href="/foo#section"> that 500s, the hard-nav lands on /foo and silently loses the #section anchor.
Next.js' doMpaNavigation(responseUrl.toString()) has the same limitation (the response URL never carries a fragment), so this is parity with upstream — not a regression. Worth preserving the fragment from currentHref as a small improvement over Next.js, or at least a code comment flagging the divergence between the user's clicked href and the hard-nav target:
| hardNavTarget = pathname + parsed.search; | |
| hardNavTarget = pathname + parsed.search; | |
| // Preserve the hash from the user's clicked href — the response | |
| // URL from a .rsc fetch never carries a fragment, so dropping it | |
| // here would silently strip `/foo#section` to `/foo`. Next.js' | |
| // doMpaNavigation has the same limitation; this is a small | |
| // ergonomic win over upstream. | |
| const origHash = new URL(currentHref, window.location.origin).hash; | |
| if (origHash) hardNavTarget += origHash; |
| } | ||
| window.location.href = hardNavTarget; | ||
| return; | ||
| } |
There was a problem hiding this comment.
Test coverage gap (non-blocking): the new redirect-aware hard-nav logic — preferring navResponseUrl ?? navResponse.url over currentHref so a chain like /old → 307 → /new → 500 hard-navs to /new rather than /old — has no test in rsc-fetch-errors.spec.ts. Both existing tests exercise direct 404/500 responses without redirect hops, so the responseUrl branch and the original-vs-response URL divergence it guards against are only covered by code review.
Worth adding a test with a Playwright route intercept that returns 307 Location to a second intercepted 500 endpoint, asserting the final URL is the 307 target and the address bar never flashes the source URL. Fine to defer as a follow-up; flagging because this was called out in the last review round as the scenario motivating the switch from currentHref.
| // 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) { |
There was a problem hiding this comment.
Minor: the !navResponse.body branch here is reachable after await fetch(...) has resolved, at which point Response.body is a ReadableStream | null but is null only for responses with no body (e.g. 204, or a HEAD request). For an actual HTTP 200/500 with an empty body, body is a non-null empty stream, not null. The practical impact is that an edge worker that returns ok headers with a 0-byte body would still be fed to createFromFetch and throw downstream — matching the behavior Next.js' !res.body check is supposed to prevent.
Not a blocker — Next.js' guard has the same theoretical gap, and the content-type check catches most of these in practice. Noting only for future reference.
| // 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"); |
There was a problem hiding this comment.
waitForLoadState("networkidle") is the right call here, but note the interaction with the persistent /\/about\.rsc(\?|$)/ intercept: after the hard-nav to /about, if the server happens to not embed RSC in the HTML (e.g. a future change to the embedding strategy), the fallback fetch in readInitialRscStream hits the intercepted 500, triggers the reload-flag path, reloads the page, and on the second hit returns null — producing brief network activity that delays networkidle beyond the default 30s timeout only if the reload itself re-issues long-running requests.
Current fixture does embed RSC via __VINEXT_RSC__, so this is fine today. If the embedding path ever becomes conditional, this test's timing could become flaky without an obvious failure mode. Worth a code comment pinning the assumption, or an explicit aboutRscHits snapshot before networkidle and after, so a regression in the embed path shows up as a count change.
|
Review posted successfully at #875 (review). SummaryThe fix is correct and matches Next.js' Posted a batched review with 4 non-blocking inline comments:
No blockers. |
…path 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.
|
/bigbonk re-review for issues |
There was a problem hiding this comment.
Reviewed the current state. The fix matches Next.js' !isFlightResponse || !res.ok || !res.body → doMpaNavigation guard from fetch-server-response.ts, and this iteration (since the last /bigbonk round) addressed the remaining parity gaps flagged in round 6: !rscResponse.body is now guarded in readInitialRscStream, trailing-slash preservation covers trailingSlash: true sites, hash fragments survive the hard-nav, a redirect-chain test now exercises the navResponseUrl ?? navResponse.url branch, and isPageUnloading is cleaned up on both pagehide and pageshow.
One small dead-code observation (file-level since it's outside the diff), and a couple of non-blocking edge-case notes. No blockers.
File-level observation: dead throw at line 941-943. The pre-existing if (!rscResponse.body) throw new Error("[vinext] Initial RSC response had no body") at line 941-943 is now unreachable because the new !rscResponse.body guard at line 921-923 returns before it. Not broken, just stale — worth removing in a follow-up so a future reader doesn't assume the throw is load-bearing and add another layer of logic around it.
Prefetch safety confirmed: prefetchRscResponse only snapshots when response.ok, and snapshotRscResponse preserves content-type, so a cached 200-with-text/html response is still rejected by the content-type guard at the nav site.
| // 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); |
There was a problem hiding this comment.
Non-blocking parity observation: isPageUnloading is set globally on pagehide, so this suppression applies to any error surfacing through the outer catch during the unload window — not just fetch aborts triggered by the unload itself. A racing RSC fetch that genuinely fails (server crash, stream-parse error) and happens to complete between pagehide and the document actually tearing down will now be silently swallowed. In practice the page is seconds from being gone, and Next.js has identical behavior, so this is parity — not a regression.
Worth flagging only because this slightly broadens the suppression surface vs the pre-fix behavior where every such error was logged. Noting for future debugging — if a user reports "my server threw but I see nothing in the console when I click a link", this is the code path that suppresses it.
| status: 307, | ||
| headers: { Location: `${BASE}/about.rsc` }, | ||
| }); | ||
| }); |
There was a problem hiding this comment.
The redirect-chain test relies on two Playwright route handlers firing in sequence: the 307 intercept on /redirect-src.rsc, then the fetch() following the redirect with redirect: "follow" (the default) and Playwright re-entering the route table for /about.rsc. This works today, but the dependency on Playwright's redirect-intercept re-dispatch is subtle — a future test migration to a different mocking layer (MSW browser, a service-worker fetch shim) could silently stop exercising the chain if that layer follows redirects transparently without re-entering the route table, at which point the test would assert against the real backend for /about.rsc and the navResponseUrl ?? navResponse.url invariant would stop being covered.
One-line comment pinning the assumption would help:
| }); | |
| // Intercept chain requires Playwright to re-enter the route table on the | |
| // 307 follow-up so both handlers fire in sequence (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) => { |
| }); | ||
| await navigationPromise; | ||
|
|
||
| expect(page.url()).toBe(`${BASE}/about`); |
There was a problem hiding this comment.
Nit: the redirect-chain test doesn't assert the user-visible invariant it was added to protect — that the URL bar never flashes /redirect-src en route to /about. page.waitForURL(/about) + final expect(page.url()).toBe(.../about) together only confirm the final state; an intermediate reflection of /redirect-src in history (which would happen if a future refactor dropped the navResponseUrl ?? navResponse.url branch and hard-nav'd to currentHref) would still pass because the server's subsequent 307 → /about converges to the same final URL.
A lightweight add would capture the URL timeline via framenavigated and assert the sequence never includes /redirect-src:
| expect(page.url()).toBe(`${BASE}/about`); | |
| const frameUrls: string[] = []; | |
| page.on("framenavigated", (frame) => { | |
| if (frame === page.mainFrame()) frameUrls.push(frame.url()); | |
| }); | |
| 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`); | |
| // The document URL must never flash /redirect-src — the hard-nav target | |
| // is the post-redirect /about, not the original clicked href. Without | |
| // this assertion a regression that dropped `navResponseUrl ?? navResponse.url` | |
| // would still pass (the server's 307 converges to /about eventually). | |
| expect(frameUrls.some((url) => url.includes("/redirect-src"))).toBe(false); | |
| expect(srcRscHits).toBeGreaterThanOrEqual(1); | |
| expect(aboutRscHits).toBeGreaterThanOrEqual(1); |
Non-blocking.
|
Review posted successfully at #875 (review). SummaryPosted a batched review acknowledging that this iteration addressed the prior round's parity gaps ( 3 inline comments, all non-blocking:
Also flagged at PR-body level: the pre-existing Prefetch safety confirmed. |
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.
|
/bigbonk re-review for issues |
There was a problem hiding this comment.
Reviewed the final state. The fix correctly mirrors Next.js' !isFlightResponse || !res.ok || !res.body → doMpaNavigation guard from fetch-server-response.ts and every substantive concern from the prior review rounds has been resolved:
sessionStorageaccess guarded againstSecurityErrorvia helper wrappers- Content-type parity (
text/x-component) on both the nav path and the initial-hydration path - Bounded reload-loop guard with fresh clear on successful embed-path entry
bootstrapHydrationfactored out so thenull-sentinel branch structurally cannot reach__VINEXT_RSC_*global assignments, even if a future refactor interposes async workpagehide/pageshowpair handles bfcache restoreisPageUnloadingsuppresses noisy diagnostics from unload-aborted fetches- Hard-nav target uses
navResponseUrl ?? navResponse.urlwith.rscstripped, trailing slash preserved, and hash fragment preserved from the user's original href - Redirect-chain test asserts the URL bar never flashes the source URL via
framenavigated
Prefetch safety confirmed: prefetchRscResponse at shims/navigation.ts:448 only snapshots when response.ok, and snapshotRscResponse preserves the original content-type (line 389), so a 200-with-text/html that somehow reached the prefetch cache is still rejected by the nav-site content-type guard.
A couple of small non-blocking observations below — none are blockers. Two of them are residual edge cases, and one is a dead-code cleanup suggestion for the same file (outside the diff).
| console.warn( | ||
| `[vinext] Initial RSC fetch ${reason}; reloading once to let the server render the HTML error page`, | ||
| ); | ||
| window.location.reload(); |
There was a problem hiding this comment.
Edge case worth flagging (non-blocking): when sessionStorage.setItem persistently throws — the exact scenario the try/catch wrappers were added to handle — the reload-loop guard silently disengages. readReloadFlag() returns null on every reload (get either throws or returns a value that was never persisted), the else branch fires, writeReloadFlag() swallows the throw, and window.location.reload() runs again. On a persistently broken .rsc endpoint in a storage-denied environment (strict-mode iframe, certain enterprise-locked browser policies), this is an infinite reload loop rather than the intended "reload once and fall back to HTML".
The behavior is strictly better than the pre-PR opaque parse failure in the common case, and likely very rare in practice, but the PR body notes "the same behavior as a hard browser refresh on a broken server" — which is accurate for the non-storage-denied path only. A module-level let priorBadRscPath: string | null = null; in-memory fallback (consulted when readReloadFlag() returns null) would close the loop across environments without relying on storage.
Worth filing as a follow-up, not a blocker.
| // 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) { |
There was a problem hiding this comment.
Minor invariant observation (non-blocking): this guard fires after if (navId !== activeNavigationId) return; on line 1191, but the value of currentHref at that point reflects any server-side redirects that happened on earlier iterations of this while (true) loop (the redirect branch at line 1246-1258 reassigns it). So the fallback hardNavTarget = currentHref on line 1216 — used when responseUrl is falsy (empty string) — would point to the final destination of the redirect chain rather than the user's original clicked href. That is the right behavior for the common case of same-loop-iteration, but worth noting that currentHref is not the user's original clicked href on redirect chains: it tracks the latest history entry.
In combination with the responseUrl preference on line 1215, this is correct today. Flagging only because the invariant (currentHref kept in sync with history across redirect hops, so hard-nav on !ok is safe) is load-bearing and currently only maintained by code-placement convention in the redirect branch on line 1253. A future refactor that moves redirect handling into a helper but forgets to reassign currentHref in the caller would silently misroute users on the error path.
| // 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) => { |
There was a problem hiding this comment.
Coverage nit (non-blocking): this redirect-chain test fulfills the 307 with Location: ${BASE}/about.rsc but does not exercise the trailing-slash preservation branch (line 1225-1227 in app-browser-entry.ts). The new logic in that branch fires when currentHref has a trailing slash that toRscUrl stripped before the round-trip; the branch is untested by this suite.
A targeted way to cover it: add an assertion that a client nav to a path like /about/ on a trailingSlash: true fixture (if one exists) hard-navs to /about/ and not /about. Since the project's trailingSlash story may not have a dedicated fixture today, this is reasonable to defer as a follow-up — the current tests cover the dominant error-path scenario.
| // 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; |
There was a problem hiding this comment.
Micro-nit: the new URL(currentHref, window.location.origin) is parsed twice — once on line 1224 to extract origPathname, and again on line 1232 to extract origHash. Not a perf concern (URL parsing is fast), just a readability win to hoist const origUrl = new URL(currentHref, window.location.origin); once:
| const origHash = new URL(currentHref, window.location.origin).hash; | |
| const origUrl = new URL(currentHref, window.location.origin); | |
| 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`. | |
| if (origUrl.hash) hardNavTarget += origUrl.hash; |
Non-blocking.
|
Review posted successfully at #875 (review). SummaryThe fix is correct and matches Next.js' Posted 4 non-blocking comments — all small residual observations:
No blockers. |
…eload guard 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.
|
/bigbonk re-review for issues |
There was a problem hiding this comment.
Reviewed the final state. The fix correctly mirrors Next.js' !isFlightResponse || !res.ok || !res.body → doMpaNavigation guard from fetch-server-response.ts, and nine rounds of prior review have addressed every substantive concern:
sessionStorageaccess guarded againstSecurityErrorvia the three helper wrappers, plus a post-write verification that aborts instead of looping in storage-denied environments (closes the infinite-reload edge case raised in the prior round)- Content-type +
!bodyparity (text/x-componentstartsWith) on both the nav path and the initial-hydration path - Bounded reload-loop guard with fresh clear on successful embed-path entry
bootstrapHydrationfactored out as a synchronous helper so thenull-sentinel branch structurally cannot reach__VINEXT_RSC_*global assignmentspagehide/pageshowpair handles bfcache restore correctlyisPageUnloadingsuppresses unload-aborted fetch diagnostics- Hard-nav target prefers post-redirect
navResponseUrl ?? navResponse.urlwith trailing-slash and hash-fragment preservation - Redirect-chain test asserts URL bar never flashes the source URL via
framenavigated
Prefetch safety confirmed: prefetchRscResponse only snapshots when response.ok, and snapshotRscResponse preserves content-type, so a cached 200-with-text/html is still rejected by the nav-site content-type guard.
All CI checks are green. Three small non-blocking observations below — all optional follow-ups.
| // 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) { |
There was a problem hiding this comment.
Dead code follow-up (non-blocking): the pre-existing if (!rscResponse.body) throw new Error("[vinext] Initial RSC response had no body") that used to live a few lines below this branch was removed in this PR (the throw is gone in the diff), but worth a quick scan of the file to confirm no other stale check references the old invariant. The new guard here converts the throw into a structured recovery path, which is the right shape.
| // 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) { |
There was a problem hiding this comment.
Minor invariant note (non-blocking): the !navResponse.body branch fires after await fetch(...) resolves, at which point Response.body is null only for responses explicitly without a body (e.g. 204 No Content, HEAD, or an edge worker that closed the stream before piping). A genuine HTTP 200/500 with a zero-byte body still produces a non-null empty ReadableStream, so this branch won't catch that case — it will be fed to createFromFetch and throw downstream.
Matches Next.js' same theoretical gap in fetch-server-response.ts, and the content-type check catches most of these in practice. Not worth changing; noting only for future reference if someone investigates why a 0-byte RSC response still produces an opaque parse error.
| // 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); |
There was a problem hiding this comment.
Minor test nit (non-blocking): hitsBeforeNetworkIdle + aboutRscHits === hitsBeforeNetworkIdle is a nice tight embedded-RSC pin. One subtle edge: if Playwright's waitForLoadState("networkidle") returns synchronously because the network was already idle at the call site, the two snapshots are trivially equal and the assertion passes without actually exercising the embed path under network pressure.
Not a real concern for this fix since the reload-loop regression would produce hits regardless of timing, but if a future variant of this test ever wants to prove "the embed path actually ran", consider asserting against a fixture-provided hydration marker (e.g. a body attribute set only by the embed branch) rather than inferring from request counts.
|
Review posted successfully at #875 (review). SummaryVerified the final state of this thoroughly-iterated PR against the checked-out code. The fix correctly mirrors Next.js' Every substantive concern from the nine prior review rounds has been addressed:
Posted three small non-blocking inline comments:
No blockers. |
|
@james-elicx do we need to change approval permission for a new model? |
|
It should be fine |
|
/bigbonk re-review for blocking issues or approve |
There was a problem hiding this comment.
Final-state review. The fix correctly mirrors Next.js' !isFlightResponse || !res.ok || !res.body → doMpaNavigation guard from fetch-server-response.ts, and ten-plus rounds of iteration have resolved every substantive concern: sessionStorage guarded + post-write verification that aborts rather than loops in storage-denied envs, content-type + !body parity on both nav and initial-hydration paths, bootstrapHydration factored out so the null-sentinel branch structurally cannot register __VINEXT_RSC_* globals, pagehide/pageshow pair for bfcache, isPageUnloading suppression, hard-nav target uses post-redirect URL with trailing-slash + hash-fragment preservation, and the redirect-chain test asserts URL timeline via framenavigated.
Prefetch safety confirmed: prefetchRscResponse snapshots only when response.ok, and snapshotRscResponse preserves content-type, so a 200-with-text/html cached prefetch is still rejected at the nav-site content-type guard.
All CI checks are green.
Three small non-blocking observations below.
| if (msg.type() === "error") { | ||
| consoleErrors.push(msg.text()); | ||
| } | ||
| }); |
There was a problem hiding this comment.
Timing gap (non-blocking): the console listener is registered after page.goto(${BASE}/) and waitForAppRouterHydration, so any RSC stream-parse error fired during the home page's initial hydration is not captured. This matters for the 500 test specifically: the home page's <Link href="/about"> triggers a prefetch of /about.rsc that hits the intercepted 500. The prefetch path is !ok-gated and discards cleanly (confirmed), but if a future regression ever routed a prefetch through createFromFetch (or a parse error fired inside prefetchRscResponse before the !ok branch), this test would silently miss it.
Moving the listener registration before page.goto would close the gap:
| }); | |
| const consoleErrors: string[] = []; | |
| page.on("console", (msg) => { | |
| if (msg.type() === "error") { | |
| consoleErrors.push(msg.text()); | |
| } | |
| }); | |
| await page.goto(`${BASE}/`); | |
| await waitForAppRouterHydration(page); |
Not a blocker — the prefetch path's current !ok-gate makes this hypothetical today.
| msg.includes("createFromReadableStream") || | ||
| msg.includes("Failed to parse RSC") || | ||
| (hasRscContext && msg.includes("Connection closed")) || | ||
| (hasRscContext && msg.includes("Unexpected token")) |
There was a problem hiding this comment.
Nit (non-blocking): hasRscContext = msg.includes("RSC") || msg.includes("vinext") correctly catches the PR body's documented failure mode ([vinext] RSC navigation error: Error: Connection closed) because the outer console.error prefix lands in msg.text(). However, a raw SyntaxError: Unexpected token '<' in JSON at position 0 fired from deep inside the RSC parser on the initial-hydration path (not wrapped by the [vinext] prefix) would not contain RSC or vinext and would be filtered out here. The createFromFetch / createFromReadableStream / Failed to parse RSC strands cover most variants, so this is a small coverage edge rather than a real gap. Worth a thought if the test starts missing a regression in the initial-hydration path specifically.
| @@ -841,6 +915,29 @@ async function readInitialRscStream(): Promise<ReadableStream<Uint8Array>> { | |||
|
|
|||
| const rscResponse = await fetch(toRscUrl(window.location.pathname + window.location.search)); | |||
There was a problem hiding this comment.
Observation (non-blocking, outside the diff's scope): fetch(toRscUrl(...)) on line 916 can reject on a true network error (DNS failure, offline, connection reset before any response bytes). That rejection propagates out of readInitialRscStream, out of main, and surfaces as an unhandled promise rejection since void main() has no .catch. The non-ok path now has a clean recovery, but a rejection is still a bare red console error + window.onerror / error-reporting hooks firing.
One-line fix (consider as a follow-up, not for this PR):
void main().catch((err) => {
console.error("[vinext] hydration aborted:", err);
});Not in this PR's scope, but now that every documented response shape has a structured recovery, the network-error path is the last remaining case that produces the same user-visible symptom this PR was added to eliminate.
|
Review posted successfully at #875 (review). SummaryPosted a batched review confirming the final state of this heavily-iterated PR. The fix correctly mirrors Next.js'
Three non-blocking observations raised:
No blockers. |
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 triggerswindow.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:Approach
In the navigation path (inside
navigateRsc): added!navResponse.okcheck after the stale-navigation guard. On non-ok: settle pending router state, clear pending pathname, andwindow.location.href = href(the browser URL without.rscsuffix). The stale-navigation check atnavId !== activeNavigationIdruns first so superseded navigations never trigger a hard-nav to a stale URL.In
readInitialRscStream: added!rscResponse.okcheck after the fallback fetch. On non-ok:window.location.reload()and return a never-resolving stream (so the caller does not proceed intocreateFromReadableStreamduring the brief window before the reload takes effect).No changes to the prefetch path --
prefetchRscResponsealready discards non-ok responses before storing snapshots.Validation
New E2E tests in
tests/e2e/app-router/rsc-fetch-errors.spec.ts:!ok) hard-navs to the non-.rscURL.rscAll 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
readInitialRscStreamnon-ok path useswindow.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).