Skip to content

Commit dd1dfae

Browse files
fix(app-router): scope layout params and layout error boundaries (#938)
* fix(app-router): scope layout params and propagate layout head errors Layouts received full route params, layout generateMetadata failures were swallowed, and layout-thrown forbidden()/unauthorized() fell back through the not-found boundary path. Those diverge from Next.js when nested layouts depend on segment params, when head generation fails before page render, or when HTTP access APIs are thrown from a layout. The generated RSC entry now delegates param slicing, head resolution, and parent access-boundary selection to typed runtime helpers. The helpers keep route-specific imports in the entry while moving the behavioral core into unit-tested modules. Tests port Next.js layout params, global-error, forbidden, and unauthorized coverage into focused helper and integration regressions. * test(app-router): cover layout viewport error boundaries Layout generateViewport() failures now intentionally share the same boundary path as layout generateMetadata() failures. Copilot flagged that this behavior change needed direct coverage. Add dev and production-preview compat assertions for co-located error.tsx handling and global-error escalation, with fixtures that throw from layout generateViewport(). * chore(app-router): address head review nits
1 parent 37e4e69 commit dd1dfae

29 files changed

Lines changed: 1045 additions & 813 deletions

File tree

packages/vinext/src/entries/app-rsc-entry.ts

Lines changed: 65 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ const appRouteHandlerCachePath = resolveEntryPath(
6060
const implicitTagsPath = resolveEntryPath("../server/implicit-tags.js", import.meta.url);
6161
const appPageCachePath = resolveEntryPath("../server/app-page-cache.js", import.meta.url);
6262
const appPageExecutionPath = resolveEntryPath("../server/app-page-execution.js", import.meta.url);
63+
const appPageBoundaryPath = resolveEntryPath("../server/app-page-boundary.js", import.meta.url);
6364
const appPageBoundaryRenderPath = resolveEntryPath(
6465
"../server/app-page-boundary-render.js",
6566
import.meta.url,
@@ -69,6 +70,8 @@ const appPageRouteWiringPath = resolveEntryPath(
6970
"../server/app-page-route-wiring.js",
7071
import.meta.url,
7172
);
73+
const appPageHeadPath = resolveEntryPath("../server/app-page-head.js", import.meta.url);
74+
const appPageParamsPath = resolveEntryPath("../server/app-page-params.js", import.meta.url);
7275
const appPageRenderPath = resolveEntryPath("../server/app-page-render.js", import.meta.url);
7376
const appPageResponsePath = resolveEntryPath("../server/app-page-response.js", import.meta.url);
7477
const cspPath = resolveEntryPath("../server/csp.js", import.meta.url);
@@ -166,16 +169,23 @@ export function generateRscEntry(
166169
for (const tmpl of route.templates) getImportVar(tmpl);
167170
if (route.loadingPath) getImportVar(route.loadingPath);
168171
if (route.errorPath) getImportVar(route.errorPath);
169-
if (route.layoutErrorPaths)
172+
if (route.layoutErrorPaths) {
170173
for (const ep of route.layoutErrorPaths) {
171174
if (ep) getImportVar(ep);
172175
}
176+
}
173177
if (route.notFoundPath) getImportVar(route.notFoundPath);
174178
for (const nfp of route.notFoundPaths || []) {
175179
if (nfp) getImportVar(nfp);
176180
}
177181
if (route.forbiddenPath) getImportVar(route.forbiddenPath);
182+
for (const fp of route.forbiddenPaths || []) {
183+
if (fp) getImportVar(fp);
184+
}
178185
if (route.unauthorizedPath) getImportVar(route.unauthorizedPath);
186+
for (const up of route.unauthorizedPaths || []) {
187+
if (up) getImportVar(up);
188+
}
179189
// Register parallel slot modules
180190
for (const slot of route.parallelSlots) {
181191
if (slot.pagePath) getImportVar(slot.pagePath);
@@ -198,6 +208,12 @@ export function generateRscEntry(
198208
const layoutVars = route.layouts.map((l) => getImportVar(l));
199209
const templateVars = route.templates.map((t) => getImportVar(t));
200210
const notFoundVars = (route.notFoundPaths || []).map((nf) => (nf ? getImportVar(nf) : "null"));
211+
const forbiddenVars = (route.forbiddenPaths || []).map((fp) =>
212+
fp ? getImportVar(fp) : "null",
213+
);
214+
const unauthorizedVars = (route.unauthorizedPaths || []).map((up) =>
215+
up ? getImportVar(up) : "null",
216+
);
201217
const slotEntries = route.parallelSlots.map((slot) => {
202218
const interceptEntries = slot.interceptingRoutes.map(
203219
(ir) => ` {
@@ -249,7 +265,9 @@ ${slotEntries.join(",\n")}
249265
notFound: ${route.notFoundPath ? getImportVar(route.notFoundPath) : "null"},
250266
notFounds: [${notFoundVars.join(", ")}],
251267
forbidden: ${route.forbiddenPath ? getImportVar(route.forbiddenPath) : "null"},
268+
forbiddens: [${forbiddenVars.join(", ")}],
252269
unauthorized: ${route.unauthorizedPath ? getImportVar(route.unauthorizedPath) : "null"},
270+
unauthorizeds: [${unauthorizedVars.join(", ")}],
253271
}`;
254272
});
255273

@@ -374,7 +392,6 @@ import { createElement } from "react";
374392
import { setNavigationContext as _setNavigationContextOrig, getNavigationContext as _getNavigationContext } from "next/navigation";
375393
import { setHeadersContext, headersContextFromRequest, getDraftModeCookieHeader, getAndClearPendingCookies, consumeDynamicUsage, markDynamicUsage, applyMiddlewareRequestHeaders, getHeadersContext, setHeadersAccessPhase } from "next/headers";
376394
import { NextRequest, NextFetchEvent } from "next/server";
377-
import { mergeMetadata, resolveModuleMetadata, mergeViewport, resolveModuleViewport } from "vinext/metadata";
378395
${middlewarePath ? `import * as middlewareModule from ${JSON.stringify(middlewarePath.replace(/\\/g, "/"))};` : ""}
379396
${instrumentationPath ? `import * as _instrumentation from ${JSON.stringify(instrumentationPath.replace(/\\/g, "/"))};` : ""}
380397
${effectiveMetaRoutes.length > 0 ? `import { sitemapToXml, robotsToText, manifestToJson } from ${JSON.stringify(metadataRoutesPath)};` : ""}
@@ -406,6 +423,9 @@ import {
406423
resolveAppPageSpecialError as __resolveAppPageSpecialError,
407424
teeAppPageRscStreamForCapture as __teeAppPageRscStreamForCapture,
408425
} from ${JSON.stringify(appPageExecutionPath)};
426+
import {
427+
resolveAppPageParentHttpAccessBoundaryModule as __resolveAppPageParentHttpAccessBoundaryModule,
428+
} from ${JSON.stringify(appPageBoundaryPath)};
409429
import {
410430
renderAppPageErrorBoundary as __renderAppPageErrorBoundary,
411431
renderAppPageHttpAccessFallback as __renderAppPageHttpAccessFallback,
@@ -419,6 +439,13 @@ import {
419439
createAppPageTreePath as __createAppPageTreePath,
420440
resolveAppPageChildSegments as __resolveAppPageChildSegments,
421441
} from ${JSON.stringify(appPageRouteWiringPath)};
442+
import {
443+
resolveAppPageSegmentParams as __resolveAppPageSegmentParams,
444+
} from ${JSON.stringify(appPageParamsPath)};
445+
import {
446+
collectAppPageSearchParams as __collectAppPageSearchParams,
447+
resolveAppPageHead as __resolveAppPageHead,
448+
} from ${JSON.stringify(appPageHeadPath)};
422449
import {
423450
renderAppPageLifecycle as __renderAppPageLifecycle,
424451
} from ${JSON.stringify(appPageRenderPath)};
@@ -1020,83 +1047,18 @@ async function buildPageElements(route, params, routePath, pageRequest) {
10201047
};
10211048
}
10221049
1023-
// Resolve metadata and viewport from layouts and page.
1024-
//
1025-
// generateMetadata() accepts a "parent" (Promise of ResolvedMetadata) as its
1026-
// second argument (Next.js 13+). The parent resolves to the accumulated
1027-
// merged metadata of all ancestor segments, enabling patterns like:
1028-
//
1029-
// const previousImages = (await parent).openGraph?.images ?? []
1030-
// return { openGraph: { images: ['/new-image.jpg', ...previousImages] } }
1031-
//
1032-
// Next.js uses an eager-execution-with-serial-resolution approach:
1033-
// all generateMetadata() calls are kicked off concurrently, but each
1034-
// segment's "parent" promise resolves only after the preceding segment's
1035-
// metadata is resolved and merged. This preserves concurrency for I/O-bound
1036-
// work while guaranteeing that parent data is available when needed.
1037-
//
1038-
// We build a chain: layoutParentPromises[0] = Promise.resolve({}) (no parent
1039-
// for root layout), layoutParentPromises[i+1] resolves to merge(layouts[0..i]),
1040-
// and pageParentPromise resolves to merge(all layouts).
1041-
//
1042-
// IMPORTANT: Layout metadata errors are swallowed (.catch(() => null)) because
1043-
// a layout's generateMetadata() failing should not crash the page.
1044-
// Page metadata errors are NOT swallowed — if the page's generateMetadata()
1045-
// throws, the error propagates out of buildPageElement() so the caller can
1046-
// route it to the nearest error.tsx boundary (or global-error.tsx).
1047-
const layoutMods = route.layouts.filter(Boolean);
1048-
1049-
// Convert URLSearchParams → plain object for page generateMetadata() and
1050-
// pageProps.searchParams. Built before the layout loop so the page metadata
1051-
// call (below) and pageProps can reference the same object.
1052-
// NOTE: Layouts do NOT receive searchParams in generateMetadata() — only
1053-
// pages do. This matches Next.js behavior (resolve-metadata.ts:777).
1054-
const spObj = Object.create(null);
1055-
let hasSearchParams = false;
1056-
if (searchParams && searchParams.forEach) {
1057-
searchParams.forEach(function(v, k) {
1058-
hasSearchParams = true;
1059-
if (k in spObj) {
1060-
spObj[k] = Array.isArray(spObj[k]) ? spObj[k].concat(v) : [spObj[k], v];
1061-
} else {
1062-
spObj[k] = v;
1063-
}
1064-
});
1065-
}
1066-
1067-
// Build the parent promise chain and kick off metadata resolution in one pass.
1068-
// Each layout module is called exactly once. layoutMetaPromises[i] is the
1069-
// promise for layout[i]'s own metadata result.
1070-
//
1071-
// All calls are kicked off immediately (concurrent I/O), but each layout's
1072-
// "parent" promise only resolves after the preceding layout's metadata is done.
1073-
const layoutMetaPromises = [];
1074-
let accumulatedMetaPromise = Promise.resolve({});
1075-
for (let i = 0; i < layoutMods.length; i++) {
1076-
const parentForThisLayout = accumulatedMetaPromise;
1077-
// Kick off this layout's metadata resolution now (concurrent with others).
1078-
const metaPromise = resolveModuleMetadata(layoutMods[i], params, undefined, parentForThisLayout)
1079-
.catch((err) => { console.error("[vinext] Layout generateMetadata() failed:", err); return null; });
1080-
layoutMetaPromises.push(metaPromise);
1081-
// Advance accumulator: resolves to merged(layouts[0..i]) once layout[i] is done.
1082-
accumulatedMetaPromise = metaPromise.then(async (result) =>
1083-
result ? mergeMetadata([await parentForThisLayout, result]) : await parentForThisLayout
1084-
);
1085-
}
1086-
// Page's parent is the fully-accumulated layout metadata.
1087-
const pageParentPromise = accumulatedMetaPromise;
1088-
1089-
const [layoutMetaResults, layoutVpResults, pageMeta, pageVp] = await Promise.all([
1090-
Promise.all(layoutMetaPromises),
1091-
Promise.all(layoutMods.map((mod) => resolveModuleViewport(mod, params).catch((err) => { console.error("[vinext] Layout generateViewport() failed:", err); return null; }))),
1092-
route.page ? resolveModuleMetadata(route.page, params, spObj, pageParentPromise) : Promise.resolve(null),
1093-
route.page ? resolveModuleViewport(route.page, params) : Promise.resolve(null),
1094-
]);
1095-
1096-
const metadataList = [...layoutMetaResults.filter(Boolean), ...(pageMeta ? [pageMeta] : [])];
1097-
const viewportList = [...layoutVpResults.filter(Boolean), ...(pageVp ? [pageVp] : [])];
1098-
const resolvedMetadata = metadataList.length > 0 ? mergeMetadata(metadataList) : null;
1099-
const resolvedViewport = mergeViewport(viewportList);
1050+
const __headResult = await __resolveAppPageHead({
1051+
layoutModules: route.layouts,
1052+
layoutTreePositions: route.layoutTreePositions,
1053+
pageModule: route.page,
1054+
params,
1055+
routeSegments: route.routeSegments,
1056+
searchParams,
1057+
});
1058+
const spObj = __headResult.searchParamsObject;
1059+
const hasSearchParams = __headResult.hasSearchParams;
1060+
const resolvedMetadata = __headResult.metadata;
1061+
const resolvedViewport = __headResult.viewport;
11001062
11011063
// Build the route tree from the leaf page, then delegate the boundary/layout/
11021064
// template/segment wiring to a typed runtime helper so the generated entry
@@ -2534,7 +2496,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
25342496
// Note: CSS is automatically injected by @vitejs/plugin-rsc's
25352497
// rscCssTransform — no manual loadCss() call needed.
25362498
const _hasLoadingBoundary = !!(route.loading && route.loading.default);
2537-
const _asyncLayoutParams = makeThenableParams(params);
2499+
const _asyncRouteParams = makeThenableParams(params);
25382500
return __renderAppPageLifecycle({
25392501
cleanPathname,
25402502
clearRequestContext() {
@@ -2576,22 +2538,21 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
25762538
probeLayoutAt(li) {
25772539
const LayoutComp = route.layouts[li]?.default;
25782540
if (!LayoutComp) return null;
2579-
return LayoutComp({ params: _asyncLayoutParams, children: null });
2541+
return LayoutComp({
2542+
params: makeThenableParams(__resolveAppPageSegmentParams(
2543+
route.routeSegments,
2544+
route.layoutTreePositions?.[li] ?? 0,
2545+
params,
2546+
)),
2547+
children: null,
2548+
});
25802549
},
25812550
probePage() {
25822551
if (!PageComponent) return null;
2583-
const _probeSearchObj = {};
2584-
url.searchParams.forEach(function(v, k) {
2585-
if (k in _probeSearchObj) {
2586-
_probeSearchObj[k] = Array.isArray(_probeSearchObj[k])
2587-
? _probeSearchObj[k].concat(v)
2588-
: [_probeSearchObj[k], v];
2589-
} else {
2590-
_probeSearchObj[k] = v;
2591-
}
2592-
});
2593-
const _asyncSearchParams = makeThenableParams(_probeSearchObj);
2594-
return PageComponent({ params: _asyncLayoutParams, searchParams: _asyncSearchParams });
2552+
const _asyncSearchParams = makeThenableParams(
2553+
__collectAppPageSearchParams(url.searchParams).searchParamsObject,
2554+
);
2555+
return PageComponent({ params: _asyncRouteParams, searchParams: _asyncSearchParams });
25952556
},
25962557
classification: {
25972558
getLayoutId(index) {
@@ -2625,27 +2586,24 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
26252586
},
26262587
middlewareContext: _mwCtx,
26272588
renderFallbackPage(statusCode) {
2628-
// Find the not-found component from the parent level (the boundary that
2629-
// would catch this in Next.js). Walk up from the throwing layout to find
2630-
// the nearest not-found at a parent layout's directory.
2631-
let parentNotFound = null;
2632-
if (route.notFounds) {
2633-
for (let pi = li - 1; pi >= 0; pi--) {
2634-
if (route.notFounds[pi]?.default) {
2635-
parentNotFound = route.notFounds[pi].default;
2636-
break;
2637-
}
2638-
}
2639-
}
2640-
if (!parentNotFound) parentNotFound = ${rootNotFoundVar ? `${rootNotFoundVar}?.default` : "null"};
2589+
const parentBoundary = __resolveAppPageParentHttpAccessBoundaryModule({
2590+
layoutIndex: li,
2591+
rootForbiddenModule: ${rootForbiddenVar ?? "null"},
2592+
rootNotFoundModule: ${rootNotFoundVar ?? "null"},
2593+
rootUnauthorizedModule: ${rootUnauthorizedVar ?? "null"},
2594+
routeForbiddenModules: route.forbiddens,
2595+
routeNotFoundModules: route.notFounds,
2596+
routeUnauthorizedModules: route.unauthorizeds,
2597+
statusCode,
2598+
})?.default ?? null;
26412599
const parentLayouts = route.layouts.slice(0, li);
26422600
return renderHTTPAccessFallbackPage(
26432601
route,
26442602
statusCode,
26452603
isRscRequest,
26462604
request,
26472605
{
2648-
boundaryComponent: parentNotFound,
2606+
boundaryComponent: parentBoundary,
26492607
layouts: parentLayouts,
26502608
matchedParams: params,
26512609
},

packages/vinext/src/routing/app-router.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,8 +108,12 @@ export type AppRoute = {
108108
notFoundPaths: (string | null)[];
109109
/** Forbidden component path (403) */
110110
forbiddenPath: string | null;
111+
/** Forbidden component paths per layout level (aligned with layouts array). */
112+
forbiddenPaths?: (string | null)[];
111113
/** Unauthorized component path (401) */
112114
unauthorizedPath: string | null;
115+
/** Unauthorized component paths per layout level (aligned with layouts array). */
116+
unauthorizedPaths?: (string | null)[];
113117
/**
114118
* Filesystem segments from app/ root to the route's directory.
115119
* Includes route groups and dynamic segments (as template strings like "[id]").
@@ -432,7 +436,9 @@ function discoverSlotSubRoutes(
432436
notFoundPath: parentRoute.notFoundPath,
433437
notFoundPaths: parentRoute.notFoundPaths,
434438
forbiddenPath: parentRoute.forbiddenPath,
439+
forbiddenPaths: parentRoute.forbiddenPaths,
435440
unauthorizedPath: parentRoute.unauthorizedPath,
441+
unauthorizedPaths: parentRoute.unauthorizedPaths,
436442
routeSegments: [...parentRoute.routeSegments, ...rawSegments],
437443
templateTreePositions: parentRoute.templateTreePositions,
438444
layoutTreePositions: parentRoute.layoutTreePositions,
@@ -554,6 +560,8 @@ function directoryToAppRoute(
554560
// These are used for per-layout NotFoundBoundary to match Next.js behavior where
555561
// notFound() thrown from a layout is caught by the parent layout's boundary.
556562
const notFoundPaths = discoverBoundaryFilePerLayout(layouts, "not-found", matcher);
563+
const forbiddenPaths = discoverBoundaryFilePerLayout(layouts, "forbidden", matcher);
564+
const unauthorizedPaths = discoverBoundaryFilePerLayout(layouts, "unauthorized", matcher);
557565

558566
// Discover parallel slots (@team, @analytics, etc.).
559567
// Slots at the route's own directory use page.tsx; slots at ancestor directories
@@ -573,7 +581,9 @@ function directoryToAppRoute(
573581
notFoundPath,
574582
notFoundPaths,
575583
forbiddenPath,
584+
forbiddenPaths,
576585
unauthorizedPath,
586+
unauthorizedPaths,
577587
routeSegments: segments,
578588
templateTreePositions,
579589
layoutTreePositions,

0 commit comments

Comments
 (0)