From a51efe3a2fee728689b01ace1e04e00e15b784a5 Mon Sep 17 00:00:00 2001 From: Daniel Walsh Date: Tue, 10 Mar 2026 01:37:40 +0000 Subject: [PATCH] perf: eliminate page probe double-execution of page components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The RSC entry had a "page probe" that called PageComponent() outside React's render cycle before every RSC render, solely to detect redirect()/notFound()/forbidden()/unauthorized() throws. The probe's result was discarded — only thrown errors mattered. Then the actual RSC render called the same component again, executing all data fetching and computation a second time. For async server components (the common case — pages that fetch data), this meant every fetch, database query, and API call ran twice per request. On Cloudflare Workers, where there's no persistent process cache to deduplicate fetches, this doubled CPU time for data-heavy pages. Investigation of Next.js source confirmed they do NOT use a probe. They handle redirect/notFound entirely during rendering: errors inside Suspense boundaries are caught by React error boundaries (rendered in-stream with status 200), while errors that escape all boundaries are caught by an outer try/catch before any bytes are flushed. Replace the page probe with three strategies based on page type: 1. Routes WITH loading.tsx (Suspense): stream the RSC response directly. Errors inside the Suspense boundary flow through the RSC stream as error references for client-side handling, matching Next.js behavior. 2. SYNC pages (no loading.tsx): a lightweight sync-only probe catches redirect/notFound thrown synchronously by the page function. This is practically free — sync server components don't do data fetching. Errors from async children inside inline boundaries flow through the stream as before. 3. ASYNC pages (no loading.tsx): buffer the entire RSC stream, then check onError for redirect/notFound. Components execute only once during the actual RSC render. Latency characteristics are identical because without Suspense, nothing can stream until all async work completes anyway. The layout probe is preserved unchanged — it's needed for correct parent-boundary selection when a layout throws notFound() (knowing which layout threw determines which not-found.tsx boundary to render). Co-Authored-By: Claude Opus 4.6 --- packages/vinext/src/entries/app-rsc-entry.ts | 130 ++- .../entry-templates.test.ts.snap | 780 ++++++++++++------ 2 files changed, 616 insertions(+), 294 deletions(-) diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index 706de395..8f05de01 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -2177,46 +2177,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { if (_layoutProbeResult instanceof Response) return _layoutProbeResult; } - // Pre-render the page component to catch redirect()/notFound() thrown synchronously. - // Server Components are just functions — we can call PageComponent directly to detect - // these special throws before starting the RSC stream. - // - // For routes with a loading.tsx Suspense boundary, we skip awaiting async components. - // The Suspense boundary + rscOnError will handle redirect/notFound thrown during - // streaming, and blocking here would defeat streaming (the slow component's delay - // would be hit before the RSC stream even starts). - // - // Because this calls the component outside React's render cycle, hooks like use() - // trigger "Invalid hook call" console.error in dev. The module-level ALS patch - // suppresses the warning only within this request's execution context. - const _hasLoadingBoundary = !!(route.loading && route.loading.default); - const _pageProbeResult = await _suppressHookWarningAls.run(true, async () => { - try { - const testResult = PageComponent({ params }); - // If it's a promise (async component), only await if there's no loading boundary. - // With a loading boundary, the Suspense streaming pipeline handles async resolution - // and any redirect/notFound errors via rscOnError. - if (testResult && typeof testResult === "object" && typeof testResult.then === "function") { - if (!_hasLoadingBoundary) { - await testResult; - } else { - // Suppress unhandled promise rejection — with a loading boundary, - // redirect/notFound errors are handled by rscOnError during streaming. - testResult.catch(() => {}); - } - } - } catch (preRenderErr) { - const specialResponse = await handleRenderError(preRenderErr); - if (specialResponse) return specialResponse; - // Non-special errors from the pre-render test are expected (e.g. use() hook - // fails outside React's render cycle, client references can't execute on server). - // Only redirect/notFound/forbidden/unauthorized are actionable here — other - // errors will be properly caught during actual RSC/SSR rendering below. - } - return null; - }); - if (_pageProbeResult instanceof Response) return _pageProbeResult; - // Mark end of compile phase: route matching, middleware, tree building are done. if (process.env.NODE_ENV !== "production") __compileEnd = performance.now(); @@ -2224,9 +2184,29 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // Track non-navigation RSC errors so we can detect when the in-tree global // ErrorBoundary catches during SSR (producing double /) and // re-render with renderErrorBoundaryPage (which skips layouts for global-error). + // + // Also track the first redirect/notFound/forbidden/unauthorized error thrown + // during rendering. Previously, a "page probe" called the page component + // outside React's render cycle to detect these throws before streaming. + // That caused double data fetching and ~2x CPU time. Instead, we now + // buffer the RSC stream and check onError — components execute only once + // during the actual RSC render. let _rscErrorForRerender = null; + let _caughtSpecialError = null; const _baseOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); const onRenderError = function(error, requestInfo, errorContext) { + // Capture the first redirect/notFound/forbidden/unauthorized for + // stream-buffered detection (replaces the old page probe). + if (!_caughtSpecialError && error && typeof error === "object" && "digest" in error) { + const digest = String(error.digest); + if ( + digest.startsWith("NEXT_REDIRECT;") || + digest === "NEXT_NOT_FOUND" || + digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;") + ) { + _caughtSpecialError = error; + } + } if (!(error && typeof error === "object" && "digest" in error)) { _rscErrorForRerender = error; } @@ -2234,6 +2214,72 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }; const rscStream = renderToReadableStream(element, { onError: onRenderError }); + // Detect page-level redirect/notFound/forbidden/unauthorized errors before + // committing to the HTTP response status code. Strategy varies by page type: + // + // 1. Routes WITH loading.tsx (Suspense): stream directly. Errors inside the + // Suspense boundary flow through the RSC stream for client-side handling. + // + // 2. SYNC pages (no loading.tsx): a lightweight sync-only probe catches + // redirect/notFound thrown synchronously by the page function. This is + // cheap — sync server components do no data fetching. The probe result + // is discarded; only the thrown error matters. + // + // 3. ASYNC pages (no loading.tsx): buffer the entire RSC stream to catch + // redirect/notFound thrown after await (e.g. fetch then notFound). + // This is the key optimization — the old "page probe" awaited the full + // async component, causing every data fetch to run twice. Buffering the + // stream instead means components execute only once. Latency is the same + // because without Suspense, nothing can stream until all async work is done. + const _hasLoadingBoundary = !!(route.loading && route.loading.default); + const _isAsyncPage = PageComponent.constructor?.name === "AsyncFunction"; + let _bufferedRscStream; + + if (_hasLoadingBoundary) { + // Suspense route: stream directly. Errors inside the Suspense boundary + // (loading.tsx) flow through the RSC stream as error references. + _bufferedRscStream = rscStream; + } else if (!_isAsyncPage) { + // Sync page: lightweight probe catches sync redirect/notFound/forbidden/ + // unauthorized from the page function itself. Errors from async children + // inside inline boundaries flow through the stream as before. + const _syncProbeResult = await _suppressHookWarningAls.run(true, async () => { + try { + PageComponent({ params }); + } catch (syncErr) { + const specialResponse = await handleRenderError(syncErr); + if (specialResponse) return specialResponse; + } + return null; + }); + if (_syncProbeResult instanceof Response) return _syncProbeResult; + _bufferedRscStream = rscStream; + } else { + // Async page without Suspense: buffer the entire RSC stream to catch + // redirect/notFound thrown after await. Components execute only once + // (during RSC rendering), eliminating the double data fetching that + // the old page probe caused. + const _rscReader = rscStream.getReader(); + const _chunks = []; + while (true) { + const { value, done } = await _rscReader.read(); + if (value) _chunks.push(value); + if (done) break; + } + + if (_caughtSpecialError) { + const specialResponse = await handleRenderError(_caughtSpecialError); + if (specialResponse) return specialResponse; + } + + _bufferedRscStream = new ReadableStream({ + start(controller) { + for (let _ci = 0; _ci < _chunks.length; _ci++) controller.enqueue(_chunks[_ci]); + controller.close(); + }, + }); + } + if (isRscRequest) { // Direct RSC stream response (for client-side navigation) // NOTE: Do NOT clear headers/navigation context here! @@ -2299,7 +2345,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const compileMs = __compileEnd !== undefined ? Math.round(__compileEnd - __reqStart) : -1; responseHeaders["x-vinext-timing"] = handlerStart + "," + compileMs + ",-1"; } - return new Response(rscStream, { status: _mwCtx.status || 200, headers: responseHeaders }); + return new Response(_bufferedRscStream, { status: _mwCtx.status || 200, headers: responseHeaders }); } // Collect font data from RSC environment before passing to SSR @@ -2324,7 +2370,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { let htmlStream; try { const ssrEntry = await import.meta.viteRsc.loadModule("ssr", "index"); - htmlStream = await ssrEntry.handleSsr(rscStream, _getNavigationContext(), fontData); + htmlStream = await ssrEntry.handleSsr(_bufferedRscStream, _getNavigationContext(), fontData); // Shell render complete; Suspense boundaries stream asynchronously if (process.env.NODE_ENV !== "production") __renderEnd = performance.now(); } catch (ssrErr) { diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index 023de0b0..02629a42 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -2338,46 +2338,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { if (_layoutProbeResult instanceof Response) return _layoutProbeResult; } - // Pre-render the page component to catch redirect()/notFound() thrown synchronously. - // Server Components are just functions — we can call PageComponent directly to detect - // these special throws before starting the RSC stream. - // - // For routes with a loading.tsx Suspense boundary, we skip awaiting async components. - // The Suspense boundary + rscOnError will handle redirect/notFound thrown during - // streaming, and blocking here would defeat streaming (the slow component's delay - // would be hit before the RSC stream even starts). - // - // Because this calls the component outside React's render cycle, hooks like use() - // trigger "Invalid hook call" console.error in dev. The module-level ALS patch - // suppresses the warning only within this request's execution context. - const _hasLoadingBoundary = !!(route.loading && route.loading.default); - const _pageProbeResult = await _suppressHookWarningAls.run(true, async () => { - try { - const testResult = PageComponent({ params }); - // If it's a promise (async component), only await if there's no loading boundary. - // With a loading boundary, the Suspense streaming pipeline handles async resolution - // and any redirect/notFound errors via rscOnError. - if (testResult && typeof testResult === "object" && typeof testResult.then === "function") { - if (!_hasLoadingBoundary) { - await testResult; - } else { - // Suppress unhandled promise rejection — with a loading boundary, - // redirect/notFound errors are handled by rscOnError during streaming. - testResult.catch(() => {}); - } - } - } catch (preRenderErr) { - const specialResponse = await handleRenderError(preRenderErr); - if (specialResponse) return specialResponse; - // Non-special errors from the pre-render test are expected (e.g. use() hook - // fails outside React's render cycle, client references can't execute on server). - // Only redirect/notFound/forbidden/unauthorized are actionable here — other - // errors will be properly caught during actual RSC/SSR rendering below. - } - return null; - }); - if (_pageProbeResult instanceof Response) return _pageProbeResult; - // Mark end of compile phase: route matching, middleware, tree building are done. if (process.env.NODE_ENV !== "production") __compileEnd = performance.now(); @@ -2385,9 +2345,29 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // Track non-navigation RSC errors so we can detect when the in-tree global // ErrorBoundary catches during SSR (producing double /) and // re-render with renderErrorBoundaryPage (which skips layouts for global-error). + // + // Also track the first redirect/notFound/forbidden/unauthorized error thrown + // during rendering. Previously, a "page probe" called the page component + // outside React's render cycle to detect these throws before streaming. + // That caused double data fetching and ~2x CPU time. Instead, we now + // buffer the RSC stream and check onError — components execute only once + // during the actual RSC render. let _rscErrorForRerender = null; + let _caughtSpecialError = null; const _baseOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); const onRenderError = function(error, requestInfo, errorContext) { + // Capture the first redirect/notFound/forbidden/unauthorized for + // stream-buffered detection (replaces the old page probe). + if (!_caughtSpecialError && error && typeof error === "object" && "digest" in error) { + const digest = String(error.digest); + if ( + digest.startsWith("NEXT_REDIRECT;") || + digest === "NEXT_NOT_FOUND" || + digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;") + ) { + _caughtSpecialError = error; + } + } if (!(error && typeof error === "object" && "digest" in error)) { _rscErrorForRerender = error; } @@ -2395,6 +2375,72 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }; const rscStream = renderToReadableStream(element, { onError: onRenderError }); + // Detect page-level redirect/notFound/forbidden/unauthorized errors before + // committing to the HTTP response status code. Strategy varies by page type: + // + // 1. Routes WITH loading.tsx (Suspense): stream directly. Errors inside the + // Suspense boundary flow through the RSC stream for client-side handling. + // + // 2. SYNC pages (no loading.tsx): a lightweight sync-only probe catches + // redirect/notFound thrown synchronously by the page function. This is + // cheap — sync server components do no data fetching. The probe result + // is discarded; only the thrown error matters. + // + // 3. ASYNC pages (no loading.tsx): buffer the entire RSC stream to catch + // redirect/notFound thrown after await (e.g. fetch then notFound). + // This is the key optimization — the old "page probe" awaited the full + // async component, causing every data fetch to run twice. Buffering the + // stream instead means components execute only once. Latency is the same + // because without Suspense, nothing can stream until all async work is done. + const _hasLoadingBoundary = !!(route.loading && route.loading.default); + const _isAsyncPage = PageComponent.constructor?.name === "AsyncFunction"; + let _bufferedRscStream; + + if (_hasLoadingBoundary) { + // Suspense route: stream directly. Errors inside the Suspense boundary + // (loading.tsx) flow through the RSC stream as error references. + _bufferedRscStream = rscStream; + } else if (!_isAsyncPage) { + // Sync page: lightweight probe catches sync redirect/notFound/forbidden/ + // unauthorized from the page function itself. Errors from async children + // inside inline boundaries flow through the stream as before. + const _syncProbeResult = await _suppressHookWarningAls.run(true, async () => { + try { + PageComponent({ params }); + } catch (syncErr) { + const specialResponse = await handleRenderError(syncErr); + if (specialResponse) return specialResponse; + } + return null; + }); + if (_syncProbeResult instanceof Response) return _syncProbeResult; + _bufferedRscStream = rscStream; + } else { + // Async page without Suspense: buffer the entire RSC stream to catch + // redirect/notFound thrown after await. Components execute only once + // (during RSC rendering), eliminating the double data fetching that + // the old page probe caused. + const _rscReader = rscStream.getReader(); + const _chunks = []; + while (true) { + const { value, done } = await _rscReader.read(); + if (value) _chunks.push(value); + if (done) break; + } + + if (_caughtSpecialError) { + const specialResponse = await handleRenderError(_caughtSpecialError); + if (specialResponse) return specialResponse; + } + + _bufferedRscStream = new ReadableStream({ + start(controller) { + for (let _ci = 0; _ci < _chunks.length; _ci++) controller.enqueue(_chunks[_ci]); + controller.close(); + }, + }); + } + if (isRscRequest) { // Direct RSC stream response (for client-side navigation) // NOTE: Do NOT clear headers/navigation context here! @@ -2460,7 +2506,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const compileMs = __compileEnd !== undefined ? Math.round(__compileEnd - __reqStart) : -1; responseHeaders["x-vinext-timing"] = handlerStart + "," + compileMs + ",-1"; } - return new Response(rscStream, { status: _mwCtx.status || 200, headers: responseHeaders }); + return new Response(_bufferedRscStream, { status: _mwCtx.status || 200, headers: responseHeaders }); } // Collect font data from RSC environment before passing to SSR @@ -2485,7 +2531,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { let htmlStream; try { const ssrEntry = await import.meta.viteRsc.loadModule("ssr", "index"); - htmlStream = await ssrEntry.handleSsr(rscStream, _getNavigationContext(), fontData); + htmlStream = await ssrEntry.handleSsr(_bufferedRscStream, _getNavigationContext(), fontData); // Shell render complete; Suspense boundaries stream asynchronously if (process.env.NODE_ENV !== "production") __renderEnd = performance.now(); } catch (ssrErr) { @@ -4635,46 +4681,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { if (_layoutProbeResult instanceof Response) return _layoutProbeResult; } - // Pre-render the page component to catch redirect()/notFound() thrown synchronously. - // Server Components are just functions — we can call PageComponent directly to detect - // these special throws before starting the RSC stream. - // - // For routes with a loading.tsx Suspense boundary, we skip awaiting async components. - // The Suspense boundary + rscOnError will handle redirect/notFound thrown during - // streaming, and blocking here would defeat streaming (the slow component's delay - // would be hit before the RSC stream even starts). - // - // Because this calls the component outside React's render cycle, hooks like use() - // trigger "Invalid hook call" console.error in dev. The module-level ALS patch - // suppresses the warning only within this request's execution context. - const _hasLoadingBoundary = !!(route.loading && route.loading.default); - const _pageProbeResult = await _suppressHookWarningAls.run(true, async () => { - try { - const testResult = PageComponent({ params }); - // If it's a promise (async component), only await if there's no loading boundary. - // With a loading boundary, the Suspense streaming pipeline handles async resolution - // and any redirect/notFound errors via rscOnError. - if (testResult && typeof testResult === "object" && typeof testResult.then === "function") { - if (!_hasLoadingBoundary) { - await testResult; - } else { - // Suppress unhandled promise rejection — with a loading boundary, - // redirect/notFound errors are handled by rscOnError during streaming. - testResult.catch(() => {}); - } - } - } catch (preRenderErr) { - const specialResponse = await handleRenderError(preRenderErr); - if (specialResponse) return specialResponse; - // Non-special errors from the pre-render test are expected (e.g. use() hook - // fails outside React's render cycle, client references can't execute on server). - // Only redirect/notFound/forbidden/unauthorized are actionable here — other - // errors will be properly caught during actual RSC/SSR rendering below. - } - return null; - }); - if (_pageProbeResult instanceof Response) return _pageProbeResult; - // Mark end of compile phase: route matching, middleware, tree building are done. if (process.env.NODE_ENV !== "production") __compileEnd = performance.now(); @@ -4682,9 +4688,29 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // Track non-navigation RSC errors so we can detect when the in-tree global // ErrorBoundary catches during SSR (producing double /) and // re-render with renderErrorBoundaryPage (which skips layouts for global-error). + // + // Also track the first redirect/notFound/forbidden/unauthorized error thrown + // during rendering. Previously, a "page probe" called the page component + // outside React's render cycle to detect these throws before streaming. + // That caused double data fetching and ~2x CPU time. Instead, we now + // buffer the RSC stream and check onError — components execute only once + // during the actual RSC render. let _rscErrorForRerender = null; + let _caughtSpecialError = null; const _baseOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); const onRenderError = function(error, requestInfo, errorContext) { + // Capture the first redirect/notFound/forbidden/unauthorized for + // stream-buffered detection (replaces the old page probe). + if (!_caughtSpecialError && error && typeof error === "object" && "digest" in error) { + const digest = String(error.digest); + if ( + digest.startsWith("NEXT_REDIRECT;") || + digest === "NEXT_NOT_FOUND" || + digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;") + ) { + _caughtSpecialError = error; + } + } if (!(error && typeof error === "object" && "digest" in error)) { _rscErrorForRerender = error; } @@ -4692,6 +4718,72 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }; const rscStream = renderToReadableStream(element, { onError: onRenderError }); + // Detect page-level redirect/notFound/forbidden/unauthorized errors before + // committing to the HTTP response status code. Strategy varies by page type: + // + // 1. Routes WITH loading.tsx (Suspense): stream directly. Errors inside the + // Suspense boundary flow through the RSC stream for client-side handling. + // + // 2. SYNC pages (no loading.tsx): a lightweight sync-only probe catches + // redirect/notFound thrown synchronously by the page function. This is + // cheap — sync server components do no data fetching. The probe result + // is discarded; only the thrown error matters. + // + // 3. ASYNC pages (no loading.tsx): buffer the entire RSC stream to catch + // redirect/notFound thrown after await (e.g. fetch then notFound). + // This is the key optimization — the old "page probe" awaited the full + // async component, causing every data fetch to run twice. Buffering the + // stream instead means components execute only once. Latency is the same + // because without Suspense, nothing can stream until all async work is done. + const _hasLoadingBoundary = !!(route.loading && route.loading.default); + const _isAsyncPage = PageComponent.constructor?.name === "AsyncFunction"; + let _bufferedRscStream; + + if (_hasLoadingBoundary) { + // Suspense route: stream directly. Errors inside the Suspense boundary + // (loading.tsx) flow through the RSC stream as error references. + _bufferedRscStream = rscStream; + } else if (!_isAsyncPage) { + // Sync page: lightweight probe catches sync redirect/notFound/forbidden/ + // unauthorized from the page function itself. Errors from async children + // inside inline boundaries flow through the stream as before. + const _syncProbeResult = await _suppressHookWarningAls.run(true, async () => { + try { + PageComponent({ params }); + } catch (syncErr) { + const specialResponse = await handleRenderError(syncErr); + if (specialResponse) return specialResponse; + } + return null; + }); + if (_syncProbeResult instanceof Response) return _syncProbeResult; + _bufferedRscStream = rscStream; + } else { + // Async page without Suspense: buffer the entire RSC stream to catch + // redirect/notFound thrown after await. Components execute only once + // (during RSC rendering), eliminating the double data fetching that + // the old page probe caused. + const _rscReader = rscStream.getReader(); + const _chunks = []; + while (true) { + const { value, done } = await _rscReader.read(); + if (value) _chunks.push(value); + if (done) break; + } + + if (_caughtSpecialError) { + const specialResponse = await handleRenderError(_caughtSpecialError); + if (specialResponse) return specialResponse; + } + + _bufferedRscStream = new ReadableStream({ + start(controller) { + for (let _ci = 0; _ci < _chunks.length; _ci++) controller.enqueue(_chunks[_ci]); + controller.close(); + }, + }); + } + if (isRscRequest) { // Direct RSC stream response (for client-side navigation) // NOTE: Do NOT clear headers/navigation context here! @@ -4757,7 +4849,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const compileMs = __compileEnd !== undefined ? Math.round(__compileEnd - __reqStart) : -1; responseHeaders["x-vinext-timing"] = handlerStart + "," + compileMs + ",-1"; } - return new Response(rscStream, { status: _mwCtx.status || 200, headers: responseHeaders }); + return new Response(_bufferedRscStream, { status: _mwCtx.status || 200, headers: responseHeaders }); } // Collect font data from RSC environment before passing to SSR @@ -4782,7 +4874,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { let htmlStream; try { const ssrEntry = await import.meta.viteRsc.loadModule("ssr", "index"); - htmlStream = await ssrEntry.handleSsr(rscStream, _getNavigationContext(), fontData); + htmlStream = await ssrEntry.handleSsr(_bufferedRscStream, _getNavigationContext(), fontData); // Shell render complete; Suspense boundaries stream asynchronously if (process.env.NODE_ENV !== "production") __renderEnd = performance.now(); } catch (ssrErr) { @@ -6959,46 +7051,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { if (_layoutProbeResult instanceof Response) return _layoutProbeResult; } - // Pre-render the page component to catch redirect()/notFound() thrown synchronously. - // Server Components are just functions — we can call PageComponent directly to detect - // these special throws before starting the RSC stream. - // - // For routes with a loading.tsx Suspense boundary, we skip awaiting async components. - // The Suspense boundary + rscOnError will handle redirect/notFound thrown during - // streaming, and blocking here would defeat streaming (the slow component's delay - // would be hit before the RSC stream even starts). - // - // Because this calls the component outside React's render cycle, hooks like use() - // trigger "Invalid hook call" console.error in dev. The module-level ALS patch - // suppresses the warning only within this request's execution context. - const _hasLoadingBoundary = !!(route.loading && route.loading.default); - const _pageProbeResult = await _suppressHookWarningAls.run(true, async () => { - try { - const testResult = PageComponent({ params }); - // If it's a promise (async component), only await if there's no loading boundary. - // With a loading boundary, the Suspense streaming pipeline handles async resolution - // and any redirect/notFound errors via rscOnError. - if (testResult && typeof testResult === "object" && typeof testResult.then === "function") { - if (!_hasLoadingBoundary) { - await testResult; - } else { - // Suppress unhandled promise rejection — with a loading boundary, - // redirect/notFound errors are handled by rscOnError during streaming. - testResult.catch(() => {}); - } - } - } catch (preRenderErr) { - const specialResponse = await handleRenderError(preRenderErr); - if (specialResponse) return specialResponse; - // Non-special errors from the pre-render test are expected (e.g. use() hook - // fails outside React's render cycle, client references can't execute on server). - // Only redirect/notFound/forbidden/unauthorized are actionable here — other - // errors will be properly caught during actual RSC/SSR rendering below. - } - return null; - }); - if (_pageProbeResult instanceof Response) return _pageProbeResult; - // Mark end of compile phase: route matching, middleware, tree building are done. if (process.env.NODE_ENV !== "production") __compileEnd = performance.now(); @@ -7006,9 +7058,29 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // Track non-navigation RSC errors so we can detect when the in-tree global // ErrorBoundary catches during SSR (producing double /) and // re-render with renderErrorBoundaryPage (which skips layouts for global-error). + // + // Also track the first redirect/notFound/forbidden/unauthorized error thrown + // during rendering. Previously, a "page probe" called the page component + // outside React's render cycle to detect these throws before streaming. + // That caused double data fetching and ~2x CPU time. Instead, we now + // buffer the RSC stream and check onError — components execute only once + // during the actual RSC render. let _rscErrorForRerender = null; + let _caughtSpecialError = null; const _baseOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); const onRenderError = function(error, requestInfo, errorContext) { + // Capture the first redirect/notFound/forbidden/unauthorized for + // stream-buffered detection (replaces the old page probe). + if (!_caughtSpecialError && error && typeof error === "object" && "digest" in error) { + const digest = String(error.digest); + if ( + digest.startsWith("NEXT_REDIRECT;") || + digest === "NEXT_NOT_FOUND" || + digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;") + ) { + _caughtSpecialError = error; + } + } if (!(error && typeof error === "object" && "digest" in error)) { _rscErrorForRerender = error; } @@ -7016,6 +7088,72 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }; const rscStream = renderToReadableStream(element, { onError: onRenderError }); + // Detect page-level redirect/notFound/forbidden/unauthorized errors before + // committing to the HTTP response status code. Strategy varies by page type: + // + // 1. Routes WITH loading.tsx (Suspense): stream directly. Errors inside the + // Suspense boundary flow through the RSC stream for client-side handling. + // + // 2. SYNC pages (no loading.tsx): a lightweight sync-only probe catches + // redirect/notFound thrown synchronously by the page function. This is + // cheap — sync server components do no data fetching. The probe result + // is discarded; only the thrown error matters. + // + // 3. ASYNC pages (no loading.tsx): buffer the entire RSC stream to catch + // redirect/notFound thrown after await (e.g. fetch then notFound). + // This is the key optimization — the old "page probe" awaited the full + // async component, causing every data fetch to run twice. Buffering the + // stream instead means components execute only once. Latency is the same + // because without Suspense, nothing can stream until all async work is done. + const _hasLoadingBoundary = !!(route.loading && route.loading.default); + const _isAsyncPage = PageComponent.constructor?.name === "AsyncFunction"; + let _bufferedRscStream; + + if (_hasLoadingBoundary) { + // Suspense route: stream directly. Errors inside the Suspense boundary + // (loading.tsx) flow through the RSC stream as error references. + _bufferedRscStream = rscStream; + } else if (!_isAsyncPage) { + // Sync page: lightweight probe catches sync redirect/notFound/forbidden/ + // unauthorized from the page function itself. Errors from async children + // inside inline boundaries flow through the stream as before. + const _syncProbeResult = await _suppressHookWarningAls.run(true, async () => { + try { + PageComponent({ params }); + } catch (syncErr) { + const specialResponse = await handleRenderError(syncErr); + if (specialResponse) return specialResponse; + } + return null; + }); + if (_syncProbeResult instanceof Response) return _syncProbeResult; + _bufferedRscStream = rscStream; + } else { + // Async page without Suspense: buffer the entire RSC stream to catch + // redirect/notFound thrown after await. Components execute only once + // (during RSC rendering), eliminating the double data fetching that + // the old page probe caused. + const _rscReader = rscStream.getReader(); + const _chunks = []; + while (true) { + const { value, done } = await _rscReader.read(); + if (value) _chunks.push(value); + if (done) break; + } + + if (_caughtSpecialError) { + const specialResponse = await handleRenderError(_caughtSpecialError); + if (specialResponse) return specialResponse; + } + + _bufferedRscStream = new ReadableStream({ + start(controller) { + for (let _ci = 0; _ci < _chunks.length; _ci++) controller.enqueue(_chunks[_ci]); + controller.close(); + }, + }); + } + if (isRscRequest) { // Direct RSC stream response (for client-side navigation) // NOTE: Do NOT clear headers/navigation context here! @@ -7081,7 +7219,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const compileMs = __compileEnd !== undefined ? Math.round(__compileEnd - __reqStart) : -1; responseHeaders["x-vinext-timing"] = handlerStart + "," + compileMs + ",-1"; } - return new Response(rscStream, { status: _mwCtx.status || 200, headers: responseHeaders }); + return new Response(_bufferedRscStream, { status: _mwCtx.status || 200, headers: responseHeaders }); } // Collect font data from RSC environment before passing to SSR @@ -7106,7 +7244,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { let htmlStream; try { const ssrEntry = await import.meta.viteRsc.loadModule("ssr", "index"); - htmlStream = await ssrEntry.handleSsr(rscStream, _getNavigationContext(), fontData); + htmlStream = await ssrEntry.handleSsr(_bufferedRscStream, _getNavigationContext(), fontData); // Shell render complete; Suspense boundaries stream asynchronously if (process.env.NODE_ENV !== "production") __renderEnd = performance.now(); } catch (ssrErr) { @@ -9293,46 +9431,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { if (_layoutProbeResult instanceof Response) return _layoutProbeResult; } - // Pre-render the page component to catch redirect()/notFound() thrown synchronously. - // Server Components are just functions — we can call PageComponent directly to detect - // these special throws before starting the RSC stream. - // - // For routes with a loading.tsx Suspense boundary, we skip awaiting async components. - // The Suspense boundary + rscOnError will handle redirect/notFound thrown during - // streaming, and blocking here would defeat streaming (the slow component's delay - // would be hit before the RSC stream even starts). - // - // Because this calls the component outside React's render cycle, hooks like use() - // trigger "Invalid hook call" console.error in dev. The module-level ALS patch - // suppresses the warning only within this request's execution context. - const _hasLoadingBoundary = !!(route.loading && route.loading.default); - const _pageProbeResult = await _suppressHookWarningAls.run(true, async () => { - try { - const testResult = PageComponent({ params }); - // If it's a promise (async component), only await if there's no loading boundary. - // With a loading boundary, the Suspense streaming pipeline handles async resolution - // and any redirect/notFound errors via rscOnError. - if (testResult && typeof testResult === "object" && typeof testResult.then === "function") { - if (!_hasLoadingBoundary) { - await testResult; - } else { - // Suppress unhandled promise rejection — with a loading boundary, - // redirect/notFound errors are handled by rscOnError during streaming. - testResult.catch(() => {}); - } - } - } catch (preRenderErr) { - const specialResponse = await handleRenderError(preRenderErr); - if (specialResponse) return specialResponse; - // Non-special errors from the pre-render test are expected (e.g. use() hook - // fails outside React's render cycle, client references can't execute on server). - // Only redirect/notFound/forbidden/unauthorized are actionable here — other - // errors will be properly caught during actual RSC/SSR rendering below. - } - return null; - }); - if (_pageProbeResult instanceof Response) return _pageProbeResult; - // Mark end of compile phase: route matching, middleware, tree building are done. if (process.env.NODE_ENV !== "production") __compileEnd = performance.now(); @@ -9340,9 +9438,29 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // Track non-navigation RSC errors so we can detect when the in-tree global // ErrorBoundary catches during SSR (producing double /) and // re-render with renderErrorBoundaryPage (which skips layouts for global-error). + // + // Also track the first redirect/notFound/forbidden/unauthorized error thrown + // during rendering. Previously, a "page probe" called the page component + // outside React's render cycle to detect these throws before streaming. + // That caused double data fetching and ~2x CPU time. Instead, we now + // buffer the RSC stream and check onError — components execute only once + // during the actual RSC render. let _rscErrorForRerender = null; + let _caughtSpecialError = null; const _baseOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); const onRenderError = function(error, requestInfo, errorContext) { + // Capture the first redirect/notFound/forbidden/unauthorized for + // stream-buffered detection (replaces the old page probe). + if (!_caughtSpecialError && error && typeof error === "object" && "digest" in error) { + const digest = String(error.digest); + if ( + digest.startsWith("NEXT_REDIRECT;") || + digest === "NEXT_NOT_FOUND" || + digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;") + ) { + _caughtSpecialError = error; + } + } if (!(error && typeof error === "object" && "digest" in error)) { _rscErrorForRerender = error; } @@ -9350,6 +9468,72 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }; const rscStream = renderToReadableStream(element, { onError: onRenderError }); + // Detect page-level redirect/notFound/forbidden/unauthorized errors before + // committing to the HTTP response status code. Strategy varies by page type: + // + // 1. Routes WITH loading.tsx (Suspense): stream directly. Errors inside the + // Suspense boundary flow through the RSC stream for client-side handling. + // + // 2. SYNC pages (no loading.tsx): a lightweight sync-only probe catches + // redirect/notFound thrown synchronously by the page function. This is + // cheap — sync server components do no data fetching. The probe result + // is discarded; only the thrown error matters. + // + // 3. ASYNC pages (no loading.tsx): buffer the entire RSC stream to catch + // redirect/notFound thrown after await (e.g. fetch then notFound). + // This is the key optimization — the old "page probe" awaited the full + // async component, causing every data fetch to run twice. Buffering the + // stream instead means components execute only once. Latency is the same + // because without Suspense, nothing can stream until all async work is done. + const _hasLoadingBoundary = !!(route.loading && route.loading.default); + const _isAsyncPage = PageComponent.constructor?.name === "AsyncFunction"; + let _bufferedRscStream; + + if (_hasLoadingBoundary) { + // Suspense route: stream directly. Errors inside the Suspense boundary + // (loading.tsx) flow through the RSC stream as error references. + _bufferedRscStream = rscStream; + } else if (!_isAsyncPage) { + // Sync page: lightweight probe catches sync redirect/notFound/forbidden/ + // unauthorized from the page function itself. Errors from async children + // inside inline boundaries flow through the stream as before. + const _syncProbeResult = await _suppressHookWarningAls.run(true, async () => { + try { + PageComponent({ params }); + } catch (syncErr) { + const specialResponse = await handleRenderError(syncErr); + if (specialResponse) return specialResponse; + } + return null; + }); + if (_syncProbeResult instanceof Response) return _syncProbeResult; + _bufferedRscStream = rscStream; + } else { + // Async page without Suspense: buffer the entire RSC stream to catch + // redirect/notFound thrown after await. Components execute only once + // (during RSC rendering), eliminating the double data fetching that + // the old page probe caused. + const _rscReader = rscStream.getReader(); + const _chunks = []; + while (true) { + const { value, done } = await _rscReader.read(); + if (value) _chunks.push(value); + if (done) break; + } + + if (_caughtSpecialError) { + const specialResponse = await handleRenderError(_caughtSpecialError); + if (specialResponse) return specialResponse; + } + + _bufferedRscStream = new ReadableStream({ + start(controller) { + for (let _ci = 0; _ci < _chunks.length; _ci++) controller.enqueue(_chunks[_ci]); + controller.close(); + }, + }); + } + if (isRscRequest) { // Direct RSC stream response (for client-side navigation) // NOTE: Do NOT clear headers/navigation context here! @@ -9415,7 +9599,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const compileMs = __compileEnd !== undefined ? Math.round(__compileEnd - __reqStart) : -1; responseHeaders["x-vinext-timing"] = handlerStart + "," + compileMs + ",-1"; } - return new Response(rscStream, { status: _mwCtx.status || 200, headers: responseHeaders }); + return new Response(_bufferedRscStream, { status: _mwCtx.status || 200, headers: responseHeaders }); } // Collect font data from RSC environment before passing to SSR @@ -9440,7 +9624,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { let htmlStream; try { const ssrEntry = await import.meta.viteRsc.loadModule("ssr", "index"); - htmlStream = await ssrEntry.handleSsr(rscStream, _getNavigationContext(), fontData); + htmlStream = await ssrEntry.handleSsr(_bufferedRscStream, _getNavigationContext(), fontData); // Shell render complete; Suspense boundaries stream asynchronously if (process.env.NODE_ENV !== "production") __renderEnd = performance.now(); } catch (ssrErr) { @@ -11594,46 +11778,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { if (_layoutProbeResult instanceof Response) return _layoutProbeResult; } - // Pre-render the page component to catch redirect()/notFound() thrown synchronously. - // Server Components are just functions — we can call PageComponent directly to detect - // these special throws before starting the RSC stream. - // - // For routes with a loading.tsx Suspense boundary, we skip awaiting async components. - // The Suspense boundary + rscOnError will handle redirect/notFound thrown during - // streaming, and blocking here would defeat streaming (the slow component's delay - // would be hit before the RSC stream even starts). - // - // Because this calls the component outside React's render cycle, hooks like use() - // trigger "Invalid hook call" console.error in dev. The module-level ALS patch - // suppresses the warning only within this request's execution context. - const _hasLoadingBoundary = !!(route.loading && route.loading.default); - const _pageProbeResult = await _suppressHookWarningAls.run(true, async () => { - try { - const testResult = PageComponent({ params }); - // If it's a promise (async component), only await if there's no loading boundary. - // With a loading boundary, the Suspense streaming pipeline handles async resolution - // and any redirect/notFound errors via rscOnError. - if (testResult && typeof testResult === "object" && typeof testResult.then === "function") { - if (!_hasLoadingBoundary) { - await testResult; - } else { - // Suppress unhandled promise rejection — with a loading boundary, - // redirect/notFound errors are handled by rscOnError during streaming. - testResult.catch(() => {}); - } - } - } catch (preRenderErr) { - const specialResponse = await handleRenderError(preRenderErr); - if (specialResponse) return specialResponse; - // Non-special errors from the pre-render test are expected (e.g. use() hook - // fails outside React's render cycle, client references can't execute on server). - // Only redirect/notFound/forbidden/unauthorized are actionable here — other - // errors will be properly caught during actual RSC/SSR rendering below. - } - return null; - }); - if (_pageProbeResult instanceof Response) return _pageProbeResult; - // Mark end of compile phase: route matching, middleware, tree building are done. if (process.env.NODE_ENV !== "production") __compileEnd = performance.now(); @@ -11641,9 +11785,29 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // Track non-navigation RSC errors so we can detect when the in-tree global // ErrorBoundary catches during SSR (producing double /) and // re-render with renderErrorBoundaryPage (which skips layouts for global-error). + // + // Also track the first redirect/notFound/forbidden/unauthorized error thrown + // during rendering. Previously, a "page probe" called the page component + // outside React's render cycle to detect these throws before streaming. + // That caused double data fetching and ~2x CPU time. Instead, we now + // buffer the RSC stream and check onError — components execute only once + // during the actual RSC render. let _rscErrorForRerender = null; + let _caughtSpecialError = null; const _baseOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); const onRenderError = function(error, requestInfo, errorContext) { + // Capture the first redirect/notFound/forbidden/unauthorized for + // stream-buffered detection (replaces the old page probe). + if (!_caughtSpecialError && error && typeof error === "object" && "digest" in error) { + const digest = String(error.digest); + if ( + digest.startsWith("NEXT_REDIRECT;") || + digest === "NEXT_NOT_FOUND" || + digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;") + ) { + _caughtSpecialError = error; + } + } if (!(error && typeof error === "object" && "digest" in error)) { _rscErrorForRerender = error; } @@ -11651,6 +11815,72 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }; const rscStream = renderToReadableStream(element, { onError: onRenderError }); + // Detect page-level redirect/notFound/forbidden/unauthorized errors before + // committing to the HTTP response status code. Strategy varies by page type: + // + // 1. Routes WITH loading.tsx (Suspense): stream directly. Errors inside the + // Suspense boundary flow through the RSC stream for client-side handling. + // + // 2. SYNC pages (no loading.tsx): a lightweight sync-only probe catches + // redirect/notFound thrown synchronously by the page function. This is + // cheap — sync server components do no data fetching. The probe result + // is discarded; only the thrown error matters. + // + // 3. ASYNC pages (no loading.tsx): buffer the entire RSC stream to catch + // redirect/notFound thrown after await (e.g. fetch then notFound). + // This is the key optimization — the old "page probe" awaited the full + // async component, causing every data fetch to run twice. Buffering the + // stream instead means components execute only once. Latency is the same + // because without Suspense, nothing can stream until all async work is done. + const _hasLoadingBoundary = !!(route.loading && route.loading.default); + const _isAsyncPage = PageComponent.constructor?.name === "AsyncFunction"; + let _bufferedRscStream; + + if (_hasLoadingBoundary) { + // Suspense route: stream directly. Errors inside the Suspense boundary + // (loading.tsx) flow through the RSC stream as error references. + _bufferedRscStream = rscStream; + } else if (!_isAsyncPage) { + // Sync page: lightweight probe catches sync redirect/notFound/forbidden/ + // unauthorized from the page function itself. Errors from async children + // inside inline boundaries flow through the stream as before. + const _syncProbeResult = await _suppressHookWarningAls.run(true, async () => { + try { + PageComponent({ params }); + } catch (syncErr) { + const specialResponse = await handleRenderError(syncErr); + if (specialResponse) return specialResponse; + } + return null; + }); + if (_syncProbeResult instanceof Response) return _syncProbeResult; + _bufferedRscStream = rscStream; + } else { + // Async page without Suspense: buffer the entire RSC stream to catch + // redirect/notFound thrown after await. Components execute only once + // (during RSC rendering), eliminating the double data fetching that + // the old page probe caused. + const _rscReader = rscStream.getReader(); + const _chunks = []; + while (true) { + const { value, done } = await _rscReader.read(); + if (value) _chunks.push(value); + if (done) break; + } + + if (_caughtSpecialError) { + const specialResponse = await handleRenderError(_caughtSpecialError); + if (specialResponse) return specialResponse; + } + + _bufferedRscStream = new ReadableStream({ + start(controller) { + for (let _ci = 0; _ci < _chunks.length; _ci++) controller.enqueue(_chunks[_ci]); + controller.close(); + }, + }); + } + if (isRscRequest) { // Direct RSC stream response (for client-side navigation) // NOTE: Do NOT clear headers/navigation context here! @@ -11716,7 +11946,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const compileMs = __compileEnd !== undefined ? Math.round(__compileEnd - __reqStart) : -1; responseHeaders["x-vinext-timing"] = handlerStart + "," + compileMs + ",-1"; } - return new Response(rscStream, { status: _mwCtx.status || 200, headers: responseHeaders }); + return new Response(_bufferedRscStream, { status: _mwCtx.status || 200, headers: responseHeaders }); } // Collect font data from RSC environment before passing to SSR @@ -11741,7 +11971,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { let htmlStream; try { const ssrEntry = await import.meta.viteRsc.loadModule("ssr", "index"); - htmlStream = await ssrEntry.handleSsr(rscStream, _getNavigationContext(), fontData); + htmlStream = await ssrEntry.handleSsr(_bufferedRscStream, _getNavigationContext(), fontData); // Shell render complete; Suspense boundaries stream asynchronously if (process.env.NODE_ENV !== "production") __renderEnd = performance.now(); } catch (ssrErr) { @@ -14008,46 +14238,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { if (_layoutProbeResult instanceof Response) return _layoutProbeResult; } - // Pre-render the page component to catch redirect()/notFound() thrown synchronously. - // Server Components are just functions — we can call PageComponent directly to detect - // these special throws before starting the RSC stream. - // - // For routes with a loading.tsx Suspense boundary, we skip awaiting async components. - // The Suspense boundary + rscOnError will handle redirect/notFound thrown during - // streaming, and blocking here would defeat streaming (the slow component's delay - // would be hit before the RSC stream even starts). - // - // Because this calls the component outside React's render cycle, hooks like use() - // trigger "Invalid hook call" console.error in dev. The module-level ALS patch - // suppresses the warning only within this request's execution context. - const _hasLoadingBoundary = !!(route.loading && route.loading.default); - const _pageProbeResult = await _suppressHookWarningAls.run(true, async () => { - try { - const testResult = PageComponent({ params }); - // If it's a promise (async component), only await if there's no loading boundary. - // With a loading boundary, the Suspense streaming pipeline handles async resolution - // and any redirect/notFound errors via rscOnError. - if (testResult && typeof testResult === "object" && typeof testResult.then === "function") { - if (!_hasLoadingBoundary) { - await testResult; - } else { - // Suppress unhandled promise rejection — with a loading boundary, - // redirect/notFound errors are handled by rscOnError during streaming. - testResult.catch(() => {}); - } - } - } catch (preRenderErr) { - const specialResponse = await handleRenderError(preRenderErr); - if (specialResponse) return specialResponse; - // Non-special errors from the pre-render test are expected (e.g. use() hook - // fails outside React's render cycle, client references can't execute on server). - // Only redirect/notFound/forbidden/unauthorized are actionable here — other - // errors will be properly caught during actual RSC/SSR rendering below. - } - return null; - }); - if (_pageProbeResult instanceof Response) return _pageProbeResult; - // Mark end of compile phase: route matching, middleware, tree building are done. if (process.env.NODE_ENV !== "production") __compileEnd = performance.now(); @@ -14055,9 +14245,29 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // Track non-navigation RSC errors so we can detect when the in-tree global // ErrorBoundary catches during SSR (producing double /) and // re-render with renderErrorBoundaryPage (which skips layouts for global-error). + // + // Also track the first redirect/notFound/forbidden/unauthorized error thrown + // during rendering. Previously, a "page probe" called the page component + // outside React's render cycle to detect these throws before streaming. + // That caused double data fetching and ~2x CPU time. Instead, we now + // buffer the RSC stream and check onError — components execute only once + // during the actual RSC render. let _rscErrorForRerender = null; + let _caughtSpecialError = null; const _baseOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); const onRenderError = function(error, requestInfo, errorContext) { + // Capture the first redirect/notFound/forbidden/unauthorized for + // stream-buffered detection (replaces the old page probe). + if (!_caughtSpecialError && error && typeof error === "object" && "digest" in error) { + const digest = String(error.digest); + if ( + digest.startsWith("NEXT_REDIRECT;") || + digest === "NEXT_NOT_FOUND" || + digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;") + ) { + _caughtSpecialError = error; + } + } if (!(error && typeof error === "object" && "digest" in error)) { _rscErrorForRerender = error; } @@ -14065,6 +14275,72 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }; const rscStream = renderToReadableStream(element, { onError: onRenderError }); + // Detect page-level redirect/notFound/forbidden/unauthorized errors before + // committing to the HTTP response status code. Strategy varies by page type: + // + // 1. Routes WITH loading.tsx (Suspense): stream directly. Errors inside the + // Suspense boundary flow through the RSC stream for client-side handling. + // + // 2. SYNC pages (no loading.tsx): a lightweight sync-only probe catches + // redirect/notFound thrown synchronously by the page function. This is + // cheap — sync server components do no data fetching. The probe result + // is discarded; only the thrown error matters. + // + // 3. ASYNC pages (no loading.tsx): buffer the entire RSC stream to catch + // redirect/notFound thrown after await (e.g. fetch then notFound). + // This is the key optimization — the old "page probe" awaited the full + // async component, causing every data fetch to run twice. Buffering the + // stream instead means components execute only once. Latency is the same + // because without Suspense, nothing can stream until all async work is done. + const _hasLoadingBoundary = !!(route.loading && route.loading.default); + const _isAsyncPage = PageComponent.constructor?.name === "AsyncFunction"; + let _bufferedRscStream; + + if (_hasLoadingBoundary) { + // Suspense route: stream directly. Errors inside the Suspense boundary + // (loading.tsx) flow through the RSC stream as error references. + _bufferedRscStream = rscStream; + } else if (!_isAsyncPage) { + // Sync page: lightweight probe catches sync redirect/notFound/forbidden/ + // unauthorized from the page function itself. Errors from async children + // inside inline boundaries flow through the stream as before. + const _syncProbeResult = await _suppressHookWarningAls.run(true, async () => { + try { + PageComponent({ params }); + } catch (syncErr) { + const specialResponse = await handleRenderError(syncErr); + if (specialResponse) return specialResponse; + } + return null; + }); + if (_syncProbeResult instanceof Response) return _syncProbeResult; + _bufferedRscStream = rscStream; + } else { + // Async page without Suspense: buffer the entire RSC stream to catch + // redirect/notFound thrown after await. Components execute only once + // (during RSC rendering), eliminating the double data fetching that + // the old page probe caused. + const _rscReader = rscStream.getReader(); + const _chunks = []; + while (true) { + const { value, done } = await _rscReader.read(); + if (value) _chunks.push(value); + if (done) break; + } + + if (_caughtSpecialError) { + const specialResponse = await handleRenderError(_caughtSpecialError); + if (specialResponse) return specialResponse; + } + + _bufferedRscStream = new ReadableStream({ + start(controller) { + for (let _ci = 0; _ci < _chunks.length; _ci++) controller.enqueue(_chunks[_ci]); + controller.close(); + }, + }); + } + if (isRscRequest) { // Direct RSC stream response (for client-side navigation) // NOTE: Do NOT clear headers/navigation context here! @@ -14130,7 +14406,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const compileMs = __compileEnd !== undefined ? Math.round(__compileEnd - __reqStart) : -1; responseHeaders["x-vinext-timing"] = handlerStart + "," + compileMs + ",-1"; } - return new Response(rscStream, { status: _mwCtx.status || 200, headers: responseHeaders }); + return new Response(_bufferedRscStream, { status: _mwCtx.status || 200, headers: responseHeaders }); } // Collect font data from RSC environment before passing to SSR @@ -14155,7 +14431,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { let htmlStream; try { const ssrEntry = await import.meta.viteRsc.loadModule("ssr", "index"); - htmlStream = await ssrEntry.handleSsr(rscStream, _getNavigationContext(), fontData); + htmlStream = await ssrEntry.handleSsr(_bufferedRscStream, _getNavigationContext(), fontData); // Shell render complete; Suspense boundaries stream asynchronously if (process.env.NODE_ENV !== "production") __renderEnd = performance.now(); } catch (ssrErr) {