diff --git a/packages/vinext/src/server/app-page-boundary.ts b/packages/vinext/src/server/app-page-boundary.ts index 2a21f39bb..de4368013 100644 --- a/packages/vinext/src/server/app-page-boundary.ts +++ b/packages/vinext/src/server/app-page-boundary.ts @@ -117,6 +117,28 @@ export function resolveAppPageHttpAccessBoundaryComponent( export function resolveAppPageParentHttpAccessBoundaryModule( options: ResolveAppPageParentHttpAccessBoundaryModuleOptions, ): TModule | null { + return resolveAppPageParentHttpAccessBoundary(options).module; +} + +/** + * Like {@link resolveAppPageParentHttpAccessBoundaryModule}, but also returns + * the layout index that owns the resolved boundary so callers can slice the + * layouts array to skip rendering layouts below the boundary owner. + * + * `layoutIndex` is the per-layout index where the boundary lives, or `null` if + * the resolved boundary is the root module (which conceptually sits above all + * layouts when no layout-level boundary is present). + * + * Used by the page-error fast path to make `forbidden()` / `unauthorized()` / + * `notFound()` escalate past intermediate layouts that lack a boundary file, + * matching Next.js's `create-component-tree.tsx` behavior where the nearest + * ancestor boundary owns the fallback subtree. + * + * @see https://github.com/vercel/next.js/blob/canary/packages/next/src/server/app-render/create-component-tree.tsx + */ +export function resolveAppPageParentHttpAccessBoundary( + options: ResolveAppPageParentHttpAccessBoundaryModuleOptions, +): { module: TModule | null; layoutIndex: number | null } { let routeModules = options.routeNotFoundModules; let rootModule = options.rootNotFoundModule; @@ -132,12 +154,12 @@ export function resolveAppPageParentHttpAccessBoundaryModule( for (let index = options.layoutIndex - 1; index >= 0; index--) { const module = routeModules[index]; if (module) { - return module; + return { module, layoutIndex: index }; } } } - return rootModule ?? null; + return { module: rootModule ?? null, layoutIndex: null }; } export function resolveAppPageErrorBoundary( diff --git a/packages/vinext/src/server/app-page-dispatch.ts b/packages/vinext/src/server/app-page-dispatch.ts index 7b4543573..b0a0eb6f8 100644 --- a/packages/vinext/src/server/app-page-dispatch.ts +++ b/packages/vinext/src/server/app-page-dispatch.ts @@ -32,7 +32,10 @@ import { } from "vinext/shims/fetch-cache"; import { AppElementsWire, type AppOutgoingElements } from "./app-elements.js"; import { readAppPageCacheResponse } from "./app-page-cache.js"; -import { resolveAppPageParentHttpAccessBoundaryModule } from "./app-page-boundary.js"; +import { + resolveAppPageParentHttpAccessBoundary, + resolveAppPageParentHttpAccessBoundaryModule, +} from "./app-page-boundary.js"; import { readStreamAsText } from "../utils/text-stream.js"; import { buildAppPageSpecialErrorResponse, @@ -131,18 +134,31 @@ type AppPageDispatchRoute = { __buildTimeReasons?: LayoutClassificationOptions["buildTimeReasons"]; error?: AppPageModule | null; errors?: readonly (AppPageModule | null | undefined)[]; + forbidden?: AppPageModule | null; forbiddens?: readonly (AppPageModule | null | undefined)[]; isDynamic: boolean; layouts: readonly AppPageModule[]; layoutTreePositions?: readonly number[]; loading?: AppPageModule | null; + notFound?: AppPageModule | null; notFounds?: readonly (AppPageModule | null | undefined)[]; params: readonly string[]; pattern: string; routeSegments: readonly string[]; + unauthorized?: AppPageModule | null; unauthorizeds?: readonly (AppPageModule | null | undefined)[]; }; +function resolveAppPageRouteBoundaryModule( + route: AppPageDispatchRoute, + statusCode: number, +): AppPageModule | null { + if (statusCode === 403) return route.forbidden ?? null; + if (statusCode === 401) return route.unauthorized ?? null; + if (statusCode === 404) return route.notFound ?? null; + return null; +} + type DispatchAppPageOptions = { /** Configured basePath (e.g. "/blog"). Used to prefix redirect Locations. */ basePath?: string; @@ -826,9 +842,52 @@ async function renderPageSpecialError( isRscRequest: options.isRscRequest, middlewareContext: options.middlewareContext, renderFallbackPage(statusCode) { + // `forbidden()` / `unauthorized()` / `notFound()` should be caught by the + // nearest ancestor boundary. When the page (the deepest segment) calls + // one of these and an intermediate layout has no matching boundary file, + // resolve to the closest ancestor layout's boundary and slice off any + // layouts beneath it so their UI does not render alongside the fallback. + // Mirrors Next.js's per-segment boundary nesting in + // `create-component-tree.tsx` (issue #1547). + // + // We only narrow layouts when the resolved boundary file lives at a + // layout's own directory. A `forbidden.tsx` sibling to the route's + // `page.tsx` (no layout there) wraps just the page subtree in Next.js, + // so all of the route's layouts must still render. + const routeBoundaryModule = resolveAppPageRouteBoundaryModule(options.route, statusCode); + const layoutCount = options.route.layouts.length; + const { module: parentBoundaryModule, layoutIndex: boundaryLayoutIndex } = + resolveAppPageParentHttpAccessBoundary({ + layoutIndex: layoutCount, + rootForbiddenModule: options.rootForbiddenModule, + rootNotFoundModule: options.rootNotFoundModule, + rootUnauthorizedModule: options.rootUnauthorizedModule, + routeForbiddenModules: options.route.forbiddens, + routeNotFoundModules: options.route.notFounds, + routeUnauthorizedModules: options.route.unauthorizeds, + statusCode, + }); + // If the route-level boundary (closest walking up from page-dir) differs + // from the per-layout resolution, a non-layout-aligned boundary sits + // below the deepest layout — keep all layouts and let the existing route + // boundary handling render it. + const useLayoutAlignedBoundary = + boundaryLayoutIndex !== null && + (routeBoundaryModule === null || routeBoundaryModule === parentBoundaryModule); + const boundaryComponent = useLayoutAlignedBoundary + ? ((parentBoundaryModule as { default?: unknown } | null)?.default ?? undefined) + : undefined; + const layoutsForBoundary = + useLayoutAlignedBoundary && boundaryLayoutIndex !== null + ? options.route.layouts.slice(0, boundaryLayoutIndex + 1) + : undefined; return options.renderHttpAccessFallbackPage( statusCode, - { matchedParams: options.params }, + { + boundaryComponent, + layouts: layoutsForBoundary, + matchedParams: options.params, + }, null, ); }, diff --git a/tests/app-router.test.ts b/tests/app-router.test.ts index 4a20acb22..b6fd0f6e7 100644 --- a/tests/app-router.test.ts +++ b/tests/app-router.test.ts @@ -1127,6 +1127,40 @@ describe("App Router integration", () => { expect(html).not.toContain("404 - Page Not Found"); }); + it("forbidden() escalates from a deep page to the nearest parent boundary (#1547)", async () => { + // Ported from Next.js: test/e2e/app-dir/forbidden/basic/forbidden-basic.test.ts + // ("should escalate forbidden to parent layout if no forbidden boundary present in current layer") + // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/forbidden/basic/forbidden-basic.test.ts + // + // The intermediate /escalate-forbidden-boundary layout has no forbidden.tsx, + // so forbidden() thrown from /escalate-forbidden-boundary/sub/403 must + // escalate past that layout to the root forbidden boundary, replacing the + // intermediate layout's UI ("Dynamic with Layout") with the root boundary + // rather than rendering it alongside. + const res = await fetch(`${baseUrl}/nextjs-compat/escalate-forbidden-boundary/sub/403`); + expect(res.status).toBe(403); + const html = await res.text(); + expect(html).toContain("403 - Forbidden"); + expect(html).not.toContain("escalate-forbidden [id]"); + // The intermediate layout's UI must NOT render: forbidden() should bubble + // past the layout-with-no-boundary to the nearest ancestor that has one. + expect(html).not.toContain("Dynamic with Layout"); + expect(html).not.toContain("escalate-forbidden-layout"); + }); + + it("unauthorized() escalates from a deep page to the nearest parent boundary (#1547)", async () => { + // Ported from Next.js: test/e2e/app-dir/unauthorized/basic/unauthorized-basic.test.ts + // ("should escalate unauthorized to parent layout if no unauthorized boundary present in current layer") + // 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/escalate-unauthorized-boundary/sub/401`); + expect(res.status).toBe(401); + const html = await res.text(); + expect(html).toContain("401 - Unauthorized"); + expect(html).not.toContain("escalate-unauthorized [id]"); + expect(html).not.toContain("Dynamic with Layout"); + expect(html).not.toContain("escalate-unauthorized-layout"); + }); + // ── 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/escalate-forbidden-boundary/layout.tsx b/tests/fixtures/app-basic/app/nextjs-compat/escalate-forbidden-boundary/layout.tsx new file mode 100644 index 000000000..0fa42301e --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/escalate-forbidden-boundary/layout.tsx @@ -0,0 +1,16 @@ +/** + * Next.js compat: forbidden/basic — when a deep page calls `forbidden()` and the + * intermediate layout segment has no `forbidden.tsx`, the boundary must escalate + * to the nearest ancestor that does (here: the app root `forbidden.tsx`). + * + * Ported from Next.js: test/e2e/app-dir/forbidden/basic/app/dynamic-layout-without-forbidden/layout.js + * https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/forbidden/basic/app/dynamic-layout-without-forbidden/layout.js + */ +export default function Layout({ children }: { children: React.ReactNode }) { + return ( +
+

Dynamic with Layout

+ {children} +
+ ); +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/escalate-forbidden-boundary/sub/[id]/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/escalate-forbidden-boundary/sub/[id]/page.tsx new file mode 100644 index 000000000..e330c089e --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/escalate-forbidden-boundary/sub/[id]/page.tsx @@ -0,0 +1,11 @@ +import { forbidden } from "next/navigation"; + +export const dynamic = "force-dynamic"; + +export default async function Page(props: { params: Promise<{ id: string }> }) { + const { id } = await props.params; + if (id === "403") { + forbidden(); + } + return

escalate-forbidden [id]

; +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/escalate-unauthorized-boundary/layout.tsx b/tests/fixtures/app-basic/app/nextjs-compat/escalate-unauthorized-boundary/layout.tsx new file mode 100644 index 000000000..7686d2710 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/escalate-unauthorized-boundary/layout.tsx @@ -0,0 +1,16 @@ +/** + * Next.js compat: unauthorized/basic — when a deep page calls `unauthorized()` + * and the intermediate layout segment has no `unauthorized.tsx`, the boundary + * must escalate to the nearest ancestor that does (the root `unauthorized.tsx`). + * + * Ported from Next.js: test/e2e/app-dir/unauthorized/basic/app/dynamic-layout-without-unauthorized/layout.js + * https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/unauthorized/basic/app/dynamic-layout-without-unauthorized/layout.js + */ +export default function Layout({ children }: { children: React.ReactNode }) { + return ( +
+

Dynamic with Layout

+ {children} +
+ ); +} diff --git a/tests/fixtures/app-basic/app/nextjs-compat/escalate-unauthorized-boundary/sub/[id]/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/escalate-unauthorized-boundary/sub/[id]/page.tsx new file mode 100644 index 000000000..90a7cb122 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/escalate-unauthorized-boundary/sub/[id]/page.tsx @@ -0,0 +1,11 @@ +import { unauthorized } from "next/navigation"; + +export const dynamic = "force-dynamic"; + +export default async function Page(props: { params: Promise<{ id: string }> }) { + const { id } = await props.params; + if (id === "401") { + unauthorized(); + } + return

escalate-unauthorized [id]

; +}