diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index 847f17076..246d4f0eb 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -59,6 +59,7 @@ const appRouteHandlerCachePath = resolveEntryPath( ); const appPageCachePath = resolveEntryPath("../server/app-page-cache.js", import.meta.url); const appPageExecutionPath = resolveEntryPath("../server/app-page-execution.js", import.meta.url); +const appPageBoundaryPath = resolveEntryPath("../server/app-page-boundary.js", import.meta.url); const appPageBoundaryRenderPath = resolveEntryPath( "../server/app-page-boundary-render.js", import.meta.url, @@ -68,6 +69,8 @@ const appPageRouteWiringPath = resolveEntryPath( "../server/app-page-route-wiring.js", import.meta.url, ); +const appPageHeadPath = resolveEntryPath("../server/app-page-head.js", import.meta.url); +const appPageParamsPath = resolveEntryPath("../server/app-page-params.js", import.meta.url); const appPageRenderPath = resolveEntryPath("../server/app-page-render.js", import.meta.url); const appPageResponsePath = resolveEntryPath("../server/app-page-response.js", import.meta.url); const cspPath = resolveEntryPath("../server/csp.js", import.meta.url); @@ -163,16 +166,23 @@ export function generateRscEntry( for (const tmpl of route.templates) getImportVar(tmpl); if (route.loadingPath) getImportVar(route.loadingPath); if (route.errorPath) getImportVar(route.errorPath); - if (route.layoutErrorPaths) + if (route.layoutErrorPaths) { for (const ep of route.layoutErrorPaths) { if (ep) getImportVar(ep); } + } if (route.notFoundPath) getImportVar(route.notFoundPath); for (const nfp of route.notFoundPaths || []) { if (nfp) getImportVar(nfp); } if (route.forbiddenPath) getImportVar(route.forbiddenPath); + for (const fp of route.forbiddenPaths || []) { + if (fp) getImportVar(fp); + } if (route.unauthorizedPath) getImportVar(route.unauthorizedPath); + for (const up of route.unauthorizedPaths || []) { + if (up) getImportVar(up); + } // Register parallel slot modules for (const slot of route.parallelSlots) { if (slot.pagePath) getImportVar(slot.pagePath); @@ -195,6 +205,12 @@ export function generateRscEntry( const layoutVars = route.layouts.map((l) => getImportVar(l)); const templateVars = route.templates.map((t) => getImportVar(t)); const notFoundVars = (route.notFoundPaths || []).map((nf) => (nf ? getImportVar(nf) : "null")); + const forbiddenVars = (route.forbiddenPaths || []).map((fp) => + fp ? getImportVar(fp) : "null", + ); + const unauthorizedVars = (route.unauthorizedPaths || []).map((up) => + up ? getImportVar(up) : "null", + ); const slotEntries = route.parallelSlots.map((slot) => { const interceptEntries = slot.interceptingRoutes.map( (ir) => ` { @@ -245,7 +261,9 @@ ${slotEntries.join(",\n")} notFound: ${route.notFoundPath ? getImportVar(route.notFoundPath) : "null"}, notFounds: [${notFoundVars.join(", ")}], forbidden: ${route.forbiddenPath ? getImportVar(route.forbiddenPath) : "null"}, + forbiddens: [${forbiddenVars.join(", ")}], unauthorized: ${route.unauthorizedPath ? getImportVar(route.unauthorizedPath) : "null"}, + unauthorizeds: [${unauthorizedVars.join(", ")}], }`; }); @@ -370,7 +388,6 @@ import { createElement } from "react"; import { setNavigationContext as _setNavigationContextOrig, getNavigationContext as _getNavigationContext } from "next/navigation"; import { setHeadersContext, headersContextFromRequest, getDraftModeCookieHeader, getAndClearPendingCookies, consumeDynamicUsage, markDynamicUsage, applyMiddlewareRequestHeaders, getHeadersContext, setHeadersAccessPhase } from "next/headers"; import { NextRequest, NextFetchEvent } from "next/server"; -import { mergeMetadata, resolveModuleMetadata, mergeViewport, resolveModuleViewport } from "vinext/metadata"; ${middlewarePath ? `import * as middlewareModule from ${JSON.stringify(middlewarePath.replace(/\\/g, "/"))};` : ""} ${instrumentationPath ? `import * as _instrumentation from ${JSON.stringify(instrumentationPath.replace(/\\/g, "/"))};` : ""} ${effectiveMetaRoutes.length > 0 ? `import { sitemapToXml, robotsToText, manifestToJson } from ${JSON.stringify(metadataRoutesPath)};` : ""} @@ -402,6 +419,9 @@ import { resolveAppPageSpecialError as __resolveAppPageSpecialError, teeAppPageRscStreamForCapture as __teeAppPageRscStreamForCapture, } from ${JSON.stringify(appPageExecutionPath)}; +import { + resolveAppPageParentHttpAccessBoundaryModule as __resolveAppPageParentHttpAccessBoundaryModule, +} from ${JSON.stringify(appPageBoundaryPath)}; import { renderAppPageErrorBoundary as __renderAppPageErrorBoundary, renderAppPageHttpAccessFallback as __renderAppPageHttpAccessFallback, @@ -415,6 +435,13 @@ import { createAppPageTreePath as __createAppPageTreePath, resolveAppPageChildSegments as __resolveAppPageChildSegments, } from ${JSON.stringify(appPageRouteWiringPath)}; +import { + resolveAppPageSegmentParams as __resolveAppPageSegmentParams, +} from ${JSON.stringify(appPageParamsPath)}; +import { + collectAppPageSearchParams as __collectAppPageSearchParams, + resolveAppPageHead as __resolveAppPageHead, +} from ${JSON.stringify(appPageHeadPath)}; import { renderAppPageLifecycle as __renderAppPageLifecycle, } from ${JSON.stringify(appPageRenderPath)}; @@ -1027,83 +1054,18 @@ async function buildPageElements(route, params, routePath, pageRequest) { }; } - // Resolve metadata and viewport from layouts and page. - // - // generateMetadata() accepts a "parent" (Promise of ResolvedMetadata) as its - // second argument (Next.js 13+). The parent resolves to the accumulated - // merged metadata of all ancestor segments, enabling patterns like: - // - // const previousImages = (await parent).openGraph?.images ?? [] - // return { openGraph: { images: ['/new-image.jpg', ...previousImages] } } - // - // Next.js uses an eager-execution-with-serial-resolution approach: - // all generateMetadata() calls are kicked off concurrently, but each - // segment's "parent" promise resolves only after the preceding segment's - // metadata is resolved and merged. This preserves concurrency for I/O-bound - // work while guaranteeing that parent data is available when needed. - // - // We build a chain: layoutParentPromises[0] = Promise.resolve({}) (no parent - // for root layout), layoutParentPromises[i+1] resolves to merge(layouts[0..i]), - // and pageParentPromise resolves to merge(all layouts). - // - // IMPORTANT: Layout metadata errors are swallowed (.catch(() => null)) because - // a layout's generateMetadata() failing should not crash the page. - // Page metadata errors are NOT swallowed — if the page's generateMetadata() - // throws, the error propagates out of buildPageElement() so the caller can - // route it to the nearest error.tsx boundary (or global-error.tsx). - const layoutMods = route.layouts.filter(Boolean); - - // Convert URLSearchParams → plain object for page generateMetadata() and - // pageProps.searchParams. Built before the layout loop so the page metadata - // call (below) and pageProps can reference the same object. - // NOTE: Layouts do NOT receive searchParams in generateMetadata() — only - // pages do. This matches Next.js behavior (resolve-metadata.ts:777). - const spObj = Object.create(null); - let hasSearchParams = false; - if (searchParams && searchParams.forEach) { - searchParams.forEach(function(v, k) { - hasSearchParams = true; - if (k in spObj) { - spObj[k] = Array.isArray(spObj[k]) ? spObj[k].concat(v) : [spObj[k], v]; - } else { - spObj[k] = v; - } - }); - } - - // Build the parent promise chain and kick off metadata resolution in one pass. - // Each layout module is called exactly once. layoutMetaPromises[i] is the - // promise for layout[i]'s own metadata result. - // - // All calls are kicked off immediately (concurrent I/O), but each layout's - // "parent" promise only resolves after the preceding layout's metadata is done. - const layoutMetaPromises = []; - let accumulatedMetaPromise = Promise.resolve({}); - for (let i = 0; i < layoutMods.length; i++) { - const parentForThisLayout = accumulatedMetaPromise; - // Kick off this layout's metadata resolution now (concurrent with others). - const metaPromise = resolveModuleMetadata(layoutMods[i], params, undefined, parentForThisLayout) - .catch((err) => { console.error("[vinext] Layout generateMetadata() failed:", err); return null; }); - layoutMetaPromises.push(metaPromise); - // Advance accumulator: resolves to merged(layouts[0..i]) once layout[i] is done. - accumulatedMetaPromise = metaPromise.then(async (result) => - result ? mergeMetadata([await parentForThisLayout, result]) : await parentForThisLayout - ); - } - // Page's parent is the fully-accumulated layout metadata. - const pageParentPromise = accumulatedMetaPromise; - - const [layoutMetaResults, layoutVpResults, pageMeta, pageVp] = await Promise.all([ - Promise.all(layoutMetaPromises), - Promise.all(layoutMods.map((mod) => resolveModuleViewport(mod, params).catch((err) => { console.error("[vinext] Layout generateViewport() failed:", err); return null; }))), - route.page ? resolveModuleMetadata(route.page, params, spObj, pageParentPromise) : Promise.resolve(null), - route.page ? resolveModuleViewport(route.page, params) : Promise.resolve(null), - ]); - - const metadataList = [...layoutMetaResults.filter(Boolean), ...(pageMeta ? [pageMeta] : [])]; - const viewportList = [...layoutVpResults.filter(Boolean), ...(pageVp ? [pageVp] : [])]; - const resolvedMetadata = metadataList.length > 0 ? mergeMetadata(metadataList) : null; - const resolvedViewport = mergeViewport(viewportList); + const __headResult = await __resolveAppPageHead({ + layoutModules: route.layouts, + layoutTreePositions: route.layoutTreePositions, + pageModule: route.page, + params, + routeSegments: route.routeSegments, + searchParams, + }); + const spObj = __headResult.searchParamsObject; + const hasSearchParams = __headResult.hasSearchParams; + const resolvedMetadata = __headResult.metadata; + const resolvedViewport = __headResult.viewport; // Build the route tree from the leaf page, then delegate the boundary/layout/ // template/segment wiring to a typed runtime helper so the generated entry @@ -2540,7 +2502,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // Note: CSS is automatically injected by @vitejs/plugin-rsc's // rscCssTransform — no manual loadCss() call needed. const _hasLoadingBoundary = !!(route.loading && route.loading.default); - const _asyncLayoutParams = makeThenableParams(params); + const _asyncRouteParams = makeThenableParams(params); return __renderAppPageLifecycle({ cleanPathname, clearRequestContext() { @@ -2583,22 +2545,21 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { probeLayoutAt(li) { const LayoutComp = route.layouts[li]?.default; if (!LayoutComp) return null; - return LayoutComp({ params: _asyncLayoutParams, children: null }); + return LayoutComp({ + params: makeThenableParams(__resolveAppPageSegmentParams( + route.routeSegments, + route.layoutTreePositions?.[li] ?? 0, + params, + )), + children: null, + }); }, probePage() { if (!PageComponent) return null; - const _probeSearchObj = {}; - url.searchParams.forEach(function(v, k) { - if (k in _probeSearchObj) { - _probeSearchObj[k] = Array.isArray(_probeSearchObj[k]) - ? _probeSearchObj[k].concat(v) - : [_probeSearchObj[k], v]; - } else { - _probeSearchObj[k] = v; - } - }); - const _asyncSearchParams = makeThenableParams(_probeSearchObj); - return PageComponent({ params: _asyncLayoutParams, searchParams: _asyncSearchParams }); + const _asyncSearchParams = makeThenableParams( + __collectAppPageSearchParams(url.searchParams).searchParamsObject, + ); + return PageComponent({ params: _asyncRouteParams, searchParams: _asyncSearchParams }); }, classification: { getLayoutId(index) { @@ -2633,19 +2594,16 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }, middlewareContext: _mwCtx, 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 parentBoundary = __resolveAppPageParentHttpAccessBoundaryModule({ + layoutIndex: li, + rootForbiddenModule: ${rootForbiddenVar ?? "null"}, + rootNotFoundModule: ${rootNotFoundVar ?? "null"}, + rootUnauthorizedModule: ${rootUnauthorizedVar ?? "null"}, + routeForbiddenModules: route.forbiddens, + routeNotFoundModules: route.notFounds, + routeUnauthorizedModules: route.unauthorizeds, + statusCode, + })?.default ?? null; const parentLayouts = route.layouts.slice(0, li); return renderHTTPAccessFallbackPage( route, @@ -2653,7 +2611,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { isRscRequest, request, { - boundaryComponent: parentNotFound, + boundaryComponent: parentBoundary, layouts: parentLayouts, matchedParams: params, }, diff --git a/packages/vinext/src/routing/app-router.ts b/packages/vinext/src/routing/app-router.ts index 143ae1d81..c5662d7bd 100644 --- a/packages/vinext/src/routing/app-router.ts +++ b/packages/vinext/src/routing/app-router.ts @@ -108,8 +108,12 @@ export type AppRoute = { notFoundPaths: (string | null)[]; /** Forbidden component path (403) */ forbiddenPath: string | null; + /** Forbidden component paths per layout level (aligned with layouts array). */ + forbiddenPaths?: (string | null)[]; /** Unauthorized component path (401) */ unauthorizedPath: string | null; + /** Unauthorized component paths per layout level (aligned with layouts array). */ + unauthorizedPaths?: (string | null)[]; /** * Filesystem segments from app/ root to the route's directory. * Includes route groups and dynamic segments (as template strings like "[id]"). @@ -387,7 +391,9 @@ function discoverSlotSubRoutes( notFoundPath: parentRoute.notFoundPath, notFoundPaths: parentRoute.notFoundPaths, forbiddenPath: parentRoute.forbiddenPath, + forbiddenPaths: parentRoute.forbiddenPaths, unauthorizedPath: parentRoute.unauthorizedPath, + unauthorizedPaths: parentRoute.unauthorizedPaths, routeSegments: [...parentRoute.routeSegments, ...rawSegments], templateTreePositions: parentRoute.templateTreePositions, layoutTreePositions: parentRoute.layoutTreePositions, @@ -508,6 +514,8 @@ function directoryToAppRoute( // These are used for per-layout NotFoundBoundary to match Next.js behavior where // notFound() thrown from a layout is caught by the parent layout's boundary. const notFoundPaths = discoverBoundaryFilePerLayout(layouts, "not-found", matcher); + const forbiddenPaths = discoverBoundaryFilePerLayout(layouts, "forbidden", matcher); + const unauthorizedPaths = discoverBoundaryFilePerLayout(layouts, "unauthorized", matcher); // Discover parallel slots (@team, @analytics, etc.). // Slots at the route's own directory use page.tsx; slots at ancestor directories @@ -527,7 +535,9 @@ function directoryToAppRoute( notFoundPath, notFoundPaths, forbiddenPath, + forbiddenPaths, unauthorizedPath, + unauthorizedPaths, routeSegments: segments, templateTreePositions, layoutTreePositions, diff --git a/packages/vinext/src/server/app-page-boundary-render.ts b/packages/vinext/src/server/app-page-boundary-render.ts index 7234665ba..736c82823 100644 --- a/packages/vinext/src/server/app-page-boundary-render.ts +++ b/packages/vinext/src/server/app-page-boundary-render.ts @@ -2,16 +2,7 @@ import { Fragment, createElement, type ComponentType, type ReactNode } from "rea import { buildClientHookErrorMessage } from "../shims/client-hook-error.js"; import { ErrorBoundary } from "../shims/error-boundary.js"; import { LayoutSegmentProvider } from "../shims/layout-segment-context.js"; -import { - MetadataHead, - ViewportHead, - mergeMetadata, - mergeViewport, - resolveModuleMetadata, - resolveModuleViewport, - type Metadata, - type Viewport, -} from "../shims/metadata.js"; +import { MetadataHead, ViewportHead } from "../shims/metadata.js"; import type { AppPageFontPreload } from "./app-page-execution.js"; import type { AppPageMiddlewareContext } from "./app-page-response.js"; import { @@ -33,6 +24,7 @@ import { createAppPayloadRouteId, type AppElements, } from "./app-elements.js"; +import { resolveAppPageHead } from "./app-page-head.js"; import { createAppPageLayoutEntries } from "./app-page-route-wiring.js"; // oxlint-disable-next-line @typescript-eslint/no-explicit-any @@ -116,55 +108,6 @@ function getDefaultExport( return module?.default ?? null; } -async function resolveAppPageLayoutHead( - layoutModules: readonly (TModule | null | undefined)[], - params: AppPageParams, -): Promise<{ metadata: Metadata | null; viewport: Viewport }> { - const filteredLayouts = layoutModules.filter(Boolean) as TModule[]; - const layoutMetadataPromises: Promise[] = []; - let accumulatedMetadata = Promise.resolve({}); - - for (let index = 0; index < filteredLayouts.length; index++) { - const parentForLayout = accumulatedMetadata; - const metadataPromise = resolveModuleMetadata( - filteredLayouts[index], - params, - undefined, - parentForLayout, - ).catch((error) => { - console.error("[vinext] Layout generateMetadata() failed:", error); - return null; - }); - layoutMetadataPromises.push(metadataPromise); - accumulatedMetadata = metadataPromise.then(async (metadataResult) => { - if (metadataResult) { - return mergeMetadata([await parentForLayout, metadataResult]); - } - return parentForLayout; - }); - } - - const [metadataResults, viewportResults] = await Promise.all([ - Promise.all(layoutMetadataPromises), - Promise.all( - filteredLayouts.map((layoutModule) => - resolveModuleViewport(layoutModule, params).catch((error) => { - console.error("[vinext] Layout generateViewport() failed:", error); - return null; - }), - ), - ), - ]); - - const metadataList = metadataResults.filter(Boolean) as Metadata[]; - const viewportList = viewportResults.filter(Boolean) as Viewport[]; - - return { - metadata: metadataList.length > 0 ? mergeMetadata(metadataList) : null, - viewport: mergeViewport(viewportList), - }; -} - function wrapRenderedBoundaryElement( options: Pick< AppPageBoundaryRenderCommonOptions, @@ -319,10 +262,12 @@ export async function renderAppPageHttpAccessFallback; @@ -13,6 +14,17 @@ type ResolveAppPageHttpAccessBoundaryComponentOptions = { statusCode: number; }; +type ResolveAppPageParentHttpAccessBoundaryModuleOptions = { + layoutIndex: number; + rootForbiddenModule?: TModule | null; + rootNotFoundModule?: TModule | null; + rootUnauthorizedModule?: TModule | null; + routeForbiddenModules?: readonly (TModule | null | undefined)[] | null; + routeNotFoundModules?: readonly (TModule | null | undefined)[] | null; + routeUnauthorizedModules?: readonly (TModule | null | undefined)[] | null; + statusCode: number; +}; + type ResolveAppPageErrorBoundaryOptions = { getDefaultExport: (module: TModule | null | undefined) => TComponent | null | undefined; globalErrorModule?: TModule | null; @@ -93,6 +105,32 @@ export function resolveAppPageHttpAccessBoundaryComponent( return options.getDefaultExport(boundaryModule) ?? null; } +export function resolveAppPageParentHttpAccessBoundaryModule( + options: ResolveAppPageParentHttpAccessBoundaryModuleOptions, +): TModule | null { + let routeModules = options.routeNotFoundModules; + let rootModule = options.rootNotFoundModule; + + if (options.statusCode === 403) { + routeModules = options.routeForbiddenModules; + rootModule = options.rootForbiddenModule; + } else if (options.statusCode === 401) { + routeModules = options.routeUnauthorizedModules; + rootModule = options.rootUnauthorizedModule; + } + + if (routeModules) { + for (let index = options.layoutIndex - 1; index >= 0; index--) { + const module = routeModules[index]; + if (module) { + return module; + } + } + } + + return rootModule ?? null; +} + export function resolveAppPageErrorBoundary( options: ResolveAppPageErrorBoundaryOptions, ): ResolveAppPageErrorBoundaryResult { @@ -141,14 +179,16 @@ export function wrapAppPageBoundaryElement< let element = options.element; if (!options.skipLayoutWrapping) { - const asyncParams = options.makeThenableParams(options.matchedParams); - for (let index = options.layoutModules.length - 1; index >= 0; index--) { const layoutComponent = options.getDefaultExport(options.layoutModules[index]); if (!layoutComponent) { continue; } + const treePosition = options.layoutTreePositions ? options.layoutTreePositions[index] : 0; + const asyncParams = options.makeThenableParams( + resolveAppPageSegmentParams(options.routeSegments, treePosition, options.matchedParams), + ); element = options.renderLayout(layoutComponent, element, asyncParams); if ( @@ -156,7 +196,6 @@ export function wrapAppPageBoundaryElement< options.renderLayoutSegmentProvider && options.resolveChildSegments ) { - const treePosition = options.layoutTreePositions ? options.layoutTreePositions[index] : 0; const childSegments = options.resolveChildSegments( options.routeSegments ?? [], treePosition, diff --git a/packages/vinext/src/server/app-page-head.ts b/packages/vinext/src/server/app-page-head.ts new file mode 100644 index 000000000..feb042470 --- /dev/null +++ b/packages/vinext/src/server/app-page-head.ts @@ -0,0 +1,141 @@ +import { + mergeMetadata, + mergeViewport, + resolveModuleMetadata, + resolveModuleViewport, + type Metadata, + type Viewport, +} from "../shims/metadata.js"; +import type { AppPageParams } from "./app-page-boundary.js"; +import { resolveAppPageSegmentParams } from "./app-page-params.js"; + +type AppPageHeadModule = Record; +type AppPageSearchParamsObject = Record; + +type ResolveAppPageHeadOptions = { + layoutModules: readonly (TModule | null | undefined)[]; + layoutTreePositions?: readonly number[] | null; + pageModule?: TModule | null; + params: AppPageParams; + routeSegments?: readonly string[] | null; + searchParams?: URLSearchParams | null; +}; + +type ResolveAppPageHeadResult = { + hasSearchParams: boolean; + metadata: Metadata | null; + searchParamsObject: AppPageSearchParamsObject; + viewport: Viewport; +}; + +type AppPageSearchParamsCollection = { + hasSearchParams: boolean; + searchParamsObject: AppPageSearchParamsObject; +}; + +function isMetadata(value: Metadata | null): value is Metadata { + return value !== null; +} + +function isViewport(value: Viewport | null): value is Viewport { + return value !== null; +} + +export function collectAppPageSearchParams( + searchParams: URLSearchParams | null | undefined, +): AppPageSearchParamsCollection { + const searchParamsObject: AppPageSearchParamsObject = Object.create(null); + let hasSearchParams = false; + + if (!searchParams) { + return { hasSearchParams, searchParamsObject }; + } + + searchParams.forEach((value, key) => { + hasSearchParams = true; + const current = searchParamsObject[key]; + if (Array.isArray(current)) { + searchParamsObject[key] = [...current, value]; + return; + } + if (current !== undefined) { + searchParamsObject[key] = [current, value]; + return; + } + searchParamsObject[key] = value; + }); + + return { hasSearchParams, searchParamsObject }; +} + +export async function resolveAppPageHead( + options: ResolveAppPageHeadOptions, +): Promise { + const { hasSearchParams, searchParamsObject } = collectAppPageSearchParams(options.searchParams); + const layoutMetadataPromises: Promise[] = []; + const layoutViewportPromises: Promise[] = []; + let accumulatedMetadata = Promise.resolve({}); + + for (let index = 0; index < options.layoutModules.length; index++) { + const layoutModule = options.layoutModules[index]; + if (!layoutModule) { + continue; + } + + const parentForLayout = accumulatedMetadata; + const layoutParams = resolveAppPageSegmentParams( + options.routeSegments, + options.layoutTreePositions?.[index] ?? 0, + options.params, + ); + const metadataPromise = resolveModuleMetadata( + layoutModule, + layoutParams, + undefined, + parentForLayout, + ); + layoutMetadataPromises.push(metadataPromise); + layoutViewportPromises.push(resolveModuleViewport(layoutModule, layoutParams)); + accumulatedMetadata = metadataPromise.then(async (metadataResult) => + metadataResult ? mergeMetadata([await parentForLayout, metadataResult]) : parentForLayout, + ); + // This parent chain can reject before a page's generateMetadata awaits it. + // The original metadataPromise remains in Promise.all below, so errors still + // propagate while avoiding process-level unhandled rejections. + void accumulatedMetadata.catch(() => null); + } + + const pageParentPromise = accumulatedMetadata; + const [layoutMetadataResults, layoutViewportResults, pageMetadata, pageViewport] = + await Promise.all([ + Promise.all(layoutMetadataPromises), + Promise.all(layoutViewportPromises), + options.pageModule + ? resolveModuleMetadata( + options.pageModule, + options.params, + searchParamsObject, + pageParentPromise, + ) + : Promise.resolve(null), + options.pageModule + ? resolveModuleViewport(options.pageModule, options.params) + : Promise.resolve(null), + ]); + + const metadataList = [ + ...layoutMetadataResults.filter(isMetadata), + ...(pageMetadata ? [pageMetadata] : []), + ]; + const viewportList = [ + ...layoutViewportResults.filter(isViewport), + ...(pageViewport ? [pageViewport] : []), + ]; + + return { + hasSearchParams, + metadata: metadataList.length > 0 ? mergeMetadata(metadataList) : null, + searchParamsObject, + viewport: mergeViewport(viewportList), + }; +} diff --git a/packages/vinext/src/server/app-page-params.ts b/packages/vinext/src/server/app-page-params.ts new file mode 100644 index 000000000..3dcd75263 --- /dev/null +++ b/packages/vinext/src/server/app-page-params.ts @@ -0,0 +1,53 @@ +import type { AppPageParams } from "./app-page-boundary.js"; + +function getSegmentParamName(segment: string): string | null { + if (segment.startsWith("[[...") && segment.endsWith("]]") && segment.length > 7) { + return segment.slice(5, -2); + } + + if (segment.startsWith("[...") && segment.endsWith("]") && segment.length > 5) { + return segment.slice(4, -1); + } + + if ( + segment.startsWith("[") && + segment.endsWith("]") && + !segment.includes(".") && + segment.length > 2 + ) { + return segment.slice(1, -1); + } + + return null; +} + +function isEmptyOptionalCatchAll(segment: string, paramValue: string | string[]): boolean { + return segment.startsWith("[[...") && Array.isArray(paramValue) && paramValue.length === 0; +} + +export function resolveAppPageSegmentParams( + routeSegments: readonly string[] | null | undefined, + treePosition: number, + matchedParams: AppPageParams, +): AppPageParams { + const segmentParams: AppPageParams = {}; + const segments = routeSegments ?? []; + const end = Math.min(Math.max(treePosition, 0), segments.length); + + for (let index = 0; index < end; index++) { + const segment = segments[index]; + const paramName = getSegmentParamName(segment); + if (!paramName) { + continue; + } + + const paramValue = matchedParams[paramName]; + if (paramValue === undefined || isEmptyOptionalCatchAll(segment, paramValue)) { + continue; + } + + segmentParams[paramName] = paramValue; + } + + return segmentParams; +} diff --git a/packages/vinext/src/server/app-page-route-wiring.tsx b/packages/vinext/src/server/app-page-route-wiring.tsx index e08ce2ce1..fd2ba12d0 100644 --- a/packages/vinext/src/server/app-page-route-wiring.tsx +++ b/packages/vinext/src/server/app-page-route-wiring.tsx @@ -19,6 +19,7 @@ import { renderWithAppDependencyBarrier, type AppRenderDependency, } from "./app-render-dependency.js"; +import { resolveAppPageSegmentParams } from "./app-page-params.js"; type AppPageComponentProps = { children?: ReactNode; @@ -329,7 +330,6 @@ export function buildAppPageElements< const templateDependenciesById = new Map(); const templateDependenciesBeforeById = new Map(); const pageDependencies: AppRenderDependency[] = []; - const routeThenableParams = options.makeThenableParams(options.matchedParams); const rootLayoutTreePath = layoutEntries[0]?.treePath ?? null; const slotNameCounts = new Map(); for (const slot of Object.values(options.route.slots ?? {})) { @@ -421,7 +421,13 @@ export function buildAppPageElements< } const layoutProps: Record = { - params: routeThenableParams, + params: options.makeThenableParams( + resolveAppPageSegmentParams( + options.route.routeSegments, + layoutEntry.treePosition, + options.matchedParams, + ), + ), }; for (const slot of Object.values(options.route.slots ?? {})) { diff --git a/packages/vinext/src/shims/metadata.tsx b/packages/vinext/src/shims/metadata.tsx index bb1c0acc4..3f3b2b424 100644 --- a/packages/vinext/src/shims/metadata.tsx +++ b/packages/vinext/src/shims/metadata.tsx @@ -354,7 +354,7 @@ export function mergeMetadata(metadataList: Metadata[]): Metadata { export async function resolveModuleMetadata( mod: Record, params: Record = {}, - searchParams?: Record, + searchParams?: Record, parent: Promise = Promise.resolve({}), ): Promise { if (typeof mod.generateMetadata === "function") { diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index 984653084..62ee1c06a 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -46,7 +46,6 @@ import { createElement } from "react"; import { setNavigationContext as _setNavigationContextOrig, getNavigationContext as _getNavigationContext } from "next/navigation"; import { setHeadersContext, headersContextFromRequest, getDraftModeCookieHeader, getAndClearPendingCookies, consumeDynamicUsage, markDynamicUsage, applyMiddlewareRequestHeaders, getHeadersContext, setHeadersAccessPhase } from "next/headers"; import { NextRequest, NextFetchEvent } from "next/server"; -import { mergeMetadata, resolveModuleMetadata, mergeViewport, resolveModuleViewport } from "vinext/metadata"; @@ -78,6 +77,9 @@ import { resolveAppPageSpecialError as __resolveAppPageSpecialError, teeAppPageRscStreamForCapture as __teeAppPageRscStreamForCapture, } from "/packages/vinext/src/server/app-page-execution.js"; +import { + resolveAppPageParentHttpAccessBoundaryModule as __resolveAppPageParentHttpAccessBoundaryModule, +} from "/packages/vinext/src/server/app-page-boundary.js"; import { renderAppPageErrorBoundary as __renderAppPageErrorBoundary, renderAppPageHttpAccessFallback as __renderAppPageHttpAccessFallback, @@ -91,6 +93,13 @@ import { createAppPageTreePath as __createAppPageTreePath, resolveAppPageChildSegments as __resolveAppPageChildSegments, } from "/packages/vinext/src/server/app-page-route-wiring.js"; +import { + resolveAppPageSegmentParams as __resolveAppPageSegmentParams, +} from "/packages/vinext/src/server/app-page-params.js"; +import { + collectAppPageSearchParams as __collectAppPageSearchParams, + resolveAppPageHead as __resolveAppPageHead, +} from "/packages/vinext/src/server/app-page-head.js"; import { renderAppPageLifecycle as __renderAppPageLifecycle, } from "/packages/vinext/src/server/app-page-render.js"; @@ -464,7 +473,9 @@ const routes = [ notFound: null, notFounds: [null], forbidden: null, + forbiddens: [], unauthorized: null, + unauthorizeds: [], }, { __buildTimeClassifications: __VINEXT_CLASS(1), // evaluated once at module load @@ -489,7 +500,9 @@ const routes = [ notFound: null, notFounds: [null], forbidden: null, + forbiddens: [], unauthorized: null, + unauthorizeds: [], }, { __buildTimeClassifications: __VINEXT_CLASS(2), // evaluated once at module load @@ -514,7 +527,9 @@ const routes = [ notFound: null, notFounds: [null, null], forbidden: null, + forbiddens: [], unauthorized: null, + unauthorizeds: [], }, { __buildTimeClassifications: __VINEXT_CLASS(3), // evaluated once at module load @@ -539,7 +554,9 @@ const routes = [ notFound: mod_10, notFounds: [null, mod_10], forbidden: null, + forbiddens: [], unauthorized: null, + unauthorizeds: [], } ]; const _routeTrie = _buildRouteTrie(routes); @@ -778,83 +795,18 @@ async function buildPageElements(route, params, routePath, pageRequest) { }; } - // Resolve metadata and viewport from layouts and page. - // - // generateMetadata() accepts a "parent" (Promise of ResolvedMetadata) as its - // second argument (Next.js 13+). The parent resolves to the accumulated - // merged metadata of all ancestor segments, enabling patterns like: - // - // const previousImages = (await parent).openGraph?.images ?? [] - // return { openGraph: { images: ['/new-image.jpg', ...previousImages] } } - // - // Next.js uses an eager-execution-with-serial-resolution approach: - // all generateMetadata() calls are kicked off concurrently, but each - // segment's "parent" promise resolves only after the preceding segment's - // metadata is resolved and merged. This preserves concurrency for I/O-bound - // work while guaranteeing that parent data is available when needed. - // - // We build a chain: layoutParentPromises[0] = Promise.resolve({}) (no parent - // for root layout), layoutParentPromises[i+1] resolves to merge(layouts[0..i]), - // and pageParentPromise resolves to merge(all layouts). - // - // IMPORTANT: Layout metadata errors are swallowed (.catch(() => null)) because - // a layout's generateMetadata() failing should not crash the page. - // Page metadata errors are NOT swallowed — if the page's generateMetadata() - // throws, the error propagates out of buildPageElement() so the caller can - // route it to the nearest error.tsx boundary (or global-error.tsx). - const layoutMods = route.layouts.filter(Boolean); - - // Convert URLSearchParams → plain object for page generateMetadata() and - // pageProps.searchParams. Built before the layout loop so the page metadata - // call (below) and pageProps can reference the same object. - // NOTE: Layouts do NOT receive searchParams in generateMetadata() — only - // pages do. This matches Next.js behavior (resolve-metadata.ts:777). - const spObj = Object.create(null); - let hasSearchParams = false; - if (searchParams && searchParams.forEach) { - searchParams.forEach(function(v, k) { - hasSearchParams = true; - if (k in spObj) { - spObj[k] = Array.isArray(spObj[k]) ? spObj[k].concat(v) : [spObj[k], v]; - } else { - spObj[k] = v; - } - }); - } - - // Build the parent promise chain and kick off metadata resolution in one pass. - // Each layout module is called exactly once. layoutMetaPromises[i] is the - // promise for layout[i]'s own metadata result. - // - // All calls are kicked off immediately (concurrent I/O), but each layout's - // "parent" promise only resolves after the preceding layout's metadata is done. - const layoutMetaPromises = []; - let accumulatedMetaPromise = Promise.resolve({}); - for (let i = 0; i < layoutMods.length; i++) { - const parentForThisLayout = accumulatedMetaPromise; - // Kick off this layout's metadata resolution now (concurrent with others). - const metaPromise = resolveModuleMetadata(layoutMods[i], params, undefined, parentForThisLayout) - .catch((err) => { console.error("[vinext] Layout generateMetadata() failed:", err); return null; }); - layoutMetaPromises.push(metaPromise); - // Advance accumulator: resolves to merged(layouts[0..i]) once layout[i] is done. - accumulatedMetaPromise = metaPromise.then(async (result) => - result ? mergeMetadata([await parentForThisLayout, result]) : await parentForThisLayout - ); - } - // Page's parent is the fully-accumulated layout metadata. - const pageParentPromise = accumulatedMetaPromise; - - const [layoutMetaResults, layoutVpResults, pageMeta, pageVp] = await Promise.all([ - Promise.all(layoutMetaPromises), - Promise.all(layoutMods.map((mod) => resolveModuleViewport(mod, params).catch((err) => { console.error("[vinext] Layout generateViewport() failed:", err); return null; }))), - route.page ? resolveModuleMetadata(route.page, params, spObj, pageParentPromise) : Promise.resolve(null), - route.page ? resolveModuleViewport(route.page, params) : Promise.resolve(null), - ]); - - const metadataList = [...layoutMetaResults.filter(Boolean), ...(pageMeta ? [pageMeta] : [])]; - const viewportList = [...layoutVpResults.filter(Boolean), ...(pageVp ? [pageVp] : [])]; - const resolvedMetadata = metadataList.length > 0 ? mergeMetadata(metadataList) : null; - const resolvedViewport = mergeViewport(viewportList); + const __headResult = await __resolveAppPageHead({ + layoutModules: route.layouts, + layoutTreePositions: route.layoutTreePositions, + pageModule: route.page, + params, + routeSegments: route.routeSegments, + searchParams, + }); + const spObj = __headResult.searchParamsObject; + const hasSearchParams = __headResult.hasSearchParams; + const resolvedMetadata = __headResult.metadata; + const resolvedViewport = __headResult.viewport; // Build the route tree from the leaf page, then delegate the boundary/layout/ // template/segment wiring to a typed runtime helper so the generated entry @@ -2214,7 +2166,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // Note: CSS is automatically injected by @vitejs/plugin-rsc's // rscCssTransform — no manual loadCss() call needed. const _hasLoadingBoundary = !!(route.loading && route.loading.default); - const _asyncLayoutParams = makeThenableParams(params); + const _asyncRouteParams = makeThenableParams(params); return __renderAppPageLifecycle({ cleanPathname, clearRequestContext() { @@ -2257,22 +2209,21 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { probeLayoutAt(li) { const LayoutComp = route.layouts[li]?.default; if (!LayoutComp) return null; - return LayoutComp({ params: _asyncLayoutParams, children: null }); + return LayoutComp({ + params: makeThenableParams(__resolveAppPageSegmentParams( + route.routeSegments, + route.layoutTreePositions?.[li] ?? 0, + params, + )), + children: null, + }); }, probePage() { if (!PageComponent) return null; - const _probeSearchObj = {}; - url.searchParams.forEach(function(v, k) { - if (k in _probeSearchObj) { - _probeSearchObj[k] = Array.isArray(_probeSearchObj[k]) - ? _probeSearchObj[k].concat(v) - : [_probeSearchObj[k], v]; - } else { - _probeSearchObj[k] = v; - } - }); - const _asyncSearchParams = makeThenableParams(_probeSearchObj); - return PageComponent({ params: _asyncLayoutParams, searchParams: _asyncSearchParams }); + const _asyncSearchParams = makeThenableParams( + __collectAppPageSearchParams(url.searchParams).searchParamsObject, + ); + return PageComponent({ params: _asyncRouteParams, searchParams: _asyncSearchParams }); }, classification: { getLayoutId(index) { @@ -2307,19 +2258,16 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }, middlewareContext: _mwCtx, 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 parentBoundary = __resolveAppPageParentHttpAccessBoundaryModule({ + layoutIndex: li, + rootForbiddenModule: null, + rootNotFoundModule: null, + rootUnauthorizedModule: null, + routeForbiddenModules: route.forbiddens, + routeNotFoundModules: route.notFounds, + routeUnauthorizedModules: route.unauthorizeds, + statusCode, + })?.default ?? null; const parentLayouts = route.layouts.slice(0, li); return renderHTTPAccessFallbackPage( route, @@ -2327,7 +2275,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { isRscRequest, request, { - boundaryComponent: parentNotFound, + boundaryComponent: parentBoundary, layouts: parentLayouts, matchedParams: params, }, @@ -2435,7 +2383,6 @@ import { createElement } from "react"; import { setNavigationContext as _setNavigationContextOrig, getNavigationContext as _getNavigationContext } from "next/navigation"; import { setHeadersContext, headersContextFromRequest, getDraftModeCookieHeader, getAndClearPendingCookies, consumeDynamicUsage, markDynamicUsage, applyMiddlewareRequestHeaders, getHeadersContext, setHeadersAccessPhase } from "next/headers"; import { NextRequest, NextFetchEvent } from "next/server"; -import { mergeMetadata, resolveModuleMetadata, mergeViewport, resolveModuleViewport } from "vinext/metadata"; @@ -2467,6 +2414,9 @@ import { resolveAppPageSpecialError as __resolveAppPageSpecialError, teeAppPageRscStreamForCapture as __teeAppPageRscStreamForCapture, } from "/packages/vinext/src/server/app-page-execution.js"; +import { + resolveAppPageParentHttpAccessBoundaryModule as __resolveAppPageParentHttpAccessBoundaryModule, +} from "/packages/vinext/src/server/app-page-boundary.js"; import { renderAppPageErrorBoundary as __renderAppPageErrorBoundary, renderAppPageHttpAccessFallback as __renderAppPageHttpAccessFallback, @@ -2480,6 +2430,13 @@ import { createAppPageTreePath as __createAppPageTreePath, resolveAppPageChildSegments as __resolveAppPageChildSegments, } from "/packages/vinext/src/server/app-page-route-wiring.js"; +import { + resolveAppPageSegmentParams as __resolveAppPageSegmentParams, +} from "/packages/vinext/src/server/app-page-params.js"; +import { + collectAppPageSearchParams as __collectAppPageSearchParams, + resolveAppPageHead as __resolveAppPageHead, +} from "/packages/vinext/src/server/app-page-head.js"; import { renderAppPageLifecycle as __renderAppPageLifecycle, } from "/packages/vinext/src/server/app-page-render.js"; @@ -2853,7 +2810,9 @@ const routes = [ notFound: null, notFounds: [null], forbidden: null, + forbiddens: [], unauthorized: null, + unauthorizeds: [], }, { __buildTimeClassifications: __VINEXT_CLASS(1), // evaluated once at module load @@ -2878,7 +2837,9 @@ const routes = [ notFound: null, notFounds: [null], forbidden: null, + forbiddens: [], unauthorized: null, + unauthorizeds: [], }, { __buildTimeClassifications: __VINEXT_CLASS(2), // evaluated once at module load @@ -2903,7 +2864,9 @@ const routes = [ notFound: null, notFounds: [null, null], forbidden: null, + forbiddens: [], unauthorized: null, + unauthorizeds: [], }, { __buildTimeClassifications: __VINEXT_CLASS(3), // evaluated once at module load @@ -2928,7 +2891,9 @@ const routes = [ notFound: mod_10, notFounds: [null, mod_10], forbidden: null, + forbiddens: [], unauthorized: null, + unauthorizeds: [], } ]; const _routeTrie = _buildRouteTrie(routes); @@ -3167,83 +3132,18 @@ async function buildPageElements(route, params, routePath, pageRequest) { }; } - // Resolve metadata and viewport from layouts and page. - // - // generateMetadata() accepts a "parent" (Promise of ResolvedMetadata) as its - // second argument (Next.js 13+). The parent resolves to the accumulated - // merged metadata of all ancestor segments, enabling patterns like: - // - // const previousImages = (await parent).openGraph?.images ?? [] - // return { openGraph: { images: ['/new-image.jpg', ...previousImages] } } - // - // Next.js uses an eager-execution-with-serial-resolution approach: - // all generateMetadata() calls are kicked off concurrently, but each - // segment's "parent" promise resolves only after the preceding segment's - // metadata is resolved and merged. This preserves concurrency for I/O-bound - // work while guaranteeing that parent data is available when needed. - // - // We build a chain: layoutParentPromises[0] = Promise.resolve({}) (no parent - // for root layout), layoutParentPromises[i+1] resolves to merge(layouts[0..i]), - // and pageParentPromise resolves to merge(all layouts). - // - // IMPORTANT: Layout metadata errors are swallowed (.catch(() => null)) because - // a layout's generateMetadata() failing should not crash the page. - // Page metadata errors are NOT swallowed — if the page's generateMetadata() - // throws, the error propagates out of buildPageElement() so the caller can - // route it to the nearest error.tsx boundary (or global-error.tsx). - const layoutMods = route.layouts.filter(Boolean); - - // Convert URLSearchParams → plain object for page generateMetadata() and - // pageProps.searchParams. Built before the layout loop so the page metadata - // call (below) and pageProps can reference the same object. - // NOTE: Layouts do NOT receive searchParams in generateMetadata() — only - // pages do. This matches Next.js behavior (resolve-metadata.ts:777). - const spObj = Object.create(null); - let hasSearchParams = false; - if (searchParams && searchParams.forEach) { - searchParams.forEach(function(v, k) { - hasSearchParams = true; - if (k in spObj) { - spObj[k] = Array.isArray(spObj[k]) ? spObj[k].concat(v) : [spObj[k], v]; - } else { - spObj[k] = v; - } - }); - } - - // Build the parent promise chain and kick off metadata resolution in one pass. - // Each layout module is called exactly once. layoutMetaPromises[i] is the - // promise for layout[i]'s own metadata result. - // - // All calls are kicked off immediately (concurrent I/O), but each layout's - // "parent" promise only resolves after the preceding layout's metadata is done. - const layoutMetaPromises = []; - let accumulatedMetaPromise = Promise.resolve({}); - for (let i = 0; i < layoutMods.length; i++) { - const parentForThisLayout = accumulatedMetaPromise; - // Kick off this layout's metadata resolution now (concurrent with others). - const metaPromise = resolveModuleMetadata(layoutMods[i], params, undefined, parentForThisLayout) - .catch((err) => { console.error("[vinext] Layout generateMetadata() failed:", err); return null; }); - layoutMetaPromises.push(metaPromise); - // Advance accumulator: resolves to merged(layouts[0..i]) once layout[i] is done. - accumulatedMetaPromise = metaPromise.then(async (result) => - result ? mergeMetadata([await parentForThisLayout, result]) : await parentForThisLayout - ); - } - // Page's parent is the fully-accumulated layout metadata. - const pageParentPromise = accumulatedMetaPromise; - - const [layoutMetaResults, layoutVpResults, pageMeta, pageVp] = await Promise.all([ - Promise.all(layoutMetaPromises), - Promise.all(layoutMods.map((mod) => resolveModuleViewport(mod, params).catch((err) => { console.error("[vinext] Layout generateViewport() failed:", err); return null; }))), - route.page ? resolveModuleMetadata(route.page, params, spObj, pageParentPromise) : Promise.resolve(null), - route.page ? resolveModuleViewport(route.page, params) : Promise.resolve(null), - ]); - - const metadataList = [...layoutMetaResults.filter(Boolean), ...(pageMeta ? [pageMeta] : [])]; - const viewportList = [...layoutVpResults.filter(Boolean), ...(pageVp ? [pageVp] : [])]; - const resolvedMetadata = metadataList.length > 0 ? mergeMetadata(metadataList) : null; - const resolvedViewport = mergeViewport(viewportList); + const __headResult = await __resolveAppPageHead({ + layoutModules: route.layouts, + layoutTreePositions: route.layoutTreePositions, + pageModule: route.page, + params, + routeSegments: route.routeSegments, + searchParams, + }); + const spObj = __headResult.searchParamsObject; + const hasSearchParams = __headResult.hasSearchParams; + const resolvedMetadata = __headResult.metadata; + const resolvedViewport = __headResult.viewport; // Build the route tree from the leaf page, then delegate the boundary/layout/ // template/segment wiring to a typed runtime helper so the generated entry @@ -4609,7 +4509,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // Note: CSS is automatically injected by @vitejs/plugin-rsc's // rscCssTransform — no manual loadCss() call needed. const _hasLoadingBoundary = !!(route.loading && route.loading.default); - const _asyncLayoutParams = makeThenableParams(params); + const _asyncRouteParams = makeThenableParams(params); return __renderAppPageLifecycle({ cleanPathname, clearRequestContext() { @@ -4652,22 +4552,21 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { probeLayoutAt(li) { const LayoutComp = route.layouts[li]?.default; if (!LayoutComp) return null; - return LayoutComp({ params: _asyncLayoutParams, children: null }); + return LayoutComp({ + params: makeThenableParams(__resolveAppPageSegmentParams( + route.routeSegments, + route.layoutTreePositions?.[li] ?? 0, + params, + )), + children: null, + }); }, probePage() { if (!PageComponent) return null; - const _probeSearchObj = {}; - url.searchParams.forEach(function(v, k) { - if (k in _probeSearchObj) { - _probeSearchObj[k] = Array.isArray(_probeSearchObj[k]) - ? _probeSearchObj[k].concat(v) - : [_probeSearchObj[k], v]; - } else { - _probeSearchObj[k] = v; - } - }); - const _asyncSearchParams = makeThenableParams(_probeSearchObj); - return PageComponent({ params: _asyncLayoutParams, searchParams: _asyncSearchParams }); + const _asyncSearchParams = makeThenableParams( + __collectAppPageSearchParams(url.searchParams).searchParamsObject, + ); + return PageComponent({ params: _asyncRouteParams, searchParams: _asyncSearchParams }); }, classification: { getLayoutId(index) { @@ -4702,19 +4601,16 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }, middlewareContext: _mwCtx, 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 parentBoundary = __resolveAppPageParentHttpAccessBoundaryModule({ + layoutIndex: li, + rootForbiddenModule: null, + rootNotFoundModule: null, + rootUnauthorizedModule: null, + routeForbiddenModules: route.forbiddens, + routeNotFoundModules: route.notFounds, + routeUnauthorizedModules: route.unauthorizeds, + statusCode, + })?.default ?? null; const parentLayouts = route.layouts.slice(0, li); return renderHTTPAccessFallbackPage( route, @@ -4722,7 +4618,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { isRscRequest, request, { - boundaryComponent: parentNotFound, + boundaryComponent: parentBoundary, layouts: parentLayouts, matchedParams: params, }, @@ -4830,7 +4726,6 @@ import { createElement } from "react"; import { setNavigationContext as _setNavigationContextOrig, getNavigationContext as _getNavigationContext } from "next/navigation"; import { setHeadersContext, headersContextFromRequest, getDraftModeCookieHeader, getAndClearPendingCookies, consumeDynamicUsage, markDynamicUsage, applyMiddlewareRequestHeaders, getHeadersContext, setHeadersAccessPhase } from "next/headers"; import { NextRequest, NextFetchEvent } from "next/server"; -import { mergeMetadata, resolveModuleMetadata, mergeViewport, resolveModuleViewport } from "vinext/metadata"; @@ -4862,6 +4757,9 @@ import { resolveAppPageSpecialError as __resolveAppPageSpecialError, teeAppPageRscStreamForCapture as __teeAppPageRscStreamForCapture, } from "/packages/vinext/src/server/app-page-execution.js"; +import { + resolveAppPageParentHttpAccessBoundaryModule as __resolveAppPageParentHttpAccessBoundaryModule, +} from "/packages/vinext/src/server/app-page-boundary.js"; import { renderAppPageErrorBoundary as __renderAppPageErrorBoundary, renderAppPageHttpAccessFallback as __renderAppPageHttpAccessFallback, @@ -4875,6 +4773,13 @@ import { createAppPageTreePath as __createAppPageTreePath, resolveAppPageChildSegments as __resolveAppPageChildSegments, } from "/packages/vinext/src/server/app-page-route-wiring.js"; +import { + resolveAppPageSegmentParams as __resolveAppPageSegmentParams, +} from "/packages/vinext/src/server/app-page-params.js"; +import { + collectAppPageSearchParams as __collectAppPageSearchParams, + resolveAppPageHead as __resolveAppPageHead, +} from "/packages/vinext/src/server/app-page-head.js"; import { renderAppPageLifecycle as __renderAppPageLifecycle, } from "/packages/vinext/src/server/app-page-render.js"; @@ -5249,7 +5154,9 @@ const routes = [ notFound: null, notFounds: [null], forbidden: null, + forbiddens: [], unauthorized: null, + unauthorizeds: [], }, { __buildTimeClassifications: __VINEXT_CLASS(1), // evaluated once at module load @@ -5274,7 +5181,9 @@ const routes = [ notFound: null, notFounds: [null], forbidden: null, + forbiddens: [], unauthorized: null, + unauthorizeds: [], }, { __buildTimeClassifications: __VINEXT_CLASS(2), // evaluated once at module load @@ -5299,7 +5208,9 @@ const routes = [ notFound: null, notFounds: [null, null], forbidden: null, + forbiddens: [], unauthorized: null, + unauthorizeds: [], }, { __buildTimeClassifications: __VINEXT_CLASS(3), // evaluated once at module load @@ -5324,7 +5235,9 @@ const routes = [ notFound: mod_10, notFounds: [null, mod_10], forbidden: null, + forbiddens: [], unauthorized: null, + unauthorizeds: [], } ]; const _routeTrie = _buildRouteTrie(routes); @@ -5563,83 +5476,18 @@ async function buildPageElements(route, params, routePath, pageRequest) { }; } - // Resolve metadata and viewport from layouts and page. - // - // generateMetadata() accepts a "parent" (Promise of ResolvedMetadata) as its - // second argument (Next.js 13+). The parent resolves to the accumulated - // merged metadata of all ancestor segments, enabling patterns like: - // - // const previousImages = (await parent).openGraph?.images ?? [] - // return { openGraph: { images: ['/new-image.jpg', ...previousImages] } } - // - // Next.js uses an eager-execution-with-serial-resolution approach: - // all generateMetadata() calls are kicked off concurrently, but each - // segment's "parent" promise resolves only after the preceding segment's - // metadata is resolved and merged. This preserves concurrency for I/O-bound - // work while guaranteeing that parent data is available when needed. - // - // We build a chain: layoutParentPromises[0] = Promise.resolve({}) (no parent - // for root layout), layoutParentPromises[i+1] resolves to merge(layouts[0..i]), - // and pageParentPromise resolves to merge(all layouts). - // - // IMPORTANT: Layout metadata errors are swallowed (.catch(() => null)) because - // a layout's generateMetadata() failing should not crash the page. - // Page metadata errors are NOT swallowed — if the page's generateMetadata() - // throws, the error propagates out of buildPageElement() so the caller can - // route it to the nearest error.tsx boundary (or global-error.tsx). - const layoutMods = route.layouts.filter(Boolean); - - // Convert URLSearchParams → plain object for page generateMetadata() and - // pageProps.searchParams. Built before the layout loop so the page metadata - // call (below) and pageProps can reference the same object. - // NOTE: Layouts do NOT receive searchParams in generateMetadata() — only - // pages do. This matches Next.js behavior (resolve-metadata.ts:777). - const spObj = Object.create(null); - let hasSearchParams = false; - if (searchParams && searchParams.forEach) { - searchParams.forEach(function(v, k) { - hasSearchParams = true; - if (k in spObj) { - spObj[k] = Array.isArray(spObj[k]) ? spObj[k].concat(v) : [spObj[k], v]; - } else { - spObj[k] = v; - } - }); - } - - // Build the parent promise chain and kick off metadata resolution in one pass. - // Each layout module is called exactly once. layoutMetaPromises[i] is the - // promise for layout[i]'s own metadata result. - // - // All calls are kicked off immediately (concurrent I/O), but each layout's - // "parent" promise only resolves after the preceding layout's metadata is done. - const layoutMetaPromises = []; - let accumulatedMetaPromise = Promise.resolve({}); - for (let i = 0; i < layoutMods.length; i++) { - const parentForThisLayout = accumulatedMetaPromise; - // Kick off this layout's metadata resolution now (concurrent with others). - const metaPromise = resolveModuleMetadata(layoutMods[i], params, undefined, parentForThisLayout) - .catch((err) => { console.error("[vinext] Layout generateMetadata() failed:", err); return null; }); - layoutMetaPromises.push(metaPromise); - // Advance accumulator: resolves to merged(layouts[0..i]) once layout[i] is done. - accumulatedMetaPromise = metaPromise.then(async (result) => - result ? mergeMetadata([await parentForThisLayout, result]) : await parentForThisLayout - ); - } - // Page's parent is the fully-accumulated layout metadata. - const pageParentPromise = accumulatedMetaPromise; - - const [layoutMetaResults, layoutVpResults, pageMeta, pageVp] = await Promise.all([ - Promise.all(layoutMetaPromises), - Promise.all(layoutMods.map((mod) => resolveModuleViewport(mod, params).catch((err) => { console.error("[vinext] Layout generateViewport() failed:", err); return null; }))), - route.page ? resolveModuleMetadata(route.page, params, spObj, pageParentPromise) : Promise.resolve(null), - route.page ? resolveModuleViewport(route.page, params) : Promise.resolve(null), - ]); - - const metadataList = [...layoutMetaResults.filter(Boolean), ...(pageMeta ? [pageMeta] : [])]; - const viewportList = [...layoutVpResults.filter(Boolean), ...(pageVp ? [pageVp] : [])]; - const resolvedMetadata = metadataList.length > 0 ? mergeMetadata(metadataList) : null; - const resolvedViewport = mergeViewport(viewportList); + const __headResult = await __resolveAppPageHead({ + layoutModules: route.layouts, + layoutTreePositions: route.layoutTreePositions, + pageModule: route.page, + params, + routeSegments: route.routeSegments, + searchParams, + }); + const spObj = __headResult.searchParamsObject; + const hasSearchParams = __headResult.hasSearchParams; + const resolvedMetadata = __headResult.metadata; + const resolvedViewport = __headResult.viewport; // Build the route tree from the leaf page, then delegate the boundary/layout/ // template/segment wiring to a typed runtime helper so the generated entry @@ -6999,7 +6847,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // Note: CSS is automatically injected by @vitejs/plugin-rsc's // rscCssTransform — no manual loadCss() call needed. const _hasLoadingBoundary = !!(route.loading && route.loading.default); - const _asyncLayoutParams = makeThenableParams(params); + const _asyncRouteParams = makeThenableParams(params); return __renderAppPageLifecycle({ cleanPathname, clearRequestContext() { @@ -7042,22 +6890,21 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { probeLayoutAt(li) { const LayoutComp = route.layouts[li]?.default; if (!LayoutComp) return null; - return LayoutComp({ params: _asyncLayoutParams, children: null }); + return LayoutComp({ + params: makeThenableParams(__resolveAppPageSegmentParams( + route.routeSegments, + route.layoutTreePositions?.[li] ?? 0, + params, + )), + children: null, + }); }, probePage() { if (!PageComponent) return null; - const _probeSearchObj = {}; - url.searchParams.forEach(function(v, k) { - if (k in _probeSearchObj) { - _probeSearchObj[k] = Array.isArray(_probeSearchObj[k]) - ? _probeSearchObj[k].concat(v) - : [_probeSearchObj[k], v]; - } else { - _probeSearchObj[k] = v; - } - }); - const _asyncSearchParams = makeThenableParams(_probeSearchObj); - return PageComponent({ params: _asyncLayoutParams, searchParams: _asyncSearchParams }); + const _asyncSearchParams = makeThenableParams( + __collectAppPageSearchParams(url.searchParams).searchParamsObject, + ); + return PageComponent({ params: _asyncRouteParams, searchParams: _asyncSearchParams }); }, classification: { getLayoutId(index) { @@ -7092,19 +6939,16 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }, middlewareContext: _mwCtx, 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 parentBoundary = __resolveAppPageParentHttpAccessBoundaryModule({ + layoutIndex: li, + rootForbiddenModule: null, + rootNotFoundModule: null, + rootUnauthorizedModule: null, + routeForbiddenModules: route.forbiddens, + routeNotFoundModules: route.notFounds, + routeUnauthorizedModules: route.unauthorizeds, + statusCode, + })?.default ?? null; const parentLayouts = route.layouts.slice(0, li); return renderHTTPAccessFallbackPage( route, @@ -7112,7 +6956,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { isRscRequest, request, { - boundaryComponent: parentNotFound, + boundaryComponent: parentBoundary, layouts: parentLayouts, matchedParams: params, }, @@ -7220,7 +7064,6 @@ import { createElement } from "react"; import { setNavigationContext as _setNavigationContextOrig, getNavigationContext as _getNavigationContext } from "next/navigation"; import { setHeadersContext, headersContextFromRequest, getDraftModeCookieHeader, getAndClearPendingCookies, consumeDynamicUsage, markDynamicUsage, applyMiddlewareRequestHeaders, getHeadersContext, setHeadersAccessPhase } from "next/headers"; import { NextRequest, NextFetchEvent } from "next/server"; -import { mergeMetadata, resolveModuleMetadata, mergeViewport, resolveModuleViewport } from "vinext/metadata"; import * as _instrumentation from "/tmp/test/instrumentation.ts"; @@ -7252,6 +7095,9 @@ import { resolveAppPageSpecialError as __resolveAppPageSpecialError, teeAppPageRscStreamForCapture as __teeAppPageRscStreamForCapture, } from "/packages/vinext/src/server/app-page-execution.js"; +import { + resolveAppPageParentHttpAccessBoundaryModule as __resolveAppPageParentHttpAccessBoundaryModule, +} from "/packages/vinext/src/server/app-page-boundary.js"; import { renderAppPageErrorBoundary as __renderAppPageErrorBoundary, renderAppPageHttpAccessFallback as __renderAppPageHttpAccessFallback, @@ -7265,6 +7111,13 @@ import { createAppPageTreePath as __createAppPageTreePath, resolveAppPageChildSegments as __resolveAppPageChildSegments, } from "/packages/vinext/src/server/app-page-route-wiring.js"; +import { + resolveAppPageSegmentParams as __resolveAppPageSegmentParams, +} from "/packages/vinext/src/server/app-page-params.js"; +import { + collectAppPageSearchParams as __collectAppPageSearchParams, + resolveAppPageHead as __resolveAppPageHead, +} from "/packages/vinext/src/server/app-page-head.js"; import { renderAppPageLifecycle as __renderAppPageLifecycle, } from "/packages/vinext/src/server/app-page-render.js"; @@ -7668,7 +7521,9 @@ const routes = [ notFound: null, notFounds: [null], forbidden: null, + forbiddens: [], unauthorized: null, + unauthorizeds: [], }, { __buildTimeClassifications: __VINEXT_CLASS(1), // evaluated once at module load @@ -7693,7 +7548,9 @@ const routes = [ notFound: null, notFounds: [null], forbidden: null, + forbiddens: [], unauthorized: null, + unauthorizeds: [], }, { __buildTimeClassifications: __VINEXT_CLASS(2), // evaluated once at module load @@ -7718,7 +7575,9 @@ const routes = [ notFound: null, notFounds: [null, null], forbidden: null, + forbiddens: [], unauthorized: null, + unauthorizeds: [], }, { __buildTimeClassifications: __VINEXT_CLASS(3), // evaluated once at module load @@ -7743,7 +7602,9 @@ const routes = [ notFound: mod_10, notFounds: [null, mod_10], forbidden: null, + forbiddens: [], unauthorized: null, + unauthorizeds: [], } ]; const _routeTrie = _buildRouteTrie(routes); @@ -7982,83 +7843,18 @@ async function buildPageElements(route, params, routePath, pageRequest) { }; } - // Resolve metadata and viewport from layouts and page. - // - // generateMetadata() accepts a "parent" (Promise of ResolvedMetadata) as its - // second argument (Next.js 13+). The parent resolves to the accumulated - // merged metadata of all ancestor segments, enabling patterns like: - // - // const previousImages = (await parent).openGraph?.images ?? [] - // return { openGraph: { images: ['/new-image.jpg', ...previousImages] } } - // - // Next.js uses an eager-execution-with-serial-resolution approach: - // all generateMetadata() calls are kicked off concurrently, but each - // segment's "parent" promise resolves only after the preceding segment's - // metadata is resolved and merged. This preserves concurrency for I/O-bound - // work while guaranteeing that parent data is available when needed. - // - // We build a chain: layoutParentPromises[0] = Promise.resolve({}) (no parent - // for root layout), layoutParentPromises[i+1] resolves to merge(layouts[0..i]), - // and pageParentPromise resolves to merge(all layouts). - // - // IMPORTANT: Layout metadata errors are swallowed (.catch(() => null)) because - // a layout's generateMetadata() failing should not crash the page. - // Page metadata errors are NOT swallowed — if the page's generateMetadata() - // throws, the error propagates out of buildPageElement() so the caller can - // route it to the nearest error.tsx boundary (or global-error.tsx). - const layoutMods = route.layouts.filter(Boolean); - - // Convert URLSearchParams → plain object for page generateMetadata() and - // pageProps.searchParams. Built before the layout loop so the page metadata - // call (below) and pageProps can reference the same object. - // NOTE: Layouts do NOT receive searchParams in generateMetadata() — only - // pages do. This matches Next.js behavior (resolve-metadata.ts:777). - const spObj = Object.create(null); - let hasSearchParams = false; - if (searchParams && searchParams.forEach) { - searchParams.forEach(function(v, k) { - hasSearchParams = true; - if (k in spObj) { - spObj[k] = Array.isArray(spObj[k]) ? spObj[k].concat(v) : [spObj[k], v]; - } else { - spObj[k] = v; - } - }); - } - - // Build the parent promise chain and kick off metadata resolution in one pass. - // Each layout module is called exactly once. layoutMetaPromises[i] is the - // promise for layout[i]'s own metadata result. - // - // All calls are kicked off immediately (concurrent I/O), but each layout's - // "parent" promise only resolves after the preceding layout's metadata is done. - const layoutMetaPromises = []; - let accumulatedMetaPromise = Promise.resolve({}); - for (let i = 0; i < layoutMods.length; i++) { - const parentForThisLayout = accumulatedMetaPromise; - // Kick off this layout's metadata resolution now (concurrent with others). - const metaPromise = resolveModuleMetadata(layoutMods[i], params, undefined, parentForThisLayout) - .catch((err) => { console.error("[vinext] Layout generateMetadata() failed:", err); return null; }); - layoutMetaPromises.push(metaPromise); - // Advance accumulator: resolves to merged(layouts[0..i]) once layout[i] is done. - accumulatedMetaPromise = metaPromise.then(async (result) => - result ? mergeMetadata([await parentForThisLayout, result]) : await parentForThisLayout - ); - } - // Page's parent is the fully-accumulated layout metadata. - const pageParentPromise = accumulatedMetaPromise; - - const [layoutMetaResults, layoutVpResults, pageMeta, pageVp] = await Promise.all([ - Promise.all(layoutMetaPromises), - Promise.all(layoutMods.map((mod) => resolveModuleViewport(mod, params).catch((err) => { console.error("[vinext] Layout generateViewport() failed:", err); return null; }))), - route.page ? resolveModuleMetadata(route.page, params, spObj, pageParentPromise) : Promise.resolve(null), - route.page ? resolveModuleViewport(route.page, params) : Promise.resolve(null), - ]); - - const metadataList = [...layoutMetaResults.filter(Boolean), ...(pageMeta ? [pageMeta] : [])]; - const viewportList = [...layoutVpResults.filter(Boolean), ...(pageVp ? [pageVp] : [])]; - const resolvedMetadata = metadataList.length > 0 ? mergeMetadata(metadataList) : null; - const resolvedViewport = mergeViewport(viewportList); + const __headResult = await __resolveAppPageHead({ + layoutModules: route.layouts, + layoutTreePositions: route.layoutTreePositions, + pageModule: route.page, + params, + routeSegments: route.routeSegments, + searchParams, + }); + const spObj = __headResult.searchParamsObject; + const hasSearchParams = __headResult.hasSearchParams; + const resolvedMetadata = __headResult.metadata; + const resolvedViewport = __headResult.viewport; // Build the route tree from the leaf page, then delegate the boundary/layout/ // template/segment wiring to a typed runtime helper so the generated entry @@ -9421,7 +9217,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // Note: CSS is automatically injected by @vitejs/plugin-rsc's // rscCssTransform — no manual loadCss() call needed. const _hasLoadingBoundary = !!(route.loading && route.loading.default); - const _asyncLayoutParams = makeThenableParams(params); + const _asyncRouteParams = makeThenableParams(params); return __renderAppPageLifecycle({ cleanPathname, clearRequestContext() { @@ -9464,22 +9260,21 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { probeLayoutAt(li) { const LayoutComp = route.layouts[li]?.default; if (!LayoutComp) return null; - return LayoutComp({ params: _asyncLayoutParams, children: null }); + return LayoutComp({ + params: makeThenableParams(__resolveAppPageSegmentParams( + route.routeSegments, + route.layoutTreePositions?.[li] ?? 0, + params, + )), + children: null, + }); }, probePage() { if (!PageComponent) return null; - const _probeSearchObj = {}; - url.searchParams.forEach(function(v, k) { - if (k in _probeSearchObj) { - _probeSearchObj[k] = Array.isArray(_probeSearchObj[k]) - ? _probeSearchObj[k].concat(v) - : [_probeSearchObj[k], v]; - } else { - _probeSearchObj[k] = v; - } - }); - const _asyncSearchParams = makeThenableParams(_probeSearchObj); - return PageComponent({ params: _asyncLayoutParams, searchParams: _asyncSearchParams }); + const _asyncSearchParams = makeThenableParams( + __collectAppPageSearchParams(url.searchParams).searchParamsObject, + ); + return PageComponent({ params: _asyncRouteParams, searchParams: _asyncSearchParams }); }, classification: { getLayoutId(index) { @@ -9514,19 +9309,16 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }, middlewareContext: _mwCtx, 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 parentBoundary = __resolveAppPageParentHttpAccessBoundaryModule({ + layoutIndex: li, + rootForbiddenModule: null, + rootNotFoundModule: null, + rootUnauthorizedModule: null, + routeForbiddenModules: route.forbiddens, + routeNotFoundModules: route.notFounds, + routeUnauthorizedModules: route.unauthorizeds, + statusCode, + })?.default ?? null; const parentLayouts = route.layouts.slice(0, li); return renderHTTPAccessFallbackPage( route, @@ -9534,7 +9326,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { isRscRequest, request, { - boundaryComponent: parentNotFound, + boundaryComponent: parentBoundary, layouts: parentLayouts, matchedParams: params, }, @@ -9642,7 +9434,6 @@ import { createElement } from "react"; import { setNavigationContext as _setNavigationContextOrig, getNavigationContext as _getNavigationContext } from "next/navigation"; import { setHeadersContext, headersContextFromRequest, getDraftModeCookieHeader, getAndClearPendingCookies, consumeDynamicUsage, markDynamicUsage, applyMiddlewareRequestHeaders, getHeadersContext, setHeadersAccessPhase } from "next/headers"; import { NextRequest, NextFetchEvent } from "next/server"; -import { mergeMetadata, resolveModuleMetadata, mergeViewport, resolveModuleViewport } from "vinext/metadata"; import { sitemapToXml, robotsToText, manifestToJson } from "/packages/vinext/src/server/metadata-routes.js"; @@ -9674,6 +9465,9 @@ import { resolveAppPageSpecialError as __resolveAppPageSpecialError, teeAppPageRscStreamForCapture as __teeAppPageRscStreamForCapture, } from "/packages/vinext/src/server/app-page-execution.js"; +import { + resolveAppPageParentHttpAccessBoundaryModule as __resolveAppPageParentHttpAccessBoundaryModule, +} from "/packages/vinext/src/server/app-page-boundary.js"; import { renderAppPageErrorBoundary as __renderAppPageErrorBoundary, renderAppPageHttpAccessFallback as __renderAppPageHttpAccessFallback, @@ -9687,6 +9481,13 @@ import { createAppPageTreePath as __createAppPageTreePath, resolveAppPageChildSegments as __resolveAppPageChildSegments, } from "/packages/vinext/src/server/app-page-route-wiring.js"; +import { + resolveAppPageSegmentParams as __resolveAppPageSegmentParams, +} from "/packages/vinext/src/server/app-page-params.js"; +import { + collectAppPageSearchParams as __collectAppPageSearchParams, + resolveAppPageHead as __resolveAppPageHead, +} from "/packages/vinext/src/server/app-page-head.js"; import { renderAppPageLifecycle as __renderAppPageLifecycle, } from "/packages/vinext/src/server/app-page-render.js"; @@ -10061,7 +9862,9 @@ const routes = [ notFound: null, notFounds: [null], forbidden: null, + forbiddens: [], unauthorized: null, + unauthorizeds: [], }, { __buildTimeClassifications: __VINEXT_CLASS(1), // evaluated once at module load @@ -10086,7 +9889,9 @@ const routes = [ notFound: null, notFounds: [null], forbidden: null, + forbiddens: [], unauthorized: null, + unauthorizeds: [], }, { __buildTimeClassifications: __VINEXT_CLASS(2), // evaluated once at module load @@ -10111,7 +9916,9 @@ const routes = [ notFound: null, notFounds: [null, null], forbidden: null, + forbiddens: [], unauthorized: null, + unauthorizeds: [], }, { __buildTimeClassifications: __VINEXT_CLASS(3), // evaluated once at module load @@ -10136,7 +9943,9 @@ const routes = [ notFound: mod_10, notFounds: [null, mod_10], forbidden: null, + forbiddens: [], unauthorized: null, + unauthorizeds: [], } ]; const _routeTrie = _buildRouteTrie(routes); @@ -10381,83 +10190,18 @@ async function buildPageElements(route, params, routePath, pageRequest) { }; } - // Resolve metadata and viewport from layouts and page. - // - // generateMetadata() accepts a "parent" (Promise of ResolvedMetadata) as its - // second argument (Next.js 13+). The parent resolves to the accumulated - // merged metadata of all ancestor segments, enabling patterns like: - // - // const previousImages = (await parent).openGraph?.images ?? [] - // return { openGraph: { images: ['/new-image.jpg', ...previousImages] } } - // - // Next.js uses an eager-execution-with-serial-resolution approach: - // all generateMetadata() calls are kicked off concurrently, but each - // segment's "parent" promise resolves only after the preceding segment's - // metadata is resolved and merged. This preserves concurrency for I/O-bound - // work while guaranteeing that parent data is available when needed. - // - // We build a chain: layoutParentPromises[0] = Promise.resolve({}) (no parent - // for root layout), layoutParentPromises[i+1] resolves to merge(layouts[0..i]), - // and pageParentPromise resolves to merge(all layouts). - // - // IMPORTANT: Layout metadata errors are swallowed (.catch(() => null)) because - // a layout's generateMetadata() failing should not crash the page. - // Page metadata errors are NOT swallowed — if the page's generateMetadata() - // throws, the error propagates out of buildPageElement() so the caller can - // route it to the nearest error.tsx boundary (or global-error.tsx). - const layoutMods = route.layouts.filter(Boolean); - - // Convert URLSearchParams → plain object for page generateMetadata() and - // pageProps.searchParams. Built before the layout loop so the page metadata - // call (below) and pageProps can reference the same object. - // NOTE: Layouts do NOT receive searchParams in generateMetadata() — only - // pages do. This matches Next.js behavior (resolve-metadata.ts:777). - const spObj = Object.create(null); - let hasSearchParams = false; - if (searchParams && searchParams.forEach) { - searchParams.forEach(function(v, k) { - hasSearchParams = true; - if (k in spObj) { - spObj[k] = Array.isArray(spObj[k]) ? spObj[k].concat(v) : [spObj[k], v]; - } else { - spObj[k] = v; - } - }); - } - - // Build the parent promise chain and kick off metadata resolution in one pass. - // Each layout module is called exactly once. layoutMetaPromises[i] is the - // promise for layout[i]'s own metadata result. - // - // All calls are kicked off immediately (concurrent I/O), but each layout's - // "parent" promise only resolves after the preceding layout's metadata is done. - const layoutMetaPromises = []; - let accumulatedMetaPromise = Promise.resolve({}); - for (let i = 0; i < layoutMods.length; i++) { - const parentForThisLayout = accumulatedMetaPromise; - // Kick off this layout's metadata resolution now (concurrent with others). - const metaPromise = resolveModuleMetadata(layoutMods[i], params, undefined, parentForThisLayout) - .catch((err) => { console.error("[vinext] Layout generateMetadata() failed:", err); return null; }); - layoutMetaPromises.push(metaPromise); - // Advance accumulator: resolves to merged(layouts[0..i]) once layout[i] is done. - accumulatedMetaPromise = metaPromise.then(async (result) => - result ? mergeMetadata([await parentForThisLayout, result]) : await parentForThisLayout - ); - } - // Page's parent is the fully-accumulated layout metadata. - const pageParentPromise = accumulatedMetaPromise; - - const [layoutMetaResults, layoutVpResults, pageMeta, pageVp] = await Promise.all([ - Promise.all(layoutMetaPromises), - Promise.all(layoutMods.map((mod) => resolveModuleViewport(mod, params).catch((err) => { console.error("[vinext] Layout generateViewport() failed:", err); return null; }))), - route.page ? resolveModuleMetadata(route.page, params, spObj, pageParentPromise) : Promise.resolve(null), - route.page ? resolveModuleViewport(route.page, params) : Promise.resolve(null), - ]); - - const metadataList = [...layoutMetaResults.filter(Boolean), ...(pageMeta ? [pageMeta] : [])]; - const viewportList = [...layoutVpResults.filter(Boolean), ...(pageVp ? [pageVp] : [])]; - const resolvedMetadata = metadataList.length > 0 ? mergeMetadata(metadataList) : null; - const resolvedViewport = mergeViewport(viewportList); + const __headResult = await __resolveAppPageHead({ + layoutModules: route.layouts, + layoutTreePositions: route.layoutTreePositions, + pageModule: route.page, + params, + routeSegments: route.routeSegments, + searchParams, + }); + const spObj = __headResult.searchParamsObject; + const hasSearchParams = __headResult.hasSearchParams; + const resolvedMetadata = __headResult.metadata; + const resolvedViewport = __headResult.viewport; // Build the route tree from the leaf page, then delegate the boundary/layout/ // template/segment wiring to a typed runtime helper so the generated entry @@ -11817,7 +11561,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // Note: CSS is automatically injected by @vitejs/plugin-rsc's // rscCssTransform — no manual loadCss() call needed. const _hasLoadingBoundary = !!(route.loading && route.loading.default); - const _asyncLayoutParams = makeThenableParams(params); + const _asyncRouteParams = makeThenableParams(params); return __renderAppPageLifecycle({ cleanPathname, clearRequestContext() { @@ -11860,22 +11604,21 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { probeLayoutAt(li) { const LayoutComp = route.layouts[li]?.default; if (!LayoutComp) return null; - return LayoutComp({ params: _asyncLayoutParams, children: null }); + return LayoutComp({ + params: makeThenableParams(__resolveAppPageSegmentParams( + route.routeSegments, + route.layoutTreePositions?.[li] ?? 0, + params, + )), + children: null, + }); }, probePage() { if (!PageComponent) return null; - const _probeSearchObj = {}; - url.searchParams.forEach(function(v, k) { - if (k in _probeSearchObj) { - _probeSearchObj[k] = Array.isArray(_probeSearchObj[k]) - ? _probeSearchObj[k].concat(v) - : [_probeSearchObj[k], v]; - } else { - _probeSearchObj[k] = v; - } - }); - const _asyncSearchParams = makeThenableParams(_probeSearchObj); - return PageComponent({ params: _asyncLayoutParams, searchParams: _asyncSearchParams }); + const _asyncSearchParams = makeThenableParams( + __collectAppPageSearchParams(url.searchParams).searchParamsObject, + ); + return PageComponent({ params: _asyncRouteParams, searchParams: _asyncSearchParams }); }, classification: { getLayoutId(index) { @@ -11910,19 +11653,16 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }, middlewareContext: _mwCtx, 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 parentBoundary = __resolveAppPageParentHttpAccessBoundaryModule({ + layoutIndex: li, + rootForbiddenModule: null, + rootNotFoundModule: null, + rootUnauthorizedModule: null, + routeForbiddenModules: route.forbiddens, + routeNotFoundModules: route.notFounds, + routeUnauthorizedModules: route.unauthorizeds, + statusCode, + })?.default ?? null; const parentLayouts = route.layouts.slice(0, li); return renderHTTPAccessFallbackPage( route, @@ -11930,7 +11670,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { isRscRequest, request, { - boundaryComponent: parentNotFound, + boundaryComponent: parentBoundary, layouts: parentLayouts, matchedParams: params, }, @@ -12038,7 +11778,6 @@ import { createElement } from "react"; import { setNavigationContext as _setNavigationContextOrig, getNavigationContext as _getNavigationContext } from "next/navigation"; import { setHeadersContext, headersContextFromRequest, getDraftModeCookieHeader, getAndClearPendingCookies, consumeDynamicUsage, markDynamicUsage, applyMiddlewareRequestHeaders, getHeadersContext, setHeadersAccessPhase } from "next/headers"; import { NextRequest, NextFetchEvent } from "next/server"; -import { mergeMetadata, resolveModuleMetadata, mergeViewport, resolveModuleViewport } from "vinext/metadata"; import * as middlewareModule from "/tmp/test/middleware.ts"; @@ -12070,6 +11809,9 @@ import { resolveAppPageSpecialError as __resolveAppPageSpecialError, teeAppPageRscStreamForCapture as __teeAppPageRscStreamForCapture, } from "/packages/vinext/src/server/app-page-execution.js"; +import { + resolveAppPageParentHttpAccessBoundaryModule as __resolveAppPageParentHttpAccessBoundaryModule, +} from "/packages/vinext/src/server/app-page-boundary.js"; import { renderAppPageErrorBoundary as __renderAppPageErrorBoundary, renderAppPageHttpAccessFallback as __renderAppPageHttpAccessFallback, @@ -12083,6 +11825,13 @@ import { createAppPageTreePath as __createAppPageTreePath, resolveAppPageChildSegments as __resolveAppPageChildSegments, } from "/packages/vinext/src/server/app-page-route-wiring.js"; +import { + resolveAppPageSegmentParams as __resolveAppPageSegmentParams, +} from "/packages/vinext/src/server/app-page-params.js"; +import { + collectAppPageSearchParams as __collectAppPageSearchParams, + resolveAppPageHead as __resolveAppPageHead, +} from "/packages/vinext/src/server/app-page-head.js"; import { renderAppPageLifecycle as __renderAppPageLifecycle, } from "/packages/vinext/src/server/app-page-render.js"; @@ -12456,7 +12205,9 @@ const routes = [ notFound: null, notFounds: [null], forbidden: null, + forbiddens: [], unauthorized: null, + unauthorizeds: [], }, { __buildTimeClassifications: __VINEXT_CLASS(1), // evaluated once at module load @@ -12481,7 +12232,9 @@ const routes = [ notFound: null, notFounds: [null], forbidden: null, + forbiddens: [], unauthorized: null, + unauthorizeds: [], }, { __buildTimeClassifications: __VINEXT_CLASS(2), // evaluated once at module load @@ -12506,7 +12259,9 @@ const routes = [ notFound: null, notFounds: [null, null], forbidden: null, + forbiddens: [], unauthorized: null, + unauthorizeds: [], }, { __buildTimeClassifications: __VINEXT_CLASS(3), // evaluated once at module load @@ -12531,7 +12286,9 @@ const routes = [ notFound: mod_10, notFounds: [null, mod_10], forbidden: null, + forbiddens: [], unauthorized: null, + unauthorizeds: [], } ]; const _routeTrie = _buildRouteTrie(routes); @@ -12770,83 +12527,18 @@ async function buildPageElements(route, params, routePath, pageRequest) { }; } - // Resolve metadata and viewport from layouts and page. - // - // generateMetadata() accepts a "parent" (Promise of ResolvedMetadata) as its - // second argument (Next.js 13+). The parent resolves to the accumulated - // merged metadata of all ancestor segments, enabling patterns like: - // - // const previousImages = (await parent).openGraph?.images ?? [] - // return { openGraph: { images: ['/new-image.jpg', ...previousImages] } } - // - // Next.js uses an eager-execution-with-serial-resolution approach: - // all generateMetadata() calls are kicked off concurrently, but each - // segment's "parent" promise resolves only after the preceding segment's - // metadata is resolved and merged. This preserves concurrency for I/O-bound - // work while guaranteeing that parent data is available when needed. - // - // We build a chain: layoutParentPromises[0] = Promise.resolve({}) (no parent - // for root layout), layoutParentPromises[i+1] resolves to merge(layouts[0..i]), - // and pageParentPromise resolves to merge(all layouts). - // - // IMPORTANT: Layout metadata errors are swallowed (.catch(() => null)) because - // a layout's generateMetadata() failing should not crash the page. - // Page metadata errors are NOT swallowed — if the page's generateMetadata() - // throws, the error propagates out of buildPageElement() so the caller can - // route it to the nearest error.tsx boundary (or global-error.tsx). - const layoutMods = route.layouts.filter(Boolean); - - // Convert URLSearchParams → plain object for page generateMetadata() and - // pageProps.searchParams. Built before the layout loop so the page metadata - // call (below) and pageProps can reference the same object. - // NOTE: Layouts do NOT receive searchParams in generateMetadata() — only - // pages do. This matches Next.js behavior (resolve-metadata.ts:777). - const spObj = Object.create(null); - let hasSearchParams = false; - if (searchParams && searchParams.forEach) { - searchParams.forEach(function(v, k) { - hasSearchParams = true; - if (k in spObj) { - spObj[k] = Array.isArray(spObj[k]) ? spObj[k].concat(v) : [spObj[k], v]; - } else { - spObj[k] = v; - } - }); - } - - // Build the parent promise chain and kick off metadata resolution in one pass. - // Each layout module is called exactly once. layoutMetaPromises[i] is the - // promise for layout[i]'s own metadata result. - // - // All calls are kicked off immediately (concurrent I/O), but each layout's - // "parent" promise only resolves after the preceding layout's metadata is done. - const layoutMetaPromises = []; - let accumulatedMetaPromise = Promise.resolve({}); - for (let i = 0; i < layoutMods.length; i++) { - const parentForThisLayout = accumulatedMetaPromise; - // Kick off this layout's metadata resolution now (concurrent with others). - const metaPromise = resolveModuleMetadata(layoutMods[i], params, undefined, parentForThisLayout) - .catch((err) => { console.error("[vinext] Layout generateMetadata() failed:", err); return null; }); - layoutMetaPromises.push(metaPromise); - // Advance accumulator: resolves to merged(layouts[0..i]) once layout[i] is done. - accumulatedMetaPromise = metaPromise.then(async (result) => - result ? mergeMetadata([await parentForThisLayout, result]) : await parentForThisLayout - ); - } - // Page's parent is the fully-accumulated layout metadata. - const pageParentPromise = accumulatedMetaPromise; - - const [layoutMetaResults, layoutVpResults, pageMeta, pageVp] = await Promise.all([ - Promise.all(layoutMetaPromises), - Promise.all(layoutMods.map((mod) => resolveModuleViewport(mod, params).catch((err) => { console.error("[vinext] Layout generateViewport() failed:", err); return null; }))), - route.page ? resolveModuleMetadata(route.page, params, spObj, pageParentPromise) : Promise.resolve(null), - route.page ? resolveModuleViewport(route.page, params) : Promise.resolve(null), - ]); - - const metadataList = [...layoutMetaResults.filter(Boolean), ...(pageMeta ? [pageMeta] : [])]; - const viewportList = [...layoutVpResults.filter(Boolean), ...(pageVp ? [pageVp] : [])]; - const resolvedMetadata = metadataList.length > 0 ? mergeMetadata(metadataList) : null; - const resolvedViewport = mergeViewport(viewportList); + const __headResult = await __resolveAppPageHead({ + layoutModules: route.layouts, + layoutTreePositions: route.layoutTreePositions, + pageModule: route.page, + params, + routeSegments: route.routeSegments, + searchParams, + }); + const spObj = __headResult.searchParamsObject; + const hasSearchParams = __headResult.hasSearchParams; + const resolvedMetadata = __headResult.metadata; + const resolvedViewport = __headResult.viewport; // Build the route tree from the leaf page, then delegate the boundary/layout/ // template/segment wiring to a typed runtime helper so the generated entry @@ -14573,7 +14265,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // Note: CSS is automatically injected by @vitejs/plugin-rsc's // rscCssTransform — no manual loadCss() call needed. const _hasLoadingBoundary = !!(route.loading && route.loading.default); - const _asyncLayoutParams = makeThenableParams(params); + const _asyncRouteParams = makeThenableParams(params); return __renderAppPageLifecycle({ cleanPathname, clearRequestContext() { @@ -14616,22 +14308,21 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { probeLayoutAt(li) { const LayoutComp = route.layouts[li]?.default; if (!LayoutComp) return null; - return LayoutComp({ params: _asyncLayoutParams, children: null }); + return LayoutComp({ + params: makeThenableParams(__resolveAppPageSegmentParams( + route.routeSegments, + route.layoutTreePositions?.[li] ?? 0, + params, + )), + children: null, + }); }, probePage() { if (!PageComponent) return null; - const _probeSearchObj = {}; - url.searchParams.forEach(function(v, k) { - if (k in _probeSearchObj) { - _probeSearchObj[k] = Array.isArray(_probeSearchObj[k]) - ? _probeSearchObj[k].concat(v) - : [_probeSearchObj[k], v]; - } else { - _probeSearchObj[k] = v; - } - }); - const _asyncSearchParams = makeThenableParams(_probeSearchObj); - return PageComponent({ params: _asyncLayoutParams, searchParams: _asyncSearchParams }); + const _asyncSearchParams = makeThenableParams( + __collectAppPageSearchParams(url.searchParams).searchParamsObject, + ); + return PageComponent({ params: _asyncRouteParams, searchParams: _asyncSearchParams }); }, classification: { getLayoutId(index) { @@ -14666,19 +14357,16 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }, middlewareContext: _mwCtx, 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 parentBoundary = __resolveAppPageParentHttpAccessBoundaryModule({ + layoutIndex: li, + rootForbiddenModule: null, + rootNotFoundModule: null, + rootUnauthorizedModule: null, + routeForbiddenModules: route.forbiddens, + routeNotFoundModules: route.notFounds, + routeUnauthorizedModules: route.unauthorizeds, + statusCode, + })?.default ?? null; const parentLayouts = route.layouts.slice(0, li); return renderHTTPAccessFallbackPage( route, @@ -14686,7 +14374,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { isRscRequest, request, { - boundaryComponent: parentNotFound, + boundaryComponent: parentBoundary, layouts: parentLayouts, matchedParams: params, }, diff --git a/tests/app-page-boundary.test.ts b/tests/app-page-boundary.test.ts index b9b3d37fa..3f6e13929 100644 --- a/tests/app-page-boundary.test.ts +++ b/tests/app-page-boundary.test.ts @@ -3,6 +3,7 @@ import { renderAppPageBoundaryResponse, resolveAppPageErrorBoundary, resolveAppPageHttpAccessBoundaryComponent, + resolveAppPageParentHttpAccessBoundaryModule, wrapAppPageBoundaryElement, } from "../packages/vinext/src/server/app-page-boundary.js"; @@ -52,6 +53,28 @@ describe("app page boundary helpers", () => { expect(component).toBe("RootNotFound"); }); + it("selects the matching parent HTTP access boundary for layout throws", () => { + expect( + resolveAppPageParentHttpAccessBoundaryModule({ + layoutIndex: 2, + rootForbiddenModule: "RootForbidden", + routeForbiddenModules: [null, "ParentForbidden", "ThrowingLayoutForbidden"], + routeNotFoundModules: [null, "ParentNotFound"], + statusCode: 403, + }), + ).toBe("ParentForbidden"); + + expect( + resolveAppPageParentHttpAccessBoundaryModule({ + layoutIndex: 2, + rootUnauthorizedModule: "RootUnauthorized", + routeNotFoundModules: [null, "ParentNotFound"], + routeUnauthorizedModules: [null, undefined, "ThrowingLayoutUnauthorized"], + statusCode: 401, + }), + ).toBe("RootUnauthorized"); + }); + it("resolves page, layout, and global error boundaries in order", () => { expect( resolveAppPageErrorBoundary({ @@ -123,11 +146,11 @@ describe("app page boundary helpers", () => { const slug = Array.isArray(params.slug) ? params.slug.join("/") : params.slug; return `${routeSegments[treePosition] ?? "root"}:${slug}`; }, - routeSegments: ["root", "[slug]"], + routeSegments: ["[slug]"], }); expect(wrapped).toBe( - 'ErrorBoundary(GlobalError)[Segment(root:post)[Layout(RootLayout)[Segment([slug]:post)[Layout(LeafLayout)[Boundary|{"slug":"post","thenable":true}]]|{"slug":"post","thenable":true}]]]', + 'ErrorBoundary(GlobalError)[Segment([slug]:post)[Layout(RootLayout)[Segment(root:post)[Layout(LeafLayout)[Boundary|{"slug":"post","thenable":true}]]|{"thenable":true}]]]', ); }); diff --git a/tests/app-page-head.test.ts b/tests/app-page-head.test.ts new file mode 100644 index 000000000..e14b1f11e --- /dev/null +++ b/tests/app-page-head.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vite-plus/test"; +import { + collectAppPageSearchParams, + resolveAppPageHead, +} from "../packages/vinext/src/server/app-page-head.js"; +import type { AppPageParams } from "../packages/vinext/src/server/app-page-boundary.js"; + +describe("app page head helpers", () => { + it("collects repeated search params into a null-prototype object", () => { + const { hasSearchParams, searchParamsObject } = collectAppPageSearchParams( + new URLSearchParams("__proto__=safe&tag=a&tag=b"), + ); + + expect(hasSearchParams).toBe(true); + expect(Object.getPrototypeOf(searchParamsObject)).toBe(null); + expect(searchParamsObject["__proto__"]).toBe("safe"); + expect(searchParamsObject.tag).toEqual(["a", "b"]); + }); + + it("passes scoped params to layout metadata and full params/searchParams to page metadata", async () => { + // Ported from Next.js: test/e2e/app-dir/layout-params/layout-params.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/layout-params/layout-params.test.ts + // Reference: packages/next/src/lib/metadata/resolve-metadata.ts + // https://github.com/vercel/next.js/blob/canary/packages/next/src/lib/metadata/resolve-metadata.ts + const layoutParamCalls: AppPageParams[] = []; + let pageParams: AppPageParams | null = null; + let pageSearchParams: Record = {}; + + const rootLayout = { + async generateMetadata({ params }: { params: Promise }) { + layoutParamCalls.push(await params); + return { title: "root" }; + }, + }; + const categoryLayout = { + async generateMetadata({ params }: { params: Promise }) { + layoutParamCalls.push(await params); + return { description: "category" }; + }, + }; + const page = { + async generateMetadata({ + params, + searchParams, + }: { + params: Promise; + searchParams: Promise>; + }) { + pageParams = await params; + pageSearchParams = await searchParams; + return { keywords: ["page"] }; + }, + }; + + const result = await resolveAppPageHead>({ + layoutModules: [rootLayout, categoryLayout], + layoutTreePositions: [1, 2], + pageModule: page, + params: { category: "books", id: "dune" }, + routeSegments: ["shop", "[category]", "[id]"], + searchParams: new URLSearchParams("tag=a&tag=b&q=hello"), + }); + + expect(layoutParamCalls).toEqual([{}, { category: "books" }]); + expect(pageParams).toEqual({ category: "books", id: "dune" }); + expect({ ...pageSearchParams }).toEqual({ + q: "hello", + tag: ["a", "b"], + }); + expect(result.hasSearchParams).toBe(true); + expect(result.metadata).toMatchObject({ + description: "category", + keywords: ["page"], + }); + }); +}); diff --git a/tests/app-page-params.test.ts b/tests/app-page-params.test.ts new file mode 100644 index 000000000..be550bd7c --- /dev/null +++ b/tests/app-page-params.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vite-plus/test"; +import { resolveAppPageSegmentParams } from "../packages/vinext/src/server/app-page-params.js"; + +describe("app page params helpers", () => { + it("passes only params that apply to each layout", () => { + // Ported from Next.js: test/e2e/app-dir/layout-params/layout-params.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/layout-params/layout-params.test.ts + const routeSegments = ["base", "[param1]", "[param2]"]; + const matchedParams = { param1: "something", param2: "another" }; + + expect(resolveAppPageSegmentParams(routeSegments, 0, matchedParams)).toEqual({}); + expect(resolveAppPageSegmentParams(routeSegments, 1, matchedParams)).toEqual({}); + expect(resolveAppPageSegmentParams(routeSegments, 2, matchedParams)).toEqual({ + param1: "something", + }); + expect(resolveAppPageSegmentParams(routeSegments, 3, matchedParams)).toEqual({ + param1: "something", + param2: "another", + }); + }); + + it("scopes catch-all params to the catch-all layout", () => { + // Ported from Next.js: test/e2e/app-dir/layout-params/layout-params.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/layout-params/layout-params.test.ts + const routeSegments = ["catchall", "[...params]"]; + const matchedParams = { params: ["something", "another"] }; + + expect(resolveAppPageSegmentParams(routeSegments, 1, matchedParams)).toEqual({}); + expect(resolveAppPageSegmentParams(routeSegments, 2, matchedParams)).toEqual({ + params: ["something", "another"], + }); + }); + + it("omits empty optional catch-all params from layouts", () => { + // Ported from Next.js: test/e2e/app-dir/layout-params/layout-params.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/layout-params/layout-params.test.ts + const routeSegments = ["optional-catchall", "[[...params]]"]; + + expect(resolveAppPageSegmentParams(routeSegments, 1, { params: [] })).toEqual({}); + expect(resolveAppPageSegmentParams(routeSegments, 2, { params: [] })).toEqual({}); + expect( + resolveAppPageSegmentParams(routeSegments, 2, { params: ["something", "another"] }), + ).toEqual({ + params: ["something", "another"], + }); + }); +}); diff --git a/tests/app-page-route-wiring.test.ts b/tests/app-page-route-wiring.test.ts index 8f7dde93d..1cb867aae 100644 --- a/tests/app-page-route-wiring.test.ts +++ b/tests/app-page-route-wiring.test.ts @@ -2,6 +2,7 @@ import { Fragment, createElement, isValidElement, type ReactNode } from "react"; import { describe, expect, it } from "vite-plus/test"; import { useSelectedLayoutSegments } from "../packages/vinext/src/shims/navigation.js"; import type { AppElements } from "../packages/vinext/src/server/app-elements.js"; +import type { AppPageParams } from "../packages/vinext/src/server/app-page-boundary.js"; import { type AppPageModule, type AppPageSlotOverride, @@ -193,6 +194,44 @@ describe("app page route wiring helpers", () => { expect(entries.map((entry) => entry.treePath)).toEqual(["/", "/(marketing)"]); }); + it("passes only segment-applicable params to each layout", () => { + // Ported from Next.js: test/e2e/app-dir/app/index.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/app/index.test.ts + const paramCalls: AppPageParams[] = []; + + buildAppPageElements({ + element: createElement(PageProbe), + makeThenableParams(params) { + paramCalls.push({ ...params }); + return Promise.resolve(params); + }, + matchedParams: { category: "books", id: "hello-world" }, + resolvedMetadata: null, + resolvedViewport: {}, + route: { + error: null, + errors: [null, null, null], + layoutTreePositions: [1, 2, 3], + layouts: [{ default: RootLayout }, { default: GroupLayout }, { default: GroupLayout }], + loading: null, + notFound: null, + notFounds: [null, null, null], + routeSegments: ["dynamic", "[category]", "[id]"], + slots: null, + templateTreePositions: [], + templates: [], + }, + routePath: "/dynamic/books/hello-world", + rootNotFoundModule: null, + }); + + expect(paramCalls).toEqual([ + {}, + { category: "books" }, + { category: "books", id: "hello-world" }, + ]); + }); + it("builds a flat elements map with route, layout, template, page, and slot entries", async () => { const elements = buildAppPageElements({ element: createElement(PageProbe), diff --git a/tests/app-router.test.ts b/tests/app-router.test.ts index 489db4850..932268811 100644 --- a/tests/app-router.test.ts +++ b/tests/app-router.test.ts @@ -761,6 +761,26 @@ describe("App Router integration", () => { expect(html).toContain('content="noindex"'); }); + it("forbidden() thrown from a layout uses the forbidden boundary", async () => { + // Ported from Next.js: test/e2e/app-dir/forbidden/basic/forbidden-basic.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/forbidden/basic/forbidden-basic.test.ts + const res = await fetch(`${baseUrl}/nextjs-compat/layout-forbidden-boundary`); + expect(res.status).toBe(403); + const html = await res.text(); + expect(html).toContain("403 - Forbidden"); + expect(html).not.toContain("404 - Page Not Found"); + }); + + it("unauthorized() thrown from a layout uses the unauthorized boundary", async () => { + // Ported from Next.js: test/e2e/app-dir/unauthorized/basic/unauthorized-basic.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/unauthorized/basic/unauthorized-basic.test.ts + const res = await fetch(`${baseUrl}/nextjs-compat/layout-unauthorized-boundary`); + expect(res.status).toBe(401); + const html = await res.text(); + expect(html).toContain("401 - Unauthorized"); + expect(html).not.toContain("404 - Page Not Found"); + }); + // ── Client hook usage without "use client" (#834) ── // When a Server Component imports a client-only hook from next/navigation // without the "use client" directive, vinext should surface a clear error diff --git a/tests/fixtures/app-basic/app/nextjs-compat/layout-forbidden-boundary/layout.tsx b/tests/fixtures/app-basic/app/nextjs-compat/layout-forbidden-boundary/layout.tsx new file mode 100644 index 000000000..cff0ad3fe --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/layout-forbidden-boundary/layout.tsx @@ -0,0 +1,9 @@ +import { forbidden } from "next/navigation"; + +/** + * Next.js compat: forbidden/basic — access fallback thrown from a layout should + * render the matching forbidden boundary, not the not-found boundary. + */ +export default function Layout() { + forbidden(); +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/layout-forbidden-boundary/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/layout-forbidden-boundary/page.tsx new file mode 100644 index 000000000..bdab4d08f --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/layout-forbidden-boundary/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return

forbidden layout page rendered

; +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/layout-metadata-error-with-boundary/error.tsx b/tests/fixtures/app-basic/app/nextjs-compat/layout-metadata-error-with-boundary/error.tsx new file mode 100644 index 000000000..aaf697878 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/layout-metadata-error-with-boundary/error.tsx @@ -0,0 +1,9 @@ +/** + * Next.js compat: global-error/basic — local error boundary for layout metadata errors. + * Source: https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/global-error/basic/index.test.ts + */ +"use client"; + +export default function ErrorBoundary() { + return

Local layout metadata error boundary

; +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/layout-metadata-error-with-boundary/layout.tsx b/tests/fixtures/app-basic/app/nextjs-compat/layout-metadata-error-with-boundary/layout.tsx new file mode 100644 index 000000000..47285e112 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/layout-metadata-error-with-boundary/layout.tsx @@ -0,0 +1,11 @@ +/** + * Next.js compat: global-error/basic — layout generateMetadata errors should + * propagate into the nearest error boundary instead of being swallowed. + */ +export function generateMetadata() { + throw new Error("Layout metadata error"); +} + +export default function Layout({ children }: { children: React.ReactNode }) { + return
{children}
; +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/layout-metadata-error-with-boundary/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/layout-metadata-error-with-boundary/page.tsx new file mode 100644 index 000000000..fab33416b --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/layout-metadata-error-with-boundary/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return

layout metadata page rendered

; +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/layout-metadata-error-without-boundary/layout.tsx b/tests/fixtures/app-basic/app/nextjs-compat/layout-metadata-error-without-boundary/layout.tsx new file mode 100644 index 000000000..0860a83d4 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/layout-metadata-error-without-boundary/layout.tsx @@ -0,0 +1,11 @@ +/** + * Next.js compat: global-error/basic — layout generateMetadata errors without + * a local error boundary should escalate to global-error.tsx. + */ +export function generateMetadata() { + throw new Error("Layout metadata error"); +} + +export default function Layout({ children }: { children: React.ReactNode }) { + return
{children}
; +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/layout-metadata-error-without-boundary/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/layout-metadata-error-without-boundary/page.tsx new file mode 100644 index 000000000..fab33416b --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/layout-metadata-error-without-boundary/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return

layout metadata page rendered

; +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/layout-unauthorized-boundary/layout.tsx b/tests/fixtures/app-basic/app/nextjs-compat/layout-unauthorized-boundary/layout.tsx new file mode 100644 index 000000000..0b6f81009 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/layout-unauthorized-boundary/layout.tsx @@ -0,0 +1,9 @@ +import { unauthorized } from "next/navigation"; + +/** + * Next.js compat: unauthorized/basic — access fallback thrown from a layout + * should render the matching unauthorized boundary, not the not-found boundary. + */ +export default function Layout() { + unauthorized(); +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/layout-unauthorized-boundary/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/layout-unauthorized-boundary/page.tsx new file mode 100644 index 000000000..7242d5f67 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/layout-unauthorized-boundary/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return

unauthorized layout page rendered

; +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/layout-viewport-error-with-boundary/error.tsx b/tests/fixtures/app-basic/app/nextjs-compat/layout-viewport-error-with-boundary/error.tsx new file mode 100644 index 000000000..dd3e80593 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/layout-viewport-error-with-boundary/error.tsx @@ -0,0 +1,10 @@ +/** + * Next.js compat: viewport resolution errors use the same metadata outlet + * boundary path as generateMetadata errors. + * Source: https://github.com/vercel/next.js/blob/canary/packages/next/src/lib/metadata/metadata.tsx + */ +"use client"; + +export default function ErrorBoundary() { + return

Local layout viewport error boundary

; +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/layout-viewport-error-with-boundary/layout.tsx b/tests/fixtures/app-basic/app/nextjs-compat/layout-viewport-error-with-boundary/layout.tsx new file mode 100644 index 000000000..c4645df49 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/layout-viewport-error-with-boundary/layout.tsx @@ -0,0 +1,11 @@ +/** + * Next.js compat: layout generateViewport errors should propagate into the + * nearest error boundary instead of being swallowed. + */ +export function generateViewport() { + throw new Error("Layout viewport error"); +} + +export default function Layout({ children }: { children: React.ReactNode }) { + return
{children}
; +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/layout-viewport-error-with-boundary/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/layout-viewport-error-with-boundary/page.tsx new file mode 100644 index 000000000..603431e9a --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/layout-viewport-error-with-boundary/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return

layout viewport page rendered

; +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/layout-viewport-error-without-boundary/layout.tsx b/tests/fixtures/app-basic/app/nextjs-compat/layout-viewport-error-without-boundary/layout.tsx new file mode 100644 index 000000000..03272e021 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/layout-viewport-error-without-boundary/layout.tsx @@ -0,0 +1,11 @@ +/** + * Next.js compat: layout generateViewport errors without a local error + * boundary should escalate to global-error.tsx. + */ +export function generateViewport() { + throw new Error("Layout viewport error"); +} + +export default function Layout({ children }: { children: React.ReactNode }) { + return
{children}
; +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/layout-viewport-error-without-boundary/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/layout-viewport-error-without-boundary/page.tsx new file mode 100644 index 000000000..603431e9a --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/layout-viewport-error-without-boundary/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return

layout viewport page rendered

; +} diff --git a/tests/nextjs-compat/global-error.test.ts b/tests/nextjs-compat/global-error.test.ts index 05deaeaae..9c65e6953 100644 --- a/tests/nextjs-compat/global-error.test.ts +++ b/tests/nextjs-compat/global-error.test.ts @@ -9,6 +9,8 @@ * - Global-error.tsx as the last resort for root-level errors * - generateMetadata() errors caught by local error.tsx when present * - generateMetadata() errors escalating to global-error when no local boundary + * - layout generateMetadata() errors following the same boundary path + * - layout generateViewport() errors following the same boundary path * * NOTE: Most Next.js global-error tests are browser-based (click buttons, check * rendered error UI after hydration/client error). This file tests SSR-level @@ -130,6 +132,54 @@ describe("Next.js compat: global-error", () => { expect(html).toContain("Metadata error"); }); + it("layout generateMetadata() error caught by local error.tsx boundary", async () => { + // Ported from Next.js: test/e2e/app-dir/global-error/basic/index.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/global-error/basic/index.test.ts + const { res, html } = await fetchHtml( + baseUrl, + "/nextjs-compat/layout-metadata-error-with-boundary", + ); + expect(res.status).toBe(200); + expect(html).toContain("Local layout metadata error boundary"); + expect(html).not.toContain("layout metadata page rendered"); + }); + + it("layout generateMetadata() error without local boundary renders global-error", async () => { + // Ported from Next.js: test/e2e/app-dir/global-error/basic/index.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/global-error/basic/index.test.ts + const { res, html } = await fetchHtml( + baseUrl, + "/nextjs-compat/layout-metadata-error-without-boundary", + ); + expect(res.status).toBe(200); + expect(html).toContain("global-error"); + expect(html).toContain("Layout metadata error"); + }); + + it("layout generateViewport() error caught by local error.tsx boundary", async () => { + // Next.js resolves viewport through the same metadata outlet error path: + // https://github.com/vercel/next.js/blob/canary/packages/next/src/lib/metadata/metadata.tsx + const { res, html } = await fetchHtml( + baseUrl, + "/nextjs-compat/layout-viewport-error-with-boundary", + ); + expect(res.status).toBe(200); + expect(html).toContain("Local layout viewport error boundary"); + expect(html).not.toContain("layout viewport page rendered"); + }); + + it("layout generateViewport() error without local boundary renders global-error", async () => { + // Next.js resolves viewport through the same metadata outlet error path: + // https://github.com/vercel/next.js/blob/canary/packages/next/src/lib/metadata/metadata.tsx + const { res, html } = await fetchHtml( + baseUrl, + "/nextjs-compat/layout-viewport-error-without-boundary", + ); + expect(res.status).toBe(200); + expect(html).toContain("global-error"); + expect(html).toContain("Layout viewport error"); + }); + // ── Structural integrity: no double / tags ─────── // global-error.tsx provides its own and . When it renders, // the root layout's / must NOT also appear. @@ -254,4 +304,42 @@ describe("Next.js compat: global-error (production preview)", () => { expect(res.status).toBe(200); expect(html).toContain("global-error"); }); + + it("layout generateMetadata() errors render the co-located error.tsx boundary with 200", async () => { + const { res, html } = await fetchHtml( + baseUrl, + "/nextjs-compat/layout-metadata-error-with-boundary", + ); + expect(res.status).toBe(200); + expect(html).toContain("Local layout metadata error boundary"); + expect(html).not.toContain("global-error"); + }); + + it("layout generateMetadata() errors without a local boundary escalate to global-error with 200", async () => { + const { res, html } = await fetchHtml( + baseUrl, + "/nextjs-compat/layout-metadata-error-without-boundary", + ); + expect(res.status).toBe(200); + expect(html).toContain("global-error"); + }); + + it("layout generateViewport() errors render the co-located error.tsx boundary with 200", async () => { + const { res, html } = await fetchHtml( + baseUrl, + "/nextjs-compat/layout-viewport-error-with-boundary", + ); + expect(res.status).toBe(200); + expect(html).toContain("Local layout viewport error boundary"); + expect(html).not.toContain("global-error"); + }); + + it("layout generateViewport() errors without a local boundary escalate to global-error with 200", async () => { + const { res, html } = await fetchHtml( + baseUrl, + "/nextjs-compat/layout-viewport-error-without-boundary", + ); + expect(res.status).toBe(200); + expect(html).toContain("global-error"); + }); });