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) {