Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions packages/vinext/src/server/app-page-boundary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,28 @@ export function resolveAppPageHttpAccessBoundaryComponent<TModule, TComponent>(
export function resolveAppPageParentHttpAccessBoundaryModule<TModule>(
options: ResolveAppPageParentHttpAccessBoundaryModuleOptions<TModule>,
): 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<TModule>(
options: ResolveAppPageParentHttpAccessBoundaryModuleOptions<TModule>,
): { module: TModule | null; layoutIndex: number | null } {
let routeModules = options.routeNotFoundModules;
let rootModule = options.rootNotFoundModule;

Expand All @@ -132,12 +154,12 @@ export function resolveAppPageParentHttpAccessBoundaryModule<TModule>(
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<TModule, TComponent>(
Expand Down
63 changes: 61 additions & 2 deletions packages/vinext/src/server/app-page-dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<TRoute extends AppPageDispatchRoute> = {
/** Configured basePath (e.g. "/blog"). Used to prefix redirect Locations. */
basePath?: string;
Expand Down Expand Up @@ -826,9 +842,52 @@ async function renderPageSpecialError<TRoute extends AppPageDispatchRoute>(
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,
);
},
Expand Down
34 changes: 34 additions & 0 deletions tests/app-router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<div data-testid="escalate-forbidden-layout">
<h2>Dynamic with Layout</h2>
{children}
</div>
);
}
Original file line number Diff line number Diff line change
@@ -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 <p id="page">escalate-forbidden [id]</p>;
}
Original file line number Diff line number Diff line change
@@ -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 (
<div data-testid="escalate-unauthorized-layout">
<h2>Dynamic with Layout</h2>
{children}
</div>
);
}
Original file line number Diff line number Diff line change
@@ -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 <p id="page">escalate-unauthorized [id]</p>;
}
Loading