From 1d338138a8857c753aa2a3943f8186231e721899 Mon Sep 17 00:00:00 2001 From: Steve Faulkner Date: Sat, 21 Mar 2026 15:29:55 -0500 Subject: [PATCH 1/2] refactor: extract app page probe runtime --- packages/vinext/src/entries/app-rsc-entry.ts | 160 ++- packages/vinext/src/server/app-page-probe.ts | 68 ++ .../entry-templates.test.ts.snap | 942 ++++++------------ tests/app-page-probe.test.ts | 158 +++ tests/app-router.test.ts | 6 +- 5 files changed, 603 insertions(+), 731 deletions(-) create mode 100644 packages/vinext/src/server/app-page-probe.ts create mode 100644 tests/app-page-probe.test.ts diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index 87aad16a..a564984c 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -59,6 +59,9 @@ const appPageResponsePath = fileURLToPath( const appPageExecutionPath = fileURLToPath( new URL("../server/app-page-execution.js", import.meta.url), ).replace(/\\/g, "/"); +const appPageProbePath = fileURLToPath( + new URL("../server/app-page-probe.js", import.meta.url), +).replace(/\\/g, "/"); const appPageBoundaryPath = fileURLToPath( new URL("../server/app-page-boundary.js", import.meta.url), ).replace(/\\/g, "/"); @@ -388,12 +391,13 @@ import { import { buildAppPageFontLinkHeader as __buildAppPageFontLinkHeader, buildAppPageSpecialErrorResponse as __buildAppPageSpecialErrorResponse, - probeAppPageComponent as __probeAppPageComponent, - probeAppPageLayouts as __probeAppPageLayouts, readAppPageTextStream as __readAppPageTextStream, resolveAppPageSpecialError as __resolveAppPageSpecialError, teeAppPageRscStreamForCapture as __teeAppPageRscStreamForCapture, } from ${JSON.stringify(appPageExecutionPath)}; +import { + probeAppPageBeforeRender as __probeAppPageBeforeRender, +} from ${JSON.stringify(appPageProbePath)}; import { renderAppPageBoundaryResponse as __renderAppPageBoundaryResponse, resolveAppPageErrorBoundary as __resolveAppPageErrorBoundary, @@ -2544,126 +2548,74 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // Note: CSS is automatically injected by @vitejs/plugin-rsc's // rscCssTransform — no manual loadCss() call needed. - // Helper: check if an error is a redirect/notFound/forbidden/unauthorized thrown by the navigation shim - async function handleRenderError(err) { - const specialError = __resolveAppPageSpecialError(err); - if (specialError) { + const _hasLoadingBoundary = !!(route.loading && route.loading.default); + const _asyncLayoutParams = makeThenableParams(params); + const __preRenderResponse = await __probeAppPageBeforeRender({ + hasLoadingBoundary: _hasLoadingBoundary, + layoutCount: route.layouts?.length ?? 0, + probeLayoutAt(li) { + const LayoutComp = route.layouts[li]?.default; + if (!LayoutComp) return null; + return LayoutComp({ params: _asyncLayoutParams, children: null }); + }, + probePage() { + return PageComponent({ params }); + }, + async renderLayoutSpecialError(__layoutSpecialError, li) { return __buildAppPageSpecialErrorResponse({ clearRequestContext() { setHeadersContext(null); setNavigationContext(null); }, renderFallbackPage(statusCode) { + // Find the not-found component from the parent level (the boundary that + // would catch this in Next.js). Walk up from the throwing layout to find + // the nearest not-found at a parent layout's directory. + let parentNotFound = null; + if (route.notFounds) { + for (let pi = li - 1; pi >= 0; pi--) { + if (route.notFounds[pi]?.default) { + parentNotFound = route.notFounds[pi].default; + break; + } + } + } + if (!parentNotFound) parentNotFound = ${rootNotFoundVar ? `${rootNotFoundVar}?.default` : "null"}; + const parentLayouts = route.layouts.slice(0, li); return renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { + boundaryComponent: parentNotFound, + layouts: parentLayouts, matchedParams: params, }); }, requestUrl: request.url, - specialError, + specialError: __layoutSpecialError, }); - } - return null; - } - - // Pre-render layout components to catch notFound()/redirect() thrown from layouts. - // In Next.js, each layout level has its own NotFoundBoundary. When a layout throws - // notFound(), the parent layout's boundary catches it and renders the parent's - // not-found.tsx. Since React Flight doesn't activate client error boundaries during - // RSC rendering, we catch layout-level throws here and render the appropriate - // fallback page with only the layouts above the throwing one. - // - // IMPORTANT: Layout pre-render runs BEFORE page pre-render. In Next.js, layouts - // render before their children — if a layout throws notFound(), the page never - // executes. By checking layouts first, we avoid a bug where the page's notFound() - // triggers renderHTTPAccessFallbackPage with ALL route layouts, but one of those - // layouts itself throws notFound() during the fallback rendering (causing a 500). - if (route.layouts && route.layouts.length > 0) { - const asyncParams = makeThenableParams(params); - const _layoutProbeResult = await __probeAppPageLayouts({ - layoutCount: route.layouts.length, - async onLayoutError(layoutErr, li) { - const __layoutSpecialError = __resolveAppPageSpecialError(layoutErr); - if (!__layoutSpecialError) { - return null; - } - - return __buildAppPageSpecialErrorResponse({ - clearRequestContext() { - setHeadersContext(null); - setNavigationContext(null); - }, - renderFallbackPage(statusCode) { - // Find the not-found component from the parent level (the boundary that - // would catch this in Next.js). Walk up from the throwing layout to find - // the nearest not-found at a parent layout's directory. - let parentNotFound = null; - if (route.notFounds) { - for (let pi = li - 1; pi >= 0; pi--) { - if (route.notFounds[pi]?.default) { - parentNotFound = route.notFounds[pi].default; - break; - } - } - } - if (!parentNotFound) parentNotFound = ${rootNotFoundVar ? `${rootNotFoundVar}?.default` : "null"}; - const parentLayouts = route.layouts.slice(0, li); - return renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { - boundaryComponent: parentNotFound, - layouts: parentLayouts, - matchedParams: params, - }); - }, - requestUrl: request.url, - specialError: __layoutSpecialError, - }); - }, - probeLayoutAt(li) { - const LayoutComp = route.layouts[li]?.default; - if (!LayoutComp) return null; - return LayoutComp({ params: asyncParams, children: null }); - }, - runWithSuppressedHookWarning(probe) { - // Run inside ALS context so the module-level console.error patch suppresses - // "Invalid hook call" only for this request's probe — concurrent requests - // each have their own ALS store and are unaffected. - return _suppressHookWarningAls.run(true, probe); - }, - }); - 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 __probeAppPageComponent({ - awaitAsyncResult: !_hasLoadingBoundary, - async onError(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; }, - probePage() { - return PageComponent({ params }); + async renderPageSpecialError(specialError) { + return __buildAppPageSpecialErrorResponse({ + clearRequestContext() { + setHeadersContext(null); + setNavigationContext(null); + }, + renderFallbackPage(statusCode) { + return renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { + matchedParams: params, + }); + }, + requestUrl: request.url, + specialError, + }); }, + resolveSpecialError: __resolveAppPageSpecialError, runWithSuppressedHookWarning(probe) { + // Run inside ALS context so the module-level console.error patch suppresses + // "Invalid hook call" only for this request's probe — concurrent requests + // each have their own ALS store and are unaffected. return _suppressHookWarningAls.run(true, probe); }, }); - if (_pageProbeResult instanceof Response) return _pageProbeResult; + if (__preRenderResponse) return __preRenderResponse; // Mark end of compile phase: route matching, middleware, tree building are done. if (process.env.NODE_ENV !== "production") __compileEnd = performance.now(); diff --git a/packages/vinext/src/server/app-page-probe.ts b/packages/vinext/src/server/app-page-probe.ts new file mode 100644 index 00000000..09c968a1 --- /dev/null +++ b/packages/vinext/src/server/app-page-probe.ts @@ -0,0 +1,68 @@ +import { + probeAppPageComponent, + probeAppPageLayouts, + type AppPageSpecialError, +} from "./app-page-execution.js"; + +export interface ProbeAppPageBeforeRenderOptions { + hasLoadingBoundary: boolean; + layoutCount: number; + probeLayoutAt: (layoutIndex: number) => unknown; + probePage: () => unknown; + renderLayoutSpecialError: ( + specialError: AppPageSpecialError, + layoutIndex: number, + ) => Promise; + renderPageSpecialError: (specialError: AppPageSpecialError) => Promise; + resolveSpecialError: (error: unknown) => AppPageSpecialError | null; + runWithSuppressedHookWarning(probe: () => Promise): Promise; +} + +export async function probeAppPageBeforeRender( + options: ProbeAppPageBeforeRenderOptions, +): Promise { + // Layouts render before their children in Next.js, so layout-level special + // errors must be handled before probing the page component itself. + if (options.layoutCount > 0) { + const layoutProbeResponse = await probeAppPageLayouts({ + layoutCount: options.layoutCount, + async onLayoutError(layoutError, layoutIndex) { + const specialError = options.resolveSpecialError(layoutError); + if (!specialError) { + return null; + } + + return options.renderLayoutSpecialError(specialError, layoutIndex); + }, + probeLayoutAt: options.probeLayoutAt, + runWithSuppressedHookWarning(probe) { + return options.runWithSuppressedHookWarning(probe); + }, + }); + + if (layoutProbeResponse) { + return layoutProbeResponse; + } + } + + // Server Components are functions, so we can probe the page ahead of stream + // creation and only turn special throws into immediate responses. + return probeAppPageComponent({ + awaitAsyncResult: !options.hasLoadingBoundary, + async onError(pageError) { + const specialError = options.resolveSpecialError(pageError); + if (specialError) { + return options.renderPageSpecialError(specialError); + } + + // Non-special probe failures (for example use() outside React's render + // cycle or client references executing on the server) are expected here. + // The real RSC/SSR render path will surface those properly below. + return null; + }, + probePage: options.probePage, + runWithSuppressedHookWarning(probe) { + return options.runWithSuppressedHookWarning(probe); + }, + }); +} diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index 618c5782..8dbd94ac 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -80,12 +80,13 @@ import { import { buildAppPageFontLinkHeader as __buildAppPageFontLinkHeader, buildAppPageSpecialErrorResponse as __buildAppPageSpecialErrorResponse, - probeAppPageComponent as __probeAppPageComponent, - probeAppPageLayouts as __probeAppPageLayouts, readAppPageTextStream as __readAppPageTextStream, resolveAppPageSpecialError as __resolveAppPageSpecialError, teeAppPageRscStreamForCapture as __teeAppPageRscStreamForCapture, } from "/packages/vinext/src/server/app-page-execution.js"; +import { + probeAppPageBeforeRender as __probeAppPageBeforeRender, +} from "/packages/vinext/src/server/app-page-probe.js"; import { renderAppPageBoundaryResponse as __renderAppPageBoundaryResponse, resolveAppPageErrorBoundary as __resolveAppPageErrorBoundary, @@ -2237,126 +2238,74 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // Note: CSS is automatically injected by @vitejs/plugin-rsc's // rscCssTransform — no manual loadCss() call needed. - // Helper: check if an error is a redirect/notFound/forbidden/unauthorized thrown by the navigation shim - async function handleRenderError(err) { - const specialError = __resolveAppPageSpecialError(err); - if (specialError) { + const _hasLoadingBoundary = !!(route.loading && route.loading.default); + const _asyncLayoutParams = makeThenableParams(params); + const __preRenderResponse = await __probeAppPageBeforeRender({ + hasLoadingBoundary: _hasLoadingBoundary, + layoutCount: route.layouts?.length ?? 0, + probeLayoutAt(li) { + const LayoutComp = route.layouts[li]?.default; + if (!LayoutComp) return null; + return LayoutComp({ params: _asyncLayoutParams, children: null }); + }, + probePage() { + return PageComponent({ params }); + }, + async renderLayoutSpecialError(__layoutSpecialError, li) { return __buildAppPageSpecialErrorResponse({ clearRequestContext() { setHeadersContext(null); setNavigationContext(null); }, renderFallbackPage(statusCode) { + // Find the not-found component from the parent level (the boundary that + // would catch this in Next.js). Walk up from the throwing layout to find + // the nearest not-found at a parent layout's directory. + let parentNotFound = null; + if (route.notFounds) { + for (let pi = li - 1; pi >= 0; pi--) { + if (route.notFounds[pi]?.default) { + parentNotFound = route.notFounds[pi].default; + break; + } + } + } + if (!parentNotFound) parentNotFound = null; + const parentLayouts = route.layouts.slice(0, li); return renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { + boundaryComponent: parentNotFound, + layouts: parentLayouts, matchedParams: params, }); }, requestUrl: request.url, - specialError, + specialError: __layoutSpecialError, }); - } - return null; - } - - // Pre-render layout components to catch notFound()/redirect() thrown from layouts. - // In Next.js, each layout level has its own NotFoundBoundary. When a layout throws - // notFound(), the parent layout's boundary catches it and renders the parent's - // not-found.tsx. Since React Flight doesn't activate client error boundaries during - // RSC rendering, we catch layout-level throws here and render the appropriate - // fallback page with only the layouts above the throwing one. - // - // IMPORTANT: Layout pre-render runs BEFORE page pre-render. In Next.js, layouts - // render before their children — if a layout throws notFound(), the page never - // executes. By checking layouts first, we avoid a bug where the page's notFound() - // triggers renderHTTPAccessFallbackPage with ALL route layouts, but one of those - // layouts itself throws notFound() during the fallback rendering (causing a 500). - if (route.layouts && route.layouts.length > 0) { - const asyncParams = makeThenableParams(params); - const _layoutProbeResult = await __probeAppPageLayouts({ - layoutCount: route.layouts.length, - async onLayoutError(layoutErr, li) { - const __layoutSpecialError = __resolveAppPageSpecialError(layoutErr); - if (!__layoutSpecialError) { - return null; - } - - return __buildAppPageSpecialErrorResponse({ - clearRequestContext() { - setHeadersContext(null); - setNavigationContext(null); - }, - renderFallbackPage(statusCode) { - // Find the not-found component from the parent level (the boundary that - // would catch this in Next.js). Walk up from the throwing layout to find - // the nearest not-found at a parent layout's directory. - let parentNotFound = null; - if (route.notFounds) { - for (let pi = li - 1; pi >= 0; pi--) { - if (route.notFounds[pi]?.default) { - parentNotFound = route.notFounds[pi].default; - break; - } - } - } - if (!parentNotFound) parentNotFound = null; - const parentLayouts = route.layouts.slice(0, li); - return renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { - boundaryComponent: parentNotFound, - layouts: parentLayouts, - matchedParams: params, - }); - }, - requestUrl: request.url, - specialError: __layoutSpecialError, - }); - }, - probeLayoutAt(li) { - const LayoutComp = route.layouts[li]?.default; - if (!LayoutComp) return null; - return LayoutComp({ params: asyncParams, children: null }); - }, - runWithSuppressedHookWarning(probe) { - // Run inside ALS context so the module-level console.error patch suppresses - // "Invalid hook call" only for this request's probe — concurrent requests - // each have their own ALS store and are unaffected. - return _suppressHookWarningAls.run(true, probe); - }, - }); - 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 __probeAppPageComponent({ - awaitAsyncResult: !_hasLoadingBoundary, - async onError(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; }, - probePage() { - return PageComponent({ params }); + async renderPageSpecialError(specialError) { + return __buildAppPageSpecialErrorResponse({ + clearRequestContext() { + setHeadersContext(null); + setNavigationContext(null); + }, + renderFallbackPage(statusCode) { + return renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { + matchedParams: params, + }); + }, + requestUrl: request.url, + specialError, + }); }, + resolveSpecialError: __resolveAppPageSpecialError, runWithSuppressedHookWarning(probe) { + // Run inside ALS context so the module-level console.error patch suppresses + // "Invalid hook call" only for this request's probe — concurrent requests + // each have their own ALS store and are unaffected. return _suppressHookWarningAls.run(true, probe); }, }); - if (_pageProbeResult instanceof Response) return _pageProbeResult; + if (__preRenderResponse) return __preRenderResponse; // Mark end of compile phase: route matching, middleware, tree building are done. if (process.env.NODE_ENV !== "production") __compileEnd = performance.now(); @@ -2637,12 +2586,13 @@ import { import { buildAppPageFontLinkHeader as __buildAppPageFontLinkHeader, buildAppPageSpecialErrorResponse as __buildAppPageSpecialErrorResponse, - probeAppPageComponent as __probeAppPageComponent, - probeAppPageLayouts as __probeAppPageLayouts, readAppPageTextStream as __readAppPageTextStream, resolveAppPageSpecialError as __resolveAppPageSpecialError, teeAppPageRscStreamForCapture as __teeAppPageRscStreamForCapture, } from "/packages/vinext/src/server/app-page-execution.js"; +import { + probeAppPageBeforeRender as __probeAppPageBeforeRender, +} from "/packages/vinext/src/server/app-page-probe.js"; import { renderAppPageBoundaryResponse as __renderAppPageBoundaryResponse, resolveAppPageErrorBoundary as __resolveAppPageErrorBoundary, @@ -4797,126 +4747,74 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // Note: CSS is automatically injected by @vitejs/plugin-rsc's // rscCssTransform — no manual loadCss() call needed. - // Helper: check if an error is a redirect/notFound/forbidden/unauthorized thrown by the navigation shim - async function handleRenderError(err) { - const specialError = __resolveAppPageSpecialError(err); - if (specialError) { + const _hasLoadingBoundary = !!(route.loading && route.loading.default); + const _asyncLayoutParams = makeThenableParams(params); + const __preRenderResponse = await __probeAppPageBeforeRender({ + hasLoadingBoundary: _hasLoadingBoundary, + layoutCount: route.layouts?.length ?? 0, + probeLayoutAt(li) { + const LayoutComp = route.layouts[li]?.default; + if (!LayoutComp) return null; + return LayoutComp({ params: _asyncLayoutParams, children: null }); + }, + probePage() { + return PageComponent({ params }); + }, + async renderLayoutSpecialError(__layoutSpecialError, li) { return __buildAppPageSpecialErrorResponse({ clearRequestContext() { setHeadersContext(null); setNavigationContext(null); }, renderFallbackPage(statusCode) { + // Find the not-found component from the parent level (the boundary that + // would catch this in Next.js). Walk up from the throwing layout to find + // the nearest not-found at a parent layout's directory. + let parentNotFound = null; + if (route.notFounds) { + for (let pi = li - 1; pi >= 0; pi--) { + if (route.notFounds[pi]?.default) { + parentNotFound = route.notFounds[pi].default; + break; + } + } + } + if (!parentNotFound) parentNotFound = null; + const parentLayouts = route.layouts.slice(0, li); return renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { + boundaryComponent: parentNotFound, + layouts: parentLayouts, matchedParams: params, }); }, requestUrl: request.url, - specialError, + specialError: __layoutSpecialError, }); - } - return null; - } - - // Pre-render layout components to catch notFound()/redirect() thrown from layouts. - // In Next.js, each layout level has its own NotFoundBoundary. When a layout throws - // notFound(), the parent layout's boundary catches it and renders the parent's - // not-found.tsx. Since React Flight doesn't activate client error boundaries during - // RSC rendering, we catch layout-level throws here and render the appropriate - // fallback page with only the layouts above the throwing one. - // - // IMPORTANT: Layout pre-render runs BEFORE page pre-render. In Next.js, layouts - // render before their children — if a layout throws notFound(), the page never - // executes. By checking layouts first, we avoid a bug where the page's notFound() - // triggers renderHTTPAccessFallbackPage with ALL route layouts, but one of those - // layouts itself throws notFound() during the fallback rendering (causing a 500). - if (route.layouts && route.layouts.length > 0) { - const asyncParams = makeThenableParams(params); - const _layoutProbeResult = await __probeAppPageLayouts({ - layoutCount: route.layouts.length, - async onLayoutError(layoutErr, li) { - const __layoutSpecialError = __resolveAppPageSpecialError(layoutErr); - if (!__layoutSpecialError) { - return null; - } - - return __buildAppPageSpecialErrorResponse({ - clearRequestContext() { - setHeadersContext(null); - setNavigationContext(null); - }, - renderFallbackPage(statusCode) { - // Find the not-found component from the parent level (the boundary that - // would catch this in Next.js). Walk up from the throwing layout to find - // the nearest not-found at a parent layout's directory. - let parentNotFound = null; - if (route.notFounds) { - for (let pi = li - 1; pi >= 0; pi--) { - if (route.notFounds[pi]?.default) { - parentNotFound = route.notFounds[pi].default; - break; - } - } - } - if (!parentNotFound) parentNotFound = null; - const parentLayouts = route.layouts.slice(0, li); - return renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { - boundaryComponent: parentNotFound, - layouts: parentLayouts, - matchedParams: params, - }); - }, - requestUrl: request.url, - specialError: __layoutSpecialError, - }); - }, - probeLayoutAt(li) { - const LayoutComp = route.layouts[li]?.default; - if (!LayoutComp) return null; - return LayoutComp({ params: asyncParams, children: null }); - }, - runWithSuppressedHookWarning(probe) { - // Run inside ALS context so the module-level console.error patch suppresses - // "Invalid hook call" only for this request's probe — concurrent requests - // each have their own ALS store and are unaffected. - return _suppressHookWarningAls.run(true, probe); - }, - }); - 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 __probeAppPageComponent({ - awaitAsyncResult: !_hasLoadingBoundary, - async onError(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; }, - probePage() { - return PageComponent({ params }); + async renderPageSpecialError(specialError) { + return __buildAppPageSpecialErrorResponse({ + clearRequestContext() { + setHeadersContext(null); + setNavigationContext(null); + }, + renderFallbackPage(statusCode) { + return renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { + matchedParams: params, + }); + }, + requestUrl: request.url, + specialError, + }); }, + resolveSpecialError: __resolveAppPageSpecialError, runWithSuppressedHookWarning(probe) { + // Run inside ALS context so the module-level console.error patch suppresses + // "Invalid hook call" only for this request's probe — concurrent requests + // each have their own ALS store and are unaffected. return _suppressHookWarningAls.run(true, probe); }, }); - if (_pageProbeResult instanceof Response) return _pageProbeResult; + if (__preRenderResponse) return __preRenderResponse; // Mark end of compile phase: route matching, middleware, tree building are done. if (process.env.NODE_ENV !== "production") __compileEnd = performance.now(); @@ -5197,12 +5095,13 @@ import { import { buildAppPageFontLinkHeader as __buildAppPageFontLinkHeader, buildAppPageSpecialErrorResponse as __buildAppPageSpecialErrorResponse, - probeAppPageComponent as __probeAppPageComponent, - probeAppPageLayouts as __probeAppPageLayouts, readAppPageTextStream as __readAppPageTextStream, resolveAppPageSpecialError as __resolveAppPageSpecialError, teeAppPageRscStreamForCapture as __teeAppPageRscStreamForCapture, } from "/packages/vinext/src/server/app-page-execution.js"; +import { + probeAppPageBeforeRender as __probeAppPageBeforeRender, +} from "/packages/vinext/src/server/app-page-probe.js"; import { renderAppPageBoundaryResponse as __renderAppPageBoundaryResponse, resolveAppPageErrorBoundary as __resolveAppPageErrorBoundary, @@ -7363,126 +7262,74 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // Note: CSS is automatically injected by @vitejs/plugin-rsc's // rscCssTransform — no manual loadCss() call needed. - // Helper: check if an error is a redirect/notFound/forbidden/unauthorized thrown by the navigation shim - async function handleRenderError(err) { - const specialError = __resolveAppPageSpecialError(err); - if (specialError) { + const _hasLoadingBoundary = !!(route.loading && route.loading.default); + const _asyncLayoutParams = makeThenableParams(params); + const __preRenderResponse = await __probeAppPageBeforeRender({ + hasLoadingBoundary: _hasLoadingBoundary, + layoutCount: route.layouts?.length ?? 0, + probeLayoutAt(li) { + const LayoutComp = route.layouts[li]?.default; + if (!LayoutComp) return null; + return LayoutComp({ params: _asyncLayoutParams, children: null }); + }, + probePage() { + return PageComponent({ params }); + }, + async renderLayoutSpecialError(__layoutSpecialError, li) { return __buildAppPageSpecialErrorResponse({ clearRequestContext() { setHeadersContext(null); setNavigationContext(null); }, renderFallbackPage(statusCode) { + // Find the not-found component from the parent level (the boundary that + // would catch this in Next.js). Walk up from the throwing layout to find + // the nearest not-found at a parent layout's directory. + let parentNotFound = null; + if (route.notFounds) { + for (let pi = li - 1; pi >= 0; pi--) { + if (route.notFounds[pi]?.default) { + parentNotFound = route.notFounds[pi].default; + break; + } + } + } + if (!parentNotFound) parentNotFound = null; + const parentLayouts = route.layouts.slice(0, li); return renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { + boundaryComponent: parentNotFound, + layouts: parentLayouts, matchedParams: params, }); }, requestUrl: request.url, - specialError, + specialError: __layoutSpecialError, }); - } - return null; - } - - // Pre-render layout components to catch notFound()/redirect() thrown from layouts. - // In Next.js, each layout level has its own NotFoundBoundary. When a layout throws - // notFound(), the parent layout's boundary catches it and renders the parent's - // not-found.tsx. Since React Flight doesn't activate client error boundaries during - // RSC rendering, we catch layout-level throws here and render the appropriate - // fallback page with only the layouts above the throwing one. - // - // IMPORTANT: Layout pre-render runs BEFORE page pre-render. In Next.js, layouts - // render before their children — if a layout throws notFound(), the page never - // executes. By checking layouts first, we avoid a bug where the page's notFound() - // triggers renderHTTPAccessFallbackPage with ALL route layouts, but one of those - // layouts itself throws notFound() during the fallback rendering (causing a 500). - if (route.layouts && route.layouts.length > 0) { - const asyncParams = makeThenableParams(params); - const _layoutProbeResult = await __probeAppPageLayouts({ - layoutCount: route.layouts.length, - async onLayoutError(layoutErr, li) { - const __layoutSpecialError = __resolveAppPageSpecialError(layoutErr); - if (!__layoutSpecialError) { - return null; - } - - return __buildAppPageSpecialErrorResponse({ - clearRequestContext() { - setHeadersContext(null); - setNavigationContext(null); - }, - renderFallbackPage(statusCode) { - // Find the not-found component from the parent level (the boundary that - // would catch this in Next.js). Walk up from the throwing layout to find - // the nearest not-found at a parent layout's directory. - let parentNotFound = null; - if (route.notFounds) { - for (let pi = li - 1; pi >= 0; pi--) { - if (route.notFounds[pi]?.default) { - parentNotFound = route.notFounds[pi].default; - break; - } - } - } - if (!parentNotFound) parentNotFound = null; - const parentLayouts = route.layouts.slice(0, li); - return renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { - boundaryComponent: parentNotFound, - layouts: parentLayouts, - matchedParams: params, - }); - }, - requestUrl: request.url, - specialError: __layoutSpecialError, - }); - }, - probeLayoutAt(li) { - const LayoutComp = route.layouts[li]?.default; - if (!LayoutComp) return null; - return LayoutComp({ params: asyncParams, children: null }); - }, - runWithSuppressedHookWarning(probe) { - // Run inside ALS context so the module-level console.error patch suppresses - // "Invalid hook call" only for this request's probe — concurrent requests - // each have their own ALS store and are unaffected. - return _suppressHookWarningAls.run(true, probe); - }, - }); - 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 __probeAppPageComponent({ - awaitAsyncResult: !_hasLoadingBoundary, - async onError(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; }, - probePage() { - return PageComponent({ params }); + async renderPageSpecialError(specialError) { + return __buildAppPageSpecialErrorResponse({ + clearRequestContext() { + setHeadersContext(null); + setNavigationContext(null); + }, + renderFallbackPage(statusCode) { + return renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { + matchedParams: params, + }); + }, + requestUrl: request.url, + specialError, + }); }, + resolveSpecialError: __resolveAppPageSpecialError, runWithSuppressedHookWarning(probe) { + // Run inside ALS context so the module-level console.error patch suppresses + // "Invalid hook call" only for this request's probe — concurrent requests + // each have their own ALS store and are unaffected. return _suppressHookWarningAls.run(true, probe); }, }); - if (_pageProbeResult instanceof Response) return _pageProbeResult; + if (__preRenderResponse) return __preRenderResponse; // Mark end of compile phase: route matching, middleware, tree building are done. if (process.env.NODE_ENV !== "production") __compileEnd = performance.now(); @@ -7772,12 +7619,13 @@ import { import { buildAppPageFontLinkHeader as __buildAppPageFontLinkHeader, buildAppPageSpecialErrorResponse as __buildAppPageSpecialErrorResponse, - probeAppPageComponent as __probeAppPageComponent, - probeAppPageLayouts as __probeAppPageLayouts, readAppPageTextStream as __readAppPageTextStream, resolveAppPageSpecialError as __resolveAppPageSpecialError, teeAppPageRscStreamForCapture as __teeAppPageRscStreamForCapture, } from "/packages/vinext/src/server/app-page-execution.js"; +import { + probeAppPageBeforeRender as __probeAppPageBeforeRender, +} from "/packages/vinext/src/server/app-page-probe.js"; import { renderAppPageBoundaryResponse as __renderAppPageBoundaryResponse, resolveAppPageErrorBoundary as __resolveAppPageErrorBoundary, @@ -9962,126 +9810,74 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // Note: CSS is automatically injected by @vitejs/plugin-rsc's // rscCssTransform — no manual loadCss() call needed. - // Helper: check if an error is a redirect/notFound/forbidden/unauthorized thrown by the navigation shim - async function handleRenderError(err) { - const specialError = __resolveAppPageSpecialError(err); - if (specialError) { + const _hasLoadingBoundary = !!(route.loading && route.loading.default); + const _asyncLayoutParams = makeThenableParams(params); + const __preRenderResponse = await __probeAppPageBeforeRender({ + hasLoadingBoundary: _hasLoadingBoundary, + layoutCount: route.layouts?.length ?? 0, + probeLayoutAt(li) { + const LayoutComp = route.layouts[li]?.default; + if (!LayoutComp) return null; + return LayoutComp({ params: _asyncLayoutParams, children: null }); + }, + probePage() { + return PageComponent({ params }); + }, + async renderLayoutSpecialError(__layoutSpecialError, li) { return __buildAppPageSpecialErrorResponse({ clearRequestContext() { setHeadersContext(null); setNavigationContext(null); }, renderFallbackPage(statusCode) { + // Find the not-found component from the parent level (the boundary that + // would catch this in Next.js). Walk up from the throwing layout to find + // the nearest not-found at a parent layout's directory. + let parentNotFound = null; + if (route.notFounds) { + for (let pi = li - 1; pi >= 0; pi--) { + if (route.notFounds[pi]?.default) { + parentNotFound = route.notFounds[pi].default; + break; + } + } + } + if (!parentNotFound) parentNotFound = null; + const parentLayouts = route.layouts.slice(0, li); return renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { + boundaryComponent: parentNotFound, + layouts: parentLayouts, matchedParams: params, }); }, requestUrl: request.url, - specialError, + specialError: __layoutSpecialError, }); - } - return null; - } - - // Pre-render layout components to catch notFound()/redirect() thrown from layouts. - // In Next.js, each layout level has its own NotFoundBoundary. When a layout throws - // notFound(), the parent layout's boundary catches it and renders the parent's - // not-found.tsx. Since React Flight doesn't activate client error boundaries during - // RSC rendering, we catch layout-level throws here and render the appropriate - // fallback page with only the layouts above the throwing one. - // - // IMPORTANT: Layout pre-render runs BEFORE page pre-render. In Next.js, layouts - // render before their children — if a layout throws notFound(), the page never - // executes. By checking layouts first, we avoid a bug where the page's notFound() - // triggers renderHTTPAccessFallbackPage with ALL route layouts, but one of those - // layouts itself throws notFound() during the fallback rendering (causing a 500). - if (route.layouts && route.layouts.length > 0) { - const asyncParams = makeThenableParams(params); - const _layoutProbeResult = await __probeAppPageLayouts({ - layoutCount: route.layouts.length, - async onLayoutError(layoutErr, li) { - const __layoutSpecialError = __resolveAppPageSpecialError(layoutErr); - if (!__layoutSpecialError) { - return null; - } - - return __buildAppPageSpecialErrorResponse({ - clearRequestContext() { - setHeadersContext(null); - setNavigationContext(null); - }, - renderFallbackPage(statusCode) { - // Find the not-found component from the parent level (the boundary that - // would catch this in Next.js). Walk up from the throwing layout to find - // the nearest not-found at a parent layout's directory. - let parentNotFound = null; - if (route.notFounds) { - for (let pi = li - 1; pi >= 0; pi--) { - if (route.notFounds[pi]?.default) { - parentNotFound = route.notFounds[pi].default; - break; - } - } - } - if (!parentNotFound) parentNotFound = null; - const parentLayouts = route.layouts.slice(0, li); - return renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { - boundaryComponent: parentNotFound, - layouts: parentLayouts, - matchedParams: params, - }); - }, - requestUrl: request.url, - specialError: __layoutSpecialError, - }); - }, - probeLayoutAt(li) { - const LayoutComp = route.layouts[li]?.default; - if (!LayoutComp) return null; - return LayoutComp({ params: asyncParams, children: null }); - }, - runWithSuppressedHookWarning(probe) { - // Run inside ALS context so the module-level console.error patch suppresses - // "Invalid hook call" only for this request's probe — concurrent requests - // each have their own ALS store and are unaffected. - return _suppressHookWarningAls.run(true, probe); - }, - }); - 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 __probeAppPageComponent({ - awaitAsyncResult: !_hasLoadingBoundary, - async onError(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; }, - probePage() { - return PageComponent({ params }); + async renderPageSpecialError(specialError) { + return __buildAppPageSpecialErrorResponse({ + clearRequestContext() { + setHeadersContext(null); + setNavigationContext(null); + }, + renderFallbackPage(statusCode) { + return renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { + matchedParams: params, + }); + }, + requestUrl: request.url, + specialError, + }); }, + resolveSpecialError: __resolveAppPageSpecialError, runWithSuppressedHookWarning(probe) { + // Run inside ALS context so the module-level console.error patch suppresses + // "Invalid hook call" only for this request's probe — concurrent requests + // each have their own ALS store and are unaffected. return _suppressHookWarningAls.run(true, probe); }, }); - if (_pageProbeResult instanceof Response) return _pageProbeResult; + if (__preRenderResponse) return __preRenderResponse; // Mark end of compile phase: route matching, middleware, tree building are done. if (process.env.NODE_ENV !== "production") __compileEnd = performance.now(); @@ -10362,12 +10158,13 @@ import { import { buildAppPageFontLinkHeader as __buildAppPageFontLinkHeader, buildAppPageSpecialErrorResponse as __buildAppPageSpecialErrorResponse, - probeAppPageComponent as __probeAppPageComponent, - probeAppPageLayouts as __probeAppPageLayouts, readAppPageTextStream as __readAppPageTextStream, resolveAppPageSpecialError as __resolveAppPageSpecialError, teeAppPageRscStreamForCapture as __teeAppPageRscStreamForCapture, } from "/packages/vinext/src/server/app-page-execution.js"; +import { + probeAppPageBeforeRender as __probeAppPageBeforeRender, +} from "/packages/vinext/src/server/app-page-probe.js"; import { renderAppPageBoundaryResponse as __renderAppPageBoundaryResponse, resolveAppPageErrorBoundary as __resolveAppPageErrorBoundary, @@ -12526,126 +12323,74 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // Note: CSS is automatically injected by @vitejs/plugin-rsc's // rscCssTransform — no manual loadCss() call needed. - // Helper: check if an error is a redirect/notFound/forbidden/unauthorized thrown by the navigation shim - async function handleRenderError(err) { - const specialError = __resolveAppPageSpecialError(err); - if (specialError) { + const _hasLoadingBoundary = !!(route.loading && route.loading.default); + const _asyncLayoutParams = makeThenableParams(params); + const __preRenderResponse = await __probeAppPageBeforeRender({ + hasLoadingBoundary: _hasLoadingBoundary, + layoutCount: route.layouts?.length ?? 0, + probeLayoutAt(li) { + const LayoutComp = route.layouts[li]?.default; + if (!LayoutComp) return null; + return LayoutComp({ params: _asyncLayoutParams, children: null }); + }, + probePage() { + return PageComponent({ params }); + }, + async renderLayoutSpecialError(__layoutSpecialError, li) { return __buildAppPageSpecialErrorResponse({ clearRequestContext() { setHeadersContext(null); setNavigationContext(null); }, renderFallbackPage(statusCode) { + // Find the not-found component from the parent level (the boundary that + // would catch this in Next.js). Walk up from the throwing layout to find + // the nearest not-found at a parent layout's directory. + let parentNotFound = null; + if (route.notFounds) { + for (let pi = li - 1; pi >= 0; pi--) { + if (route.notFounds[pi]?.default) { + parentNotFound = route.notFounds[pi].default; + break; + } + } + } + if (!parentNotFound) parentNotFound = null; + const parentLayouts = route.layouts.slice(0, li); return renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { + boundaryComponent: parentNotFound, + layouts: parentLayouts, matchedParams: params, }); }, requestUrl: request.url, - specialError, + specialError: __layoutSpecialError, }); - } - return null; - } - - // Pre-render layout components to catch notFound()/redirect() thrown from layouts. - // In Next.js, each layout level has its own NotFoundBoundary. When a layout throws - // notFound(), the parent layout's boundary catches it and renders the parent's - // not-found.tsx. Since React Flight doesn't activate client error boundaries during - // RSC rendering, we catch layout-level throws here and render the appropriate - // fallback page with only the layouts above the throwing one. - // - // IMPORTANT: Layout pre-render runs BEFORE page pre-render. In Next.js, layouts - // render before their children — if a layout throws notFound(), the page never - // executes. By checking layouts first, we avoid a bug where the page's notFound() - // triggers renderHTTPAccessFallbackPage with ALL route layouts, but one of those - // layouts itself throws notFound() during the fallback rendering (causing a 500). - if (route.layouts && route.layouts.length > 0) { - const asyncParams = makeThenableParams(params); - const _layoutProbeResult = await __probeAppPageLayouts({ - layoutCount: route.layouts.length, - async onLayoutError(layoutErr, li) { - const __layoutSpecialError = __resolveAppPageSpecialError(layoutErr); - if (!__layoutSpecialError) { - return null; - } - - return __buildAppPageSpecialErrorResponse({ - clearRequestContext() { - setHeadersContext(null); - setNavigationContext(null); - }, - renderFallbackPage(statusCode) { - // Find the not-found component from the parent level (the boundary that - // would catch this in Next.js). Walk up from the throwing layout to find - // the nearest not-found at a parent layout's directory. - let parentNotFound = null; - if (route.notFounds) { - for (let pi = li - 1; pi >= 0; pi--) { - if (route.notFounds[pi]?.default) { - parentNotFound = route.notFounds[pi].default; - break; - } - } - } - if (!parentNotFound) parentNotFound = null; - const parentLayouts = route.layouts.slice(0, li); - return renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { - boundaryComponent: parentNotFound, - layouts: parentLayouts, - matchedParams: params, - }); - }, - requestUrl: request.url, - specialError: __layoutSpecialError, - }); - }, - probeLayoutAt(li) { - const LayoutComp = route.layouts[li]?.default; - if (!LayoutComp) return null; - return LayoutComp({ params: asyncParams, children: null }); - }, - runWithSuppressedHookWarning(probe) { - // Run inside ALS context so the module-level console.error patch suppresses - // "Invalid hook call" only for this request's probe — concurrent requests - // each have their own ALS store and are unaffected. - return _suppressHookWarningAls.run(true, probe); - }, - }); - 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 __probeAppPageComponent({ - awaitAsyncResult: !_hasLoadingBoundary, - async onError(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; }, - probePage() { - return PageComponent({ params }); + async renderPageSpecialError(specialError) { + return __buildAppPageSpecialErrorResponse({ + clearRequestContext() { + setHeadersContext(null); + setNavigationContext(null); + }, + renderFallbackPage(statusCode) { + return renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { + matchedParams: params, + }); + }, + requestUrl: request.url, + specialError, + }); }, + resolveSpecialError: __resolveAppPageSpecialError, runWithSuppressedHookWarning(probe) { + // Run inside ALS context so the module-level console.error patch suppresses + // "Invalid hook call" only for this request's probe — concurrent requests + // each have their own ALS store and are unaffected. return _suppressHookWarningAls.run(true, probe); }, }); - if (_pageProbeResult instanceof Response) return _pageProbeResult; + if (__preRenderResponse) return __preRenderResponse; // Mark end of compile phase: route matching, middleware, tree building are done. if (process.env.NODE_ENV !== "production") __compileEnd = performance.now(); @@ -12926,12 +12671,13 @@ import { import { buildAppPageFontLinkHeader as __buildAppPageFontLinkHeader, buildAppPageSpecialErrorResponse as __buildAppPageSpecialErrorResponse, - probeAppPageComponent as __probeAppPageComponent, - probeAppPageLayouts as __probeAppPageLayouts, readAppPageTextStream as __readAppPageTextStream, resolveAppPageSpecialError as __resolveAppPageSpecialError, teeAppPageRscStreamForCapture as __teeAppPageRscStreamForCapture, } from "/packages/vinext/src/server/app-page-execution.js"; +import { + probeAppPageBeforeRender as __probeAppPageBeforeRender, +} from "/packages/vinext/src/server/app-page-probe.js"; import { renderAppPageBoundaryResponse as __renderAppPageBoundaryResponse, resolveAppPageErrorBoundary as __resolveAppPageErrorBoundary, @@ -15443,126 +15189,74 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // Note: CSS is automatically injected by @vitejs/plugin-rsc's // rscCssTransform — no manual loadCss() call needed. - // Helper: check if an error is a redirect/notFound/forbidden/unauthorized thrown by the navigation shim - async function handleRenderError(err) { - const specialError = __resolveAppPageSpecialError(err); - if (specialError) { + const _hasLoadingBoundary = !!(route.loading && route.loading.default); + const _asyncLayoutParams = makeThenableParams(params); + const __preRenderResponse = await __probeAppPageBeforeRender({ + hasLoadingBoundary: _hasLoadingBoundary, + layoutCount: route.layouts?.length ?? 0, + probeLayoutAt(li) { + const LayoutComp = route.layouts[li]?.default; + if (!LayoutComp) return null; + return LayoutComp({ params: _asyncLayoutParams, children: null }); + }, + probePage() { + return PageComponent({ params }); + }, + async renderLayoutSpecialError(__layoutSpecialError, li) { return __buildAppPageSpecialErrorResponse({ clearRequestContext() { setHeadersContext(null); setNavigationContext(null); }, renderFallbackPage(statusCode) { + // Find the not-found component from the parent level (the boundary that + // would catch this in Next.js). Walk up from the throwing layout to find + // the nearest not-found at a parent layout's directory. + let parentNotFound = null; + if (route.notFounds) { + for (let pi = li - 1; pi >= 0; pi--) { + if (route.notFounds[pi]?.default) { + parentNotFound = route.notFounds[pi].default; + break; + } + } + } + if (!parentNotFound) parentNotFound = null; + const parentLayouts = route.layouts.slice(0, li); return renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { + boundaryComponent: parentNotFound, + layouts: parentLayouts, matchedParams: params, }); }, requestUrl: request.url, - specialError, + specialError: __layoutSpecialError, }); - } - return null; - } - - // Pre-render layout components to catch notFound()/redirect() thrown from layouts. - // In Next.js, each layout level has its own NotFoundBoundary. When a layout throws - // notFound(), the parent layout's boundary catches it and renders the parent's - // not-found.tsx. Since React Flight doesn't activate client error boundaries during - // RSC rendering, we catch layout-level throws here and render the appropriate - // fallback page with only the layouts above the throwing one. - // - // IMPORTANT: Layout pre-render runs BEFORE page pre-render. In Next.js, layouts - // render before their children — if a layout throws notFound(), the page never - // executes. By checking layouts first, we avoid a bug where the page's notFound() - // triggers renderHTTPAccessFallbackPage with ALL route layouts, but one of those - // layouts itself throws notFound() during the fallback rendering (causing a 500). - if (route.layouts && route.layouts.length > 0) { - const asyncParams = makeThenableParams(params); - const _layoutProbeResult = await __probeAppPageLayouts({ - layoutCount: route.layouts.length, - async onLayoutError(layoutErr, li) { - const __layoutSpecialError = __resolveAppPageSpecialError(layoutErr); - if (!__layoutSpecialError) { - return null; - } - - return __buildAppPageSpecialErrorResponse({ - clearRequestContext() { - setHeadersContext(null); - setNavigationContext(null); - }, - renderFallbackPage(statusCode) { - // Find the not-found component from the parent level (the boundary that - // would catch this in Next.js). Walk up from the throwing layout to find - // the nearest not-found at a parent layout's directory. - let parentNotFound = null; - if (route.notFounds) { - for (let pi = li - 1; pi >= 0; pi--) { - if (route.notFounds[pi]?.default) { - parentNotFound = route.notFounds[pi].default; - break; - } - } - } - if (!parentNotFound) parentNotFound = null; - const parentLayouts = route.layouts.slice(0, li); - return renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { - boundaryComponent: parentNotFound, - layouts: parentLayouts, - matchedParams: params, - }); - }, - requestUrl: request.url, - specialError: __layoutSpecialError, - }); - }, - probeLayoutAt(li) { - const LayoutComp = route.layouts[li]?.default; - if (!LayoutComp) return null; - return LayoutComp({ params: asyncParams, children: null }); - }, - runWithSuppressedHookWarning(probe) { - // Run inside ALS context so the module-level console.error patch suppresses - // "Invalid hook call" only for this request's probe — concurrent requests - // each have their own ALS store and are unaffected. - return _suppressHookWarningAls.run(true, probe); - }, - }); - 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 __probeAppPageComponent({ - awaitAsyncResult: !_hasLoadingBoundary, - async onError(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; }, - probePage() { - return PageComponent({ params }); + async renderPageSpecialError(specialError) { + return __buildAppPageSpecialErrorResponse({ + clearRequestContext() { + setHeadersContext(null); + setNavigationContext(null); + }, + renderFallbackPage(statusCode) { + return renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { + matchedParams: params, + }); + }, + requestUrl: request.url, + specialError, + }); }, + resolveSpecialError: __resolveAppPageSpecialError, runWithSuppressedHookWarning(probe) { + // Run inside ALS context so the module-level console.error patch suppresses + // "Invalid hook call" only for this request's probe — concurrent requests + // each have their own ALS store and are unaffected. return _suppressHookWarningAls.run(true, probe); }, }); - if (_pageProbeResult instanceof Response) return _pageProbeResult; + if (__preRenderResponse) return __preRenderResponse; // Mark end of compile phase: route matching, middleware, tree building are done. if (process.env.NODE_ENV !== "production") __compileEnd = performance.now(); diff --git a/tests/app-page-probe.test.ts b/tests/app-page-probe.test.ts new file mode 100644 index 00000000..10acf9d9 --- /dev/null +++ b/tests/app-page-probe.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it, vi } from "vite-plus/test"; +import { probeAppPageBeforeRender } from "../packages/vinext/src/server/app-page-probe.js"; + +describe("app page probe helpers", () => { + it("handles layout special errors before probing the page", async () => { + const layoutError = new Error("layout failed"); + const pageProbe = vi.fn(() => "page"); + const renderLayoutSpecialError = vi.fn(async () => { + return new Response("layout-fallback", { status: 404 }); + }); + const renderPageSpecialError = vi.fn(); + const probedLayouts: number[] = []; + + const response = await probeAppPageBeforeRender({ + hasLoadingBoundary: false, + layoutCount: 3, + probeLayoutAt(layoutIndex) { + probedLayouts.push(layoutIndex); + if (layoutIndex === 1) { + throw layoutError; + } + return null; + }, + probePage: pageProbe, + renderLayoutSpecialError, + renderPageSpecialError, + resolveSpecialError(error) { + return error === layoutError + ? { + kind: "http-access-fallback", + statusCode: 404, + } + : null; + }, + runWithSuppressedHookWarning(probe) { + return probe(); + }, + }); + + expect(probedLayouts).toEqual([2, 1]); + expect(pageProbe).not.toHaveBeenCalled(); + expect(renderLayoutSpecialError).toHaveBeenCalledWith( + { + kind: "http-access-fallback", + statusCode: 404, + }, + 1, + ); + expect(renderPageSpecialError).not.toHaveBeenCalled(); + expect(response?.status).toBe(404); + await expect(response?.text()).resolves.toBe("layout-fallback"); + }); + + it("falls through to the page probe when layout failures are not special", async () => { + const layoutError = new Error("ordinary layout failure"); + const pageProbe = vi.fn(() => null); + const renderLayoutSpecialError = vi.fn(); + + const response = await probeAppPageBeforeRender({ + hasLoadingBoundary: false, + layoutCount: 2, + probeLayoutAt(layoutIndex) { + if (layoutIndex === 1) { + throw layoutError; + } + return null; + }, + probePage: pageProbe, + renderLayoutSpecialError, + renderPageSpecialError() { + throw new Error("should not render a page special error"); + }, + resolveSpecialError() { + return null; + }, + runWithSuppressedHookWarning(probe) { + return probe(); + }, + }); + + expect(response).toBeNull(); + expect(pageProbe).toHaveBeenCalledTimes(1); + expect(renderLayoutSpecialError).not.toHaveBeenCalled(); + }); + + it("turns special page probe failures into immediate responses", async () => { + const pageError = new Error("page failed"); + const renderPageSpecialError = vi.fn(async () => { + return new Response("page-fallback", { status: 307 }); + }); + + const response = await probeAppPageBeforeRender({ + hasLoadingBoundary: false, + layoutCount: 0, + probeLayoutAt() { + throw new Error("should not probe layouts"); + }, + probePage() { + return Promise.reject(pageError); + }, + renderLayoutSpecialError() { + throw new Error("should not render a layout special error"); + }, + renderPageSpecialError, + resolveSpecialError(error) { + return error === pageError + ? { + kind: "redirect", + location: "/target", + statusCode: 307, + } + : null; + }, + runWithSuppressedHookWarning(probe) { + return probe(); + }, + }); + + expect(renderPageSpecialError).toHaveBeenCalledWith({ + kind: "redirect", + location: "/target", + statusCode: 307, + }); + expect(response?.status).toBe(307); + await expect(response?.text()).resolves.toBe("page-fallback"); + }); + + it("does not await async page probes when a loading boundary is present", async () => { + const renderPageSpecialError = vi.fn(); + + const response = await probeAppPageBeforeRender({ + hasLoadingBoundary: true, + layoutCount: 0, + probeLayoutAt() { + throw new Error("should not probe layouts"); + }, + probePage() { + return Promise.reject(new Error("late page failure")); + }, + renderLayoutSpecialError() { + throw new Error("should not render a layout special error"); + }, + renderPageSpecialError, + resolveSpecialError() { + return { + kind: "http-access-fallback", + statusCode: 404, + }; + }, + runWithSuppressedHookWarning(probe) { + return probe(); + }, + }); + + expect(response).toBeNull(); + expect(renderPageSpecialError).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/app-router.test.ts b/tests/app-router.test.ts index c6150536..52ad1960 100644 --- a/tests/app-router.test.ts +++ b/tests/app-router.test.ts @@ -3701,14 +3701,14 @@ describe("generateRscEntry ISR code generation", () => { expect(code).toContain("resolveAppPageRscResponsePolicy as __resolveAppPageRscResponsePolicy"); }); - it("generated code delegates page probes and special-error handling to typed helpers", () => { + it("generated code delegates page probe orchestration to typed helpers", () => { const code = generateRscEntry("/tmp/test/app", minimalRoutes); - expect(code).toContain("probeAppPageLayouts as __probeAppPageLayouts"); - expect(code).toContain("probeAppPageComponent as __probeAppPageComponent"); + expect(code).toContain("probeAppPageBeforeRender as __probeAppPageBeforeRender"); expect(code).toContain("resolveAppPageSpecialError as __resolveAppPageSpecialError"); expect(code).toContain( "buildAppPageSpecialErrorResponse as __buildAppPageSpecialErrorResponse", ); + expect(code).toContain("const __preRenderResponse = await __probeAppPageBeforeRender({"); }); it("generated code delegates page HTML stream plumbing to typed helpers", () => { From faf248f6c7eef26a3ba18963b9ccb04578266252 Mon Sep 17 00:00:00 2001 From: Steve Faulkner Date: Sat, 21 Mar 2026 15:40:22 -0500 Subject: [PATCH 2/2] fix: handle SSR special errors in app page render --- packages/vinext/src/entries/app-rsc-entry.ts | 17 ++- .../entry-templates.test.ts.snap | 102 ++++++++++++++++-- tests/app-router.test.ts | 7 ++ 3 files changed, 119 insertions(+), 7 deletions(-) diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index a564984c..502cb80b 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -2726,7 +2726,22 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // Shell render complete; Suspense boundaries stream asynchronously if (process.env.NODE_ENV !== "production") __renderEnd = performance.now(); } catch (ssrErr) { - const specialResponse = await handleRenderError(ssrErr); + const __ssrSpecialError = __resolveAppPageSpecialError(ssrErr); + const specialResponse = __ssrSpecialError + ? await __buildAppPageSpecialErrorResponse({ + clearRequestContext() { + setHeadersContext(null); + setNavigationContext(null); + }, + renderFallbackPage(statusCode) { + return renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { + matchedParams: params, + }); + }, + requestUrl: request.url, + specialError: __ssrSpecialError, + }) + : null; if (specialResponse) return specialResponse; // Non-special error during SSR — render error.tsx if available const errorBoundaryResp = await renderErrorBoundaryPage(route, ssrErr, isRscRequest, request, params); diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index 8dbd94ac..39c2d401 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -2416,7 +2416,22 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // Shell render complete; Suspense boundaries stream asynchronously if (process.env.NODE_ENV !== "production") __renderEnd = performance.now(); } catch (ssrErr) { - const specialResponse = await handleRenderError(ssrErr); + const __ssrSpecialError = __resolveAppPageSpecialError(ssrErr); + const specialResponse = __ssrSpecialError + ? await __buildAppPageSpecialErrorResponse({ + clearRequestContext() { + setHeadersContext(null); + setNavigationContext(null); + }, + renderFallbackPage(statusCode) { + return renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { + matchedParams: params, + }); + }, + requestUrl: request.url, + specialError: __ssrSpecialError, + }) + : null; if (specialResponse) return specialResponse; // Non-special error during SSR — render error.tsx if available const errorBoundaryResp = await renderErrorBoundaryPage(route, ssrErr, isRscRequest, request, params); @@ -4925,7 +4940,22 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // Shell render complete; Suspense boundaries stream asynchronously if (process.env.NODE_ENV !== "production") __renderEnd = performance.now(); } catch (ssrErr) { - const specialResponse = await handleRenderError(ssrErr); + const __ssrSpecialError = __resolveAppPageSpecialError(ssrErr); + const specialResponse = __ssrSpecialError + ? await __buildAppPageSpecialErrorResponse({ + clearRequestContext() { + setHeadersContext(null); + setNavigationContext(null); + }, + renderFallbackPage(statusCode) { + return renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { + matchedParams: params, + }); + }, + requestUrl: request.url, + specialError: __ssrSpecialError, + }) + : null; if (specialResponse) return specialResponse; // Non-special error during SSR — render error.tsx if available const errorBoundaryResp = await renderErrorBoundaryPage(route, ssrErr, isRscRequest, request, params); @@ -7440,7 +7470,22 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // Shell render complete; Suspense boundaries stream asynchronously if (process.env.NODE_ENV !== "production") __renderEnd = performance.now(); } catch (ssrErr) { - const specialResponse = await handleRenderError(ssrErr); + const __ssrSpecialError = __resolveAppPageSpecialError(ssrErr); + const specialResponse = __ssrSpecialError + ? await __buildAppPageSpecialErrorResponse({ + clearRequestContext() { + setHeadersContext(null); + setNavigationContext(null); + }, + renderFallbackPage(statusCode) { + return renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { + matchedParams: params, + }); + }, + requestUrl: request.url, + specialError: __ssrSpecialError, + }) + : null; if (specialResponse) return specialResponse; // Non-special error during SSR — render error.tsx if available const errorBoundaryResp = await renderErrorBoundaryPage(route, ssrErr, isRscRequest, request, params); @@ -9988,7 +10033,22 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // Shell render complete; Suspense boundaries stream asynchronously if (process.env.NODE_ENV !== "production") __renderEnd = performance.now(); } catch (ssrErr) { - const specialResponse = await handleRenderError(ssrErr); + const __ssrSpecialError = __resolveAppPageSpecialError(ssrErr); + const specialResponse = __ssrSpecialError + ? await __buildAppPageSpecialErrorResponse({ + clearRequestContext() { + setHeadersContext(null); + setNavigationContext(null); + }, + renderFallbackPage(statusCode) { + return renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { + matchedParams: params, + }); + }, + requestUrl: request.url, + specialError: __ssrSpecialError, + }) + : null; if (specialResponse) return specialResponse; // Non-special error during SSR — render error.tsx if available const errorBoundaryResp = await renderErrorBoundaryPage(route, ssrErr, isRscRequest, request, params); @@ -12501,7 +12561,22 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // Shell render complete; Suspense boundaries stream asynchronously if (process.env.NODE_ENV !== "production") __renderEnd = performance.now(); } catch (ssrErr) { - const specialResponse = await handleRenderError(ssrErr); + const __ssrSpecialError = __resolveAppPageSpecialError(ssrErr); + const specialResponse = __ssrSpecialError + ? await __buildAppPageSpecialErrorResponse({ + clearRequestContext() { + setHeadersContext(null); + setNavigationContext(null); + }, + renderFallbackPage(statusCode) { + return renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { + matchedParams: params, + }); + }, + requestUrl: request.url, + specialError: __ssrSpecialError, + }) + : null; if (specialResponse) return specialResponse; // Non-special error during SSR — render error.tsx if available const errorBoundaryResp = await renderErrorBoundaryPage(route, ssrErr, isRscRequest, request, params); @@ -15367,7 +15442,22 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // Shell render complete; Suspense boundaries stream asynchronously if (process.env.NODE_ENV !== "production") __renderEnd = performance.now(); } catch (ssrErr) { - const specialResponse = await handleRenderError(ssrErr); + const __ssrSpecialError = __resolveAppPageSpecialError(ssrErr); + const specialResponse = __ssrSpecialError + ? await __buildAppPageSpecialErrorResponse({ + clearRequestContext() { + setHeadersContext(null); + setNavigationContext(null); + }, + renderFallbackPage(statusCode) { + return renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, request, { + matchedParams: params, + }); + }, + requestUrl: request.url, + specialError: __ssrSpecialError, + }) + : null; if (specialResponse) return specialResponse; // Non-special error during SSR — render error.tsx if available const errorBoundaryResp = await renderErrorBoundaryPage(route, ssrErr, isRscRequest, request, params); diff --git a/tests/app-router.test.ts b/tests/app-router.test.ts index 52ad1960..fb57470e 100644 --- a/tests/app-router.test.ts +++ b/tests/app-router.test.ts @@ -3711,6 +3711,13 @@ describe("generateRscEntry ISR code generation", () => { expect(code).toContain("const __preRenderResponse = await __probeAppPageBeforeRender({"); }); + it("generated code handles SSR special errors without a legacy handleRenderError helper", () => { + const code = generateRscEntry("/tmp/test/app", minimalRoutes); + expect(code).toContain("const __ssrSpecialError = __resolveAppPageSpecialError(ssrErr);"); + expect(code).toContain("specialError: __ssrSpecialError"); + expect(code).not.toContain("handleRenderError(ssrErr)"); + }); + it("generated code delegates page HTML stream plumbing to typed helpers", () => { const code = generateRscEntry("/tmp/test/app", minimalRoutes); expect(code).toContain("createAppPageFontData as __createAppPageFontData");