From 7f4f8ebb019ab08597e0eba0a978bf98f5e7745d Mon Sep 17 00:00:00 2001
From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com>
Date: Thu, 2 Apr 2026 12:33:43 +1100
Subject: [PATCH 1/7] Extract app page route wiring helpers
---
packages/vinext/src/entries/app-rsc-entry.ts | 267 +--
.../src/server/app-page-route-wiring.tsx | 317 ++++
packages/vinext/src/shims/error-boundary.tsx | 35 +-
.../entry-templates.test.ts.snap | 1514 ++---------------
tests/app-page-route-wiring.test.ts | 152 ++
tests/error-boundary.test.ts | 136 +-
6 files changed, 815 insertions(+), 1606 deletions(-)
create mode 100644 packages/vinext/src/server/app-page-route-wiring.tsx
create mode 100644 tests/app-page-route-wiring.test.ts
diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts
index 23e1c3ecc..52b21e0ba 100644
--- a/packages/vinext/src/entries/app-rsc-entry.ts
+++ b/packages/vinext/src/entries/app-rsc-entry.ts
@@ -55,6 +55,10 @@ const appPageBoundaryRenderPath = resolveEntryPath(
"../server/app-page-boundary-render.js",
import.meta.url,
);
+const appPageRouteWiringPath = resolveEntryPath(
+ "../server/app-page-route-wiring.js",
+ import.meta.url,
+);
const appPageRenderPath = resolveEntryPath("../server/app-page-render.js", import.meta.url);
const appPageRequestPath = resolveEntryPath("../server/app-page-request.js", import.meta.url);
const appRouteHandlerResponsePath = resolveEntryPath(
@@ -337,13 +341,11 @@ function renderToReadableStream(model, options) {
}
}));
}
-import { createElement, Suspense, Fragment } from "react";
+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 { ErrorBoundary, NotFoundBoundary } from "vinext/error-boundary";
-import { LayoutSegmentProvider } from "vinext/layout-segment-context";
-import { MetadataHead, mergeMetadata, resolveModuleMetadata, ViewportHead, mergeViewport, resolveModuleViewport } from "vinext/metadata";
+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)};` : ""}
@@ -375,6 +377,10 @@ import {
renderAppPageErrorBoundary as __renderAppPageErrorBoundary,
renderAppPageHttpAccessFallback as __renderAppPageHttpAccessFallback,
} from ${JSON.stringify(appPageBoundaryRenderPath)};
+import {
+ buildAppPageRouteElement as __buildAppPageRouteElement,
+ resolveAppPageChildSegments as __resolveAppPageChildSegments,
+} from ${JSON.stringify(appPageRouteWiringPath)};
import {
renderAppPageLifecycle as __renderAppPageLifecycle,
} from ${JSON.stringify(appPageRenderPath)};
@@ -542,38 +548,6 @@ function makeThenableParams(obj) {
return Object.assign(Promise.resolve(plain), plain);
}
-// Resolve route tree segments to actual values using matched params.
-// Dynamic segments like [id] are replaced with param values, catch-all
-// segments like [...slug] are joined with "/", and route groups are kept as-is.
-function __resolveChildSegments(routeSegments, treePosition, params) {
- var raw = routeSegments.slice(treePosition);
- var result = [];
- for (var j = 0; j < raw.length; j++) {
- var seg = raw[j];
- // Optional catch-all: [[...param]]
- if (seg.indexOf("[[...") === 0 && seg.charAt(seg.length - 1) === "]" && seg.charAt(seg.length - 2) === "]") {
- var pn = seg.slice(5, -2);
- var v = params[pn];
- // Skip empty optional catch-all (e.g., visiting /blog on [[...slug]] route)
- if (Array.isArray(v) && v.length === 0) continue;
- if (v == null) continue;
- result.push(Array.isArray(v) ? v.join("/") : v);
- // Catch-all: [...param]
- } else if (seg.indexOf("[...") === 0 && seg.charAt(seg.length - 1) === "]") {
- var pn2 = seg.slice(4, -1);
- var v2 = params[pn2];
- result.push(Array.isArray(v2) ? v2.join("/") : (v2 || seg));
- // Dynamic: [param]
- } else if (seg.charAt(0) === "[" && seg.charAt(seg.length - 1) === "]" && seg.indexOf(".") === -1) {
- var pn3 = seg.slice(1, -1);
- result.push(params[pn3] || seg);
- } else {
- result.push(seg);
- }
- }
- return result;
-}
-
// djb2 hash — matches Next.js's stringHash for digest generation.
// Produces a stable numeric string from error message + stack.
function __errorDigest(str) {
@@ -777,7 +751,7 @@ async function renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, req
makeThenableParams,
matchedParams: opts?.matchedParams ?? route?.params ?? {},
requestUrl: request.url,
- resolveChildSegments: __resolveChildSegments,
+ resolveChildSegments: __resolveAppPageChildSegments,
rootForbiddenModule: rootForbiddenModule,
rootLayouts: rootLayouts,
rootNotFoundModule: rootNotFoundModule,
@@ -823,7 +797,7 @@ async function renderErrorBoundaryPage(route, error, isRscRequest, request, matc
makeThenableParams,
matchedParams: matchedParams ?? route?.params ?? {},
requestUrl: request.url,
- resolveChildSegments: __resolveChildSegments,
+ resolveChildSegments: __resolveAppPageChildSegments,
rootLayouts: rootLayouts,
route,
renderToReadableStream,
@@ -989,12 +963,10 @@ async function buildPageElement(route, params, opts, searchParams) {
const resolvedMetadata = metadataList.length > 0 ? mergeMetadata(metadataList) : null;
const resolvedViewport = mergeViewport(viewportList);
- // Build nested layout tree from outermost to innermost.
- // Next.js 16 passes params/searchParams as Promises (async pattern)
- // but pre-16 code accesses them as plain objects (params.id).
- // makeThenableParams() normalises null-prototype + preserves both patterns.
- const asyncParams = makeThenableParams(params);
- const pageProps = { params: asyncParams };
+ // 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
+ // stays thin and the wiring logic can be unit tested directly.
+ const pageProps = { params: makeThenableParams(params) };
if (searchParams) {
// Always provide searchParams prop when the URL object is available, even
// when the query string is empty -- pages that do "await searchParams" need
@@ -1010,196 +982,25 @@ async function buildPageElement(route, params, opts, searchParams) {
// dynamic, and this avoids false positives from React internals.
if (hasSearchParams) markDynamicUsage();
}
- let element = createElement(PageComponent, pageProps);
-
- // Wrap page with empty segment provider so useSelectedLayoutSegments()
- // returns [] when called from inside a page component (leaf node).
- element = createElement(LayoutSegmentProvider, { segmentMap: { children: [] } }, element);
-
- // Add metadata + viewport head tags (React 19 hoists title/meta/link to
)
- // Next.js always injects charset and default viewport even when no metadata/viewport
- // is exported. We replicate that by always emitting these essential head elements.
- {
- const headElements = [];
- // Always emit — Next.js includes this on every page
- headElements.push(createElement("meta", { charSet: "utf-8" }));
- if (resolvedMetadata) headElements.push(createElement(MetadataHead, { metadata: resolvedMetadata }));
- headElements.push(createElement(ViewportHead, { viewport: resolvedViewport }));
- element = createElement(Fragment, null, ...headElements, element);
- }
-
- // Wrap with loading.tsx Suspense if present
- if (route.loading?.default) {
- element = createElement(
- Suspense,
- { fallback: createElement(route.loading.default) },
- element,
- );
- }
-
- // Wrap with the leaf's error.tsx ErrorBoundary if it's not already covered
- // by a per-layout error boundary (i.e., the leaf has error.tsx but no layout).
- // Per-layout error boundaries are interleaved with layouts below.
- {
- const lastLayoutError = route.errors ? route.errors[route.errors.length - 1] : null;
- if (route.error?.default && route.error !== lastLayoutError) {
- element = createElement(ErrorBoundary, {
- fallback: route.error.default,
- children: element,
- });
- }
- }
-
- // Wrap with NotFoundBoundary so client-side notFound() renders not-found.tsx
- // instead of crashing the React tree. Must be above ErrorBoundary since
- // ErrorBoundary re-throws notFound errors.
- // Pre-render the not-found component as a React element since it may be a
- // server component (not a client reference) and can't be passed as a function prop.
- {
- const NotFoundComponent = route.notFound?.default ?? ${rootNotFoundVar ? `${rootNotFoundVar}?.default` : "null"};
- if (NotFoundComponent) {
- element = createElement(NotFoundBoundary, {
- fallback: createElement(NotFoundComponent),
- children: element,
- });
- }
- }
-
- // Wrap with templates (innermost first, then outer)
- // Templates are like layouts but re-mount on navigation (client-side concern).
- // On the server, they just wrap the content like layouts do.
- if (route.templates) {
- for (let i = route.templates.length - 1; i >= 0; i--) {
- const TemplateComponent = route.templates[i]?.default;
- if (TemplateComponent) {
- element = createElement(TemplateComponent, { children: element, params });
- }
- }
- }
-
- // Wrap with layouts (innermost first, then outer).
- // At each layout level, first wrap with that level's error boundary (if any)
- // so the boundary is inside the layout and catches errors from children.
- // This matches Next.js behavior: Layout > ErrorBoundary > children.
- // Parallel slots are passed as named props to the innermost layout
- // (the layout at the same directory level as the page/slots)
- for (let i = route.layouts.length - 1; i >= 0; i--) {
- // Wrap with per-layout error boundary before wrapping with layout.
- // This places the ErrorBoundary inside the layout, catching errors
- // from child segments (matching Next.js per-segment error handling).
- if (route.errors && route.errors[i]?.default) {
- element = createElement(ErrorBoundary, {
- fallback: route.errors[i].default,
- children: element,
- });
- }
-
- const LayoutComponent = route.layouts[i]?.default;
- if (LayoutComponent) {
- // Per-layout NotFoundBoundary: wraps this layout's children so that
- // notFound() thrown from a child layout is caught here.
- // Matches Next.js behavior where each segment has its own boundary.
- // The boundary at level N catches errors from Layout[N+1] and below,
- // but NOT from Layout[N] itself (which propagates to level N-1).
- {
- const LayoutNotFound = route.notFounds?.[i]?.default;
- if (LayoutNotFound) {
- element = createElement(NotFoundBoundary, {
- fallback: createElement(LayoutNotFound),
- children: element,
- });
- }
- }
-
- const layoutProps = { children: element, params: makeThenableParams(params) };
-
- // Add parallel slot elements to the layout that defines them.
- // Each slot has a layoutIndex indicating which layout it belongs to.
- if (route.slots) {
- for (const [slotName, slotMod] of Object.entries(route.slots)) {
- // Attach slot to the layout at its layoutIndex, or to the innermost layout if -1
- const targetIdx = slotMod.layoutIndex >= 0 ? slotMod.layoutIndex : route.layouts.length - 1;
- if (i !== targetIdx) continue;
- // Check if this slot has an intercepting route that should activate
- let SlotPage = null;
- let slotParams = params;
-
- if (opts && opts.interceptSlot === slotName && opts.interceptPage) {
- // Use the intercepting route's page component
- SlotPage = opts.interceptPage.default;
- slotParams = opts.interceptParams || params;
- } else {
- SlotPage = slotMod.page?.default || slotMod.default?.default;
- }
-
- if (SlotPage) {
- let slotElement = createElement(SlotPage, { params: makeThenableParams(slotParams) });
- // Wrap with slot-specific layout if present.
- // In Next.js, @slot/layout.tsx wraps the slot's page content
- // before it is passed as a prop to the parent layout.
- const SlotLayout = slotMod.layout?.default;
- if (SlotLayout) {
- slotElement = createElement(SlotLayout, {
- children: slotElement,
- params: makeThenableParams(slotParams),
- });
- }
- // Wrap with slot-specific loading if present
- if (slotMod.loading?.default) {
- slotElement = createElement(Suspense,
- { fallback: createElement(slotMod.loading.default) },
- slotElement,
- );
- }
- // Wrap with slot-specific error boundary if present
- if (slotMod.error?.default) {
- slotElement = createElement(ErrorBoundary, {
- fallback: slotMod.error.default,
- children: slotElement,
- });
- }
- layoutProps[slotName] = slotElement;
+ return __buildAppPageRouteElement({
+ element: createElement(PageComponent, pageProps),
+ globalErrorModule: ${globalErrorVar ? globalErrorVar : "null"},
+ makeThenableParams,
+ matchedParams: params,
+ resolvedMetadata,
+ resolvedViewport,
+ rootNotFoundModule: ${rootNotFoundVar ? rootNotFoundVar : "null"},
+ route,
+ slotOverrides:
+ opts && opts.interceptSlot && opts.interceptPage
+ ? {
+ [opts.interceptSlot]: {
+ pageModule: opts.interceptPage,
+ params: opts.interceptParams || params,
+ },
}
- }
- }
-
- element = createElement(LayoutComponent, layoutProps);
-
- // Wrap the layout with LayoutSegmentProvider so useSelectedLayoutSegments()
- // called INSIDE this layout gets the correct child segments. We resolve the
- // route tree segments using actual param values and pass them through context.
- // We wrap the layout (not just children) because hooks are called from
- // components rendered inside the layout's own JSX.
- const treePos = route.layoutTreePositions ? route.layoutTreePositions[i] : 0;
- const childSegs = __resolveChildSegments(route.routeSegments || [], treePos, params);
- element = createElement(LayoutSegmentProvider, { segmentMap: { children: childSegs } }, element);
- }
- }
-
- // Wrap with global error boundary if app/global-error.tsx exists.
- // This must be present in both HTML and RSC paths so the component tree
- // structure matches — otherwise React reconciliation on client-side navigation
- // would see a mismatched tree and destroy/recreate the DOM.
- //
- // For RSC requests (client-side nav), this provides error recovery on the client.
- // For HTML requests (initial page load), the ErrorBoundary catches during SSR
- // but produces double / (root layout + global-error). The request
- // handler detects this via the rscOnError flag and re-renders without layouts.
- ${
- globalErrorVar
- ? `
- const GlobalErrorComponent = ${globalErrorVar}.default;
- if (GlobalErrorComponent) {
- element = createElement(ErrorBoundary, {
- fallback: GlobalErrorComponent,
- children: element,
- });
- }
- `
- : ""
- }
-
- return element;
+ : null,
+ });
}
${middlewarePath ? generateMiddlewareMatcherCode("modern") : ""}
diff --git a/packages/vinext/src/server/app-page-route-wiring.tsx b/packages/vinext/src/server/app-page-route-wiring.tsx
new file mode 100644
index 000000000..936826612
--- /dev/null
+++ b/packages/vinext/src/server/app-page-route-wiring.tsx
@@ -0,0 +1,317 @@
+import { Suspense, type ComponentType, type ReactNode } from "react";
+import { ErrorBoundary, NotFoundBoundary } from "../shims/error-boundary.js";
+import { LayoutSegmentProvider } from "../shims/layout-segment-context.js";
+import { MetadataHead, ViewportHead, type Metadata, type Viewport } from "../shims/metadata.js";
+import type { AppPageParams } from "./app-page-boundary.js";
+
+type AppPageComponentProps = {
+ children?: ReactNode;
+ error?: Error;
+ params?: unknown;
+ reset?: () => void;
+} & Record;
+
+type AppPageComponent = ComponentType;
+type ErrorBoundaryFallbackComponent = ComponentType<{ error: Error; reset: () => void }>;
+
+export type AppPageModule = Record & {
+ default?: AppPageComponent | null | undefined;
+};
+
+export type AppPageRouteWiringSlot = {
+ default?: TModule | null;
+ error?: TModule | null;
+ layout?: TModule | null;
+ layoutIndex: number;
+ loading?: TModule | null;
+ page?: TModule | null;
+};
+
+export type AppPageRouteWiringRoute = {
+ error?: TModule | null;
+ errors?: readonly (TModule | null | undefined)[] | null;
+ layoutTreePositions?: readonly number[] | null;
+ layouts: readonly (TModule | null | undefined)[];
+ loading?: TModule | null;
+ notFound?: TModule | null;
+ notFounds?: readonly (TModule | null | undefined)[] | null;
+ routeSegments?: readonly string[];
+ slots?: Readonly>> | null;
+ templates?: readonly (TModule | null | undefined)[] | null;
+};
+
+export type AppPageSlotOverride = {
+ pageModule: TModule;
+ params?: AppPageParams;
+ props?: Readonly>;
+};
+
+export type AppPageLayoutEntry = {
+ errorModule?: TModule | null | undefined;
+ id: string;
+ layoutModule?: TModule | null | undefined;
+ notFoundModule?: TModule | null | undefined;
+ treePath: string;
+ treePosition: number;
+};
+
+export type BuildAppPageRouteElementOptions = {
+ element: ReactNode;
+ globalErrorModule?: TModule | null;
+ makeThenableParams: (params: AppPageParams) => unknown;
+ matchedParams: AppPageParams;
+ resolvedMetadata: Metadata | null;
+ resolvedViewport: Viewport;
+ rootNotFoundModule?: TModule | null;
+ route: AppPageRouteWiringRoute;
+ slotOverrides?: Readonly>> | null;
+};
+
+function getDefaultExport(
+ module: TModule | null | undefined,
+): AppPageComponent | null {
+ return module?.default ?? null;
+}
+
+function wrapWithErrorBoundary(fallback: AppPageComponent, children: ReactNode): ReactNode {
+ const FallbackBoundary: ErrorBoundaryFallbackComponent = ({ error, reset }) => {
+ const FallbackComponent = fallback;
+ return ;
+ };
+
+ return {children};
+}
+
+export function createAppPageTreePath(
+ routeSegments: readonly string[] | null | undefined,
+ treePosition: number,
+): string {
+ const treePathSegments = routeSegments?.slice(0, treePosition) ?? [];
+ if (treePathSegments.length === 0) {
+ return "/";
+ }
+ return `/${treePathSegments.join("/")}`;
+}
+
+export function createAppPageLayoutEntries(
+ route: Pick<
+ AppPageRouteWiringRoute,
+ "errors" | "layoutTreePositions" | "layouts" | "notFounds" | "routeSegments"
+ >,
+): AppPageLayoutEntry[] {
+ return route.layouts.map((layoutModule, index) => {
+ const treePosition = route.layoutTreePositions?.[index] ?? 0;
+ const treePath = createAppPageTreePath(route.routeSegments, treePosition);
+ return {
+ errorModule: route.errors?.[index] ?? null,
+ id: `layout:${treePath}`,
+ layoutModule,
+ notFoundModule: route.notFounds?.[index] ?? null,
+ treePath,
+ treePosition,
+ };
+ });
+}
+
+export function resolveAppPageChildSegments(
+ routeSegments: readonly string[],
+ treePosition: number,
+ params: AppPageParams,
+): string[] {
+ const rawSegments = routeSegments.slice(treePosition);
+ const resolvedSegments: string[] = [];
+
+ for (const segment of rawSegments) {
+ if (
+ segment.startsWith("[[...") &&
+ segment.endsWith("]]") &&
+ segment.length > "[[...x]]".length - 1
+ ) {
+ const paramName = segment.slice(5, -2);
+ const paramValue = params[paramName];
+ if (Array.isArray(paramValue) && paramValue.length === 0) {
+ continue;
+ }
+ if (paramValue === undefined) {
+ continue;
+ }
+ resolvedSegments.push(Array.isArray(paramValue) ? paramValue.join("/") : paramValue);
+ continue;
+ }
+
+ if (segment.startsWith("[...") && segment.endsWith("]")) {
+ const paramName = segment.slice(4, -1);
+ const paramValue = params[paramName];
+ if (Array.isArray(paramValue)) {
+ resolvedSegments.push(paramValue.join("/"));
+ continue;
+ }
+ resolvedSegments.push(paramValue ?? segment);
+ continue;
+ }
+
+ if (segment.startsWith("[") && segment.endsWith("]") && !segment.includes(".")) {
+ const paramName = segment.slice(1, -1);
+ const paramValue = params[paramName];
+ resolvedSegments.push(
+ Array.isArray(paramValue) ? paramValue.join("/") : (paramValue ?? segment),
+ );
+ continue;
+ }
+
+ resolvedSegments.push(segment);
+ }
+
+ return resolvedSegments;
+}
+
+export function buildAppPageRouteElement(
+ options: BuildAppPageRouteElementOptions,
+): ReactNode {
+ let element: ReactNode = (
+ {options.element}
+ );
+
+ element = (
+ <>
+
+ {options.resolvedMetadata ? : null}
+
+ {element}
+ >
+ );
+
+ const loadingComponent = getDefaultExport(options.route.loading);
+ if (loadingComponent) {
+ const LoadingComponent = loadingComponent;
+ element = }>{element};
+ }
+
+ const lastLayoutErrorModule =
+ options.route.errors && options.route.errors.length > 0
+ ? options.route.errors[options.route.errors.length - 1]
+ : null;
+ const pageErrorComponent = getDefaultExport(options.route.error);
+ if (pageErrorComponent && options.route.error !== lastLayoutErrorModule) {
+ element = wrapWithErrorBoundary(pageErrorComponent, element);
+ }
+
+ const notFoundComponent =
+ getDefaultExport(options.route.notFound) ?? getDefaultExport(options.rootNotFoundModule);
+ if (notFoundComponent) {
+ const NotFoundComponent = notFoundComponent;
+ element = }>{element};
+ }
+
+ const templates = options.route.templates ?? [];
+ for (let index = templates.length - 1; index >= 0; index--) {
+ const templateComponent = getDefaultExport(templates[index]);
+ if (!templateComponent) {
+ continue;
+ }
+ const TemplateComponent = templateComponent;
+ element = {element};
+ }
+
+ const routeSlots = options.route.slots ?? {};
+ const layoutEntries = createAppPageLayoutEntries(options.route);
+ const routeThenableParams = options.makeThenableParams(options.matchedParams);
+
+ for (let index = layoutEntries.length - 1; index >= 0; index--) {
+ const layoutEntry = layoutEntries[index];
+ const layoutErrorComponent = getDefaultExport(layoutEntry.errorModule);
+ if (layoutErrorComponent) {
+ element = wrapWithErrorBoundary(layoutErrorComponent, element);
+ }
+
+ const layoutComponent = getDefaultExport(layoutEntry.layoutModule);
+ if (!layoutComponent) {
+ continue;
+ }
+
+ const layoutNotFoundComponent = getDefaultExport(layoutEntry.notFoundModule);
+ if (layoutNotFoundComponent) {
+ const LayoutNotFoundComponent = layoutNotFoundComponent;
+ element = (
+ }>{element}
+ );
+ }
+
+ const layoutProps: Record = {
+ params: routeThenableParams,
+ };
+
+ for (const [slotName, slot] of Object.entries(routeSlots)) {
+ const targetIndex = slot.layoutIndex >= 0 ? slot.layoutIndex : layoutEntries.length - 1;
+ if (index !== targetIndex) {
+ continue;
+ }
+
+ const slotOverride = options.slotOverrides?.[slotName];
+ const slotParams = slotOverride?.params ?? options.matchedParams;
+ const slotComponent =
+ getDefaultExport(slotOverride?.pageModule) ??
+ getDefaultExport(slot.page) ??
+ getDefaultExport(slot.default);
+ if (!slotComponent) {
+ continue;
+ }
+
+ const slotProps: Record = {
+ params: options.makeThenableParams(slotParams),
+ };
+ if (slotOverride?.props) {
+ Object.assign(slotProps, slotOverride.props);
+ }
+
+ const SlotComponent = slotComponent;
+ let slotElement: ReactNode = ;
+
+ const slotLayoutComponent = getDefaultExport(slot.layout);
+ if (slotLayoutComponent) {
+ const SlotLayoutComponent = slotLayoutComponent;
+ slotElement = (
+
+ {slotElement}
+
+ );
+ }
+
+ const slotLoadingComponent = getDefaultExport(slot.loading);
+ if (slotLoadingComponent) {
+ const SlotLoadingComponent = slotLoadingComponent;
+ slotElement = }>{slotElement};
+ }
+
+ const slotErrorComponent = getDefaultExport(slot.error);
+ if (slotErrorComponent) {
+ slotElement = wrapWithErrorBoundary(slotErrorComponent, slotElement);
+ }
+
+ layoutProps[slotName] = slotElement;
+ }
+
+ const LayoutComponent = layoutComponent;
+ element = {element};
+ element = (
+
+ {element}
+
+ );
+ }
+
+ const globalErrorComponent = getDefaultExport(options.globalErrorModule);
+ if (globalErrorComponent) {
+ element = wrapWithErrorBoundary(globalErrorComponent, element);
+ }
+
+ return element;
+}
diff --git a/packages/vinext/src/shims/error-boundary.tsx b/packages/vinext/src/shims/error-boundary.tsx
index 1f097ba1c..b7eb76fd5 100644
--- a/packages/vinext/src/shims/error-boundary.tsx
+++ b/packages/vinext/src/shims/error-boundary.tsx
@@ -9,8 +9,13 @@ export type ErrorBoundaryProps = {
children: React.ReactNode;
};
+type ErrorBoundaryInnerProps = {
+ pathname: string;
+} & ErrorBoundaryProps;
+
export type ErrorBoundaryState = {
error: Error | null;
+ previousPathname: string;
};
/**
@@ -18,10 +23,23 @@ export type ErrorBoundaryState = {
* This must be a client component since error boundaries use
* componentDidCatch / getDerivedStateFromError.
*/
-export class ErrorBoundary extends React.Component {
- constructor(props: ErrorBoundaryProps) {
+export class ErrorBoundaryInner extends React.Component<
+ ErrorBoundaryInnerProps,
+ ErrorBoundaryState
+> {
+ constructor(props: ErrorBoundaryInnerProps) {
super(props);
- this.state = { error: null };
+ this.state = { error: null, previousPathname: props.pathname };
+ }
+
+ static getDerivedStateFromProps(
+ props: ErrorBoundaryInnerProps,
+ state: ErrorBoundaryState,
+ ): ErrorBoundaryState | null {
+ if (props.pathname !== state.previousPathname && state.error) {
+ return { error: null, previousPathname: props.pathname };
+ }
+ return { error: state.error, previousPathname: props.pathname };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
@@ -38,7 +56,7 @@ export class ErrorBoundary extends React.Component {
@@ -54,6 +72,15 @@ export class ErrorBoundary extends React.Component
+ {children}
+
+ );
+}
+
// ---------------------------------------------------------------------------
// NotFoundBoundary — catches notFound() on the client and renders not-found.tsx
// ---------------------------------------------------------------------------
diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap
index 7c8a503e2..96989ffff 100644
--- a/tests/__snapshots__/entry-templates.test.ts.snap
+++ b/tests/__snapshots__/entry-templates.test.ts.snap
@@ -41,13 +41,11 @@ function renderToReadableStream(model, options) {
}
}));
}
-import { createElement, Suspense, Fragment } from "react";
+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 { ErrorBoundary, NotFoundBoundary } from "vinext/error-boundary";
-import { LayoutSegmentProvider } from "vinext/layout-segment-context";
-import { MetadataHead, mergeMetadata, resolveModuleMetadata, ViewportHead, mergeViewport, resolveModuleViewport } from "vinext/metadata";
+import { mergeMetadata, resolveModuleMetadata, mergeViewport, resolveModuleViewport } from "vinext/metadata";
@@ -79,6 +77,10 @@ import {
renderAppPageErrorBoundary as __renderAppPageErrorBoundary,
renderAppPageHttpAccessFallback as __renderAppPageHttpAccessFallback,
} from "/packages/vinext/src/server/app-page-boundary-render.js";
+import {
+ buildAppPageRouteElement as __buildAppPageRouteElement,
+ resolveAppPageChildSegments as __resolveAppPageChildSegments,
+} from "/packages/vinext/src/server/app-page-route-wiring.js";
import {
renderAppPageLifecycle as __renderAppPageLifecycle,
} from "/packages/vinext/src/server/app-page-render.js";
@@ -246,38 +248,6 @@ function makeThenableParams(obj) {
return Object.assign(Promise.resolve(plain), plain);
}
-// Resolve route tree segments to actual values using matched params.
-// Dynamic segments like [id] are replaced with param values, catch-all
-// segments like [...slug] are joined with "/", and route groups are kept as-is.
-function __resolveChildSegments(routeSegments, treePosition, params) {
- var raw = routeSegments.slice(treePosition);
- var result = [];
- for (var j = 0; j < raw.length; j++) {
- var seg = raw[j];
- // Optional catch-all: [[...param]]
- if (seg.indexOf("[[...") === 0 && seg.charAt(seg.length - 1) === "]" && seg.charAt(seg.length - 2) === "]") {
- var pn = seg.slice(5, -2);
- var v = params[pn];
- // Skip empty optional catch-all (e.g., visiting /blog on [[...slug]] route)
- if (Array.isArray(v) && v.length === 0) continue;
- if (v == null) continue;
- result.push(Array.isArray(v) ? v.join("/") : v);
- // Catch-all: [...param]
- } else if (seg.indexOf("[...") === 0 && seg.charAt(seg.length - 1) === "]") {
- var pn2 = seg.slice(4, -1);
- var v2 = params[pn2];
- result.push(Array.isArray(v2) ? v2.join("/") : (v2 || seg));
- // Dynamic: [param]
- } else if (seg.charAt(0) === "[" && seg.charAt(seg.length - 1) === "]" && seg.indexOf(".") === -1) {
- var pn3 = seg.slice(1, -1);
- result.push(params[pn3] || seg);
- } else {
- result.push(seg);
- }
- }
- return result;
-}
-
// djb2 hash — matches Next.js's stringHash for digest generation.
// Produces a stable numeric string from error message + stack.
function __errorDigest(str) {
@@ -544,7 +514,7 @@ async function renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, req
makeThenableParams,
matchedParams: opts?.matchedParams ?? route?.params ?? {},
requestUrl: request.url,
- resolveChildSegments: __resolveChildSegments,
+ resolveChildSegments: __resolveAppPageChildSegments,
rootForbiddenModule: rootForbiddenModule,
rootLayouts: rootLayouts,
rootNotFoundModule: rootNotFoundModule,
@@ -590,7 +560,7 @@ async function renderErrorBoundaryPage(route, error, isRscRequest, request, matc
makeThenableParams,
matchedParams: matchedParams ?? route?.params ?? {},
requestUrl: request.url,
- resolveChildSegments: __resolveChildSegments,
+ resolveChildSegments: __resolveAppPageChildSegments,
rootLayouts: rootLayouts,
route,
renderToReadableStream,
@@ -756,12 +726,10 @@ async function buildPageElement(route, params, opts, searchParams) {
const resolvedMetadata = metadataList.length > 0 ? mergeMetadata(metadataList) : null;
const resolvedViewport = mergeViewport(viewportList);
- // Build nested layout tree from outermost to innermost.
- // Next.js 16 passes params/searchParams as Promises (async pattern)
- // but pre-16 code accesses them as plain objects (params.id).
- // makeThenableParams() normalises null-prototype + preserves both patterns.
- const asyncParams = makeThenableParams(params);
- const pageProps = { params: asyncParams };
+ // 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
+ // stays thin and the wiring logic can be unit tested directly.
+ const pageProps = { params: makeThenableParams(params) };
if (searchParams) {
// Always provide searchParams prop when the URL object is available, even
// when the query string is empty -- pages that do "await searchParams" need
@@ -777,184 +745,25 @@ async function buildPageElement(route, params, opts, searchParams) {
// dynamic, and this avoids false positives from React internals.
if (hasSearchParams) markDynamicUsage();
}
- let element = createElement(PageComponent, pageProps);
-
- // Wrap page with empty segment provider so useSelectedLayoutSegments()
- // returns [] when called from inside a page component (leaf node).
- element = createElement(LayoutSegmentProvider, { segmentMap: { children: [] } }, element);
-
- // Add metadata + viewport head tags (React 19 hoists title/meta/link to )
- // Next.js always injects charset and default viewport even when no metadata/viewport
- // is exported. We replicate that by always emitting these essential head elements.
- {
- const headElements = [];
- // Always emit — Next.js includes this on every page
- headElements.push(createElement("meta", { charSet: "utf-8" }));
- if (resolvedMetadata) headElements.push(createElement(MetadataHead, { metadata: resolvedMetadata }));
- headElements.push(createElement(ViewportHead, { viewport: resolvedViewport }));
- element = createElement(Fragment, null, ...headElements, element);
- }
-
- // Wrap with loading.tsx Suspense if present
- if (route.loading?.default) {
- element = createElement(
- Suspense,
- { fallback: createElement(route.loading.default) },
- element,
- );
- }
-
- // Wrap with the leaf's error.tsx ErrorBoundary if it's not already covered
- // by a per-layout error boundary (i.e., the leaf has error.tsx but no layout).
- // Per-layout error boundaries are interleaved with layouts below.
- {
- const lastLayoutError = route.errors ? route.errors[route.errors.length - 1] : null;
- if (route.error?.default && route.error !== lastLayoutError) {
- element = createElement(ErrorBoundary, {
- fallback: route.error.default,
- children: element,
- });
- }
- }
-
- // Wrap with NotFoundBoundary so client-side notFound() renders not-found.tsx
- // instead of crashing the React tree. Must be above ErrorBoundary since
- // ErrorBoundary re-throws notFound errors.
- // Pre-render the not-found component as a React element since it may be a
- // server component (not a client reference) and can't be passed as a function prop.
- {
- const NotFoundComponent = route.notFound?.default ?? null;
- if (NotFoundComponent) {
- element = createElement(NotFoundBoundary, {
- fallback: createElement(NotFoundComponent),
- children: element,
- });
- }
- }
-
- // Wrap with templates (innermost first, then outer)
- // Templates are like layouts but re-mount on navigation (client-side concern).
- // On the server, they just wrap the content like layouts do.
- if (route.templates) {
- for (let i = route.templates.length - 1; i >= 0; i--) {
- const TemplateComponent = route.templates[i]?.default;
- if (TemplateComponent) {
- element = createElement(TemplateComponent, { children: element, params });
- }
- }
- }
-
- // Wrap with layouts (innermost first, then outer).
- // At each layout level, first wrap with that level's error boundary (if any)
- // so the boundary is inside the layout and catches errors from children.
- // This matches Next.js behavior: Layout > ErrorBoundary > children.
- // Parallel slots are passed as named props to the innermost layout
- // (the layout at the same directory level as the page/slots)
- for (let i = route.layouts.length - 1; i >= 0; i--) {
- // Wrap with per-layout error boundary before wrapping with layout.
- // This places the ErrorBoundary inside the layout, catching errors
- // from child segments (matching Next.js per-segment error handling).
- if (route.errors && route.errors[i]?.default) {
- element = createElement(ErrorBoundary, {
- fallback: route.errors[i].default,
- children: element,
- });
- }
-
- const LayoutComponent = route.layouts[i]?.default;
- if (LayoutComponent) {
- // Per-layout NotFoundBoundary: wraps this layout's children so that
- // notFound() thrown from a child layout is caught here.
- // Matches Next.js behavior where each segment has its own boundary.
- // The boundary at level N catches errors from Layout[N+1] and below,
- // but NOT from Layout[N] itself (which propagates to level N-1).
- {
- const LayoutNotFound = route.notFounds?.[i]?.default;
- if (LayoutNotFound) {
- element = createElement(NotFoundBoundary, {
- fallback: createElement(LayoutNotFound),
- children: element,
- });
- }
- }
-
- const layoutProps = { children: element, params: makeThenableParams(params) };
-
- // Add parallel slot elements to the layout that defines them.
- // Each slot has a layoutIndex indicating which layout it belongs to.
- if (route.slots) {
- for (const [slotName, slotMod] of Object.entries(route.slots)) {
- // Attach slot to the layout at its layoutIndex, or to the innermost layout if -1
- const targetIdx = slotMod.layoutIndex >= 0 ? slotMod.layoutIndex : route.layouts.length - 1;
- if (i !== targetIdx) continue;
- // Check if this slot has an intercepting route that should activate
- let SlotPage = null;
- let slotParams = params;
-
- if (opts && opts.interceptSlot === slotName && opts.interceptPage) {
- // Use the intercepting route's page component
- SlotPage = opts.interceptPage.default;
- slotParams = opts.interceptParams || params;
- } else {
- SlotPage = slotMod.page?.default || slotMod.default?.default;
- }
-
- if (SlotPage) {
- let slotElement = createElement(SlotPage, { params: makeThenableParams(slotParams) });
- // Wrap with slot-specific layout if present.
- // In Next.js, @slot/layout.tsx wraps the slot's page content
- // before it is passed as a prop to the parent layout.
- const SlotLayout = slotMod.layout?.default;
- if (SlotLayout) {
- slotElement = createElement(SlotLayout, {
- children: slotElement,
- params: makeThenableParams(slotParams),
- });
- }
- // Wrap with slot-specific loading if present
- if (slotMod.loading?.default) {
- slotElement = createElement(Suspense,
- { fallback: createElement(slotMod.loading.default) },
- slotElement,
- );
- }
- // Wrap with slot-specific error boundary if present
- if (slotMod.error?.default) {
- slotElement = createElement(ErrorBoundary, {
- fallback: slotMod.error.default,
- children: slotElement,
- });
- }
- layoutProps[slotName] = slotElement;
+ return __buildAppPageRouteElement({
+ element: createElement(PageComponent, pageProps),
+ globalErrorModule: null,
+ makeThenableParams,
+ matchedParams: params,
+ resolvedMetadata,
+ resolvedViewport,
+ rootNotFoundModule: null,
+ route,
+ slotOverrides:
+ opts && opts.interceptSlot && opts.interceptPage
+ ? {
+ [opts.interceptSlot]: {
+ pageModule: opts.interceptPage,
+ params: opts.interceptParams || params,
+ },
}
- }
- }
-
- element = createElement(LayoutComponent, layoutProps);
-
- // Wrap the layout with LayoutSegmentProvider so useSelectedLayoutSegments()
- // called INSIDE this layout gets the correct child segments. We resolve the
- // route tree segments using actual param values and pass them through context.
- // We wrap the layout (not just children) because hooks are called from
- // components rendered inside the layout's own JSX.
- const treePos = route.layoutTreePositions ? route.layoutTreePositions[i] : 0;
- const childSegs = __resolveChildSegments(route.routeSegments || [], treePos, params);
- element = createElement(LayoutSegmentProvider, { segmentMap: { children: childSegs } }, element);
- }
- }
-
- // Wrap with global error boundary if app/global-error.tsx exists.
- // This must be present in both HTML and RSC paths so the component tree
- // structure matches — otherwise React reconciliation on client-side navigation
- // would see a mismatched tree and destroy/recreate the DOM.
- //
- // For RSC requests (client-side nav), this provides error recovery on the client.
- // For HTML requests (initial page load), the ErrorBoundary catches during SSR
- // but produces double / (root layout + global-error). The request
- // handler detects this via the rscOnError flag and re-renders without layouts.
-
-
- return element;
+ : null,
+ });
}
@@ -2235,13 +2044,11 @@ function renderToReadableStream(model, options) {
}
}));
}
-import { createElement, Suspense, Fragment } from "react";
+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 { ErrorBoundary, NotFoundBoundary } from "vinext/error-boundary";
-import { LayoutSegmentProvider } from "vinext/layout-segment-context";
-import { MetadataHead, mergeMetadata, resolveModuleMetadata, ViewportHead, mergeViewport, resolveModuleViewport } from "vinext/metadata";
+import { mergeMetadata, resolveModuleMetadata, mergeViewport, resolveModuleViewport } from "vinext/metadata";
@@ -2273,6 +2080,10 @@ import {
renderAppPageErrorBoundary as __renderAppPageErrorBoundary,
renderAppPageHttpAccessFallback as __renderAppPageHttpAccessFallback,
} from "/packages/vinext/src/server/app-page-boundary-render.js";
+import {
+ buildAppPageRouteElement as __buildAppPageRouteElement,
+ resolveAppPageChildSegments as __resolveAppPageChildSegments,
+} from "/packages/vinext/src/server/app-page-route-wiring.js";
import {
renderAppPageLifecycle as __renderAppPageLifecycle,
} from "/packages/vinext/src/server/app-page-render.js";
@@ -2440,38 +2251,6 @@ function makeThenableParams(obj) {
return Object.assign(Promise.resolve(plain), plain);
}
-// Resolve route tree segments to actual values using matched params.
-// Dynamic segments like [id] are replaced with param values, catch-all
-// segments like [...slug] are joined with "/", and route groups are kept as-is.
-function __resolveChildSegments(routeSegments, treePosition, params) {
- var raw = routeSegments.slice(treePosition);
- var result = [];
- for (var j = 0; j < raw.length; j++) {
- var seg = raw[j];
- // Optional catch-all: [[...param]]
- if (seg.indexOf("[[...") === 0 && seg.charAt(seg.length - 1) === "]" && seg.charAt(seg.length - 2) === "]") {
- var pn = seg.slice(5, -2);
- var v = params[pn];
- // Skip empty optional catch-all (e.g., visiting /blog on [[...slug]] route)
- if (Array.isArray(v) && v.length === 0) continue;
- if (v == null) continue;
- result.push(Array.isArray(v) ? v.join("/") : v);
- // Catch-all: [...param]
- } else if (seg.indexOf("[...") === 0 && seg.charAt(seg.length - 1) === "]") {
- var pn2 = seg.slice(4, -1);
- var v2 = params[pn2];
- result.push(Array.isArray(v2) ? v2.join("/") : (v2 || seg));
- // Dynamic: [param]
- } else if (seg.charAt(0) === "[" && seg.charAt(seg.length - 1) === "]" && seg.indexOf(".") === -1) {
- var pn3 = seg.slice(1, -1);
- result.push(params[pn3] || seg);
- } else {
- result.push(seg);
- }
- }
- return result;
-}
-
// djb2 hash — matches Next.js's stringHash for digest generation.
// Produces a stable numeric string from error message + stack.
function __errorDigest(str) {
@@ -2738,7 +2517,7 @@ async function renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, req
makeThenableParams,
matchedParams: opts?.matchedParams ?? route?.params ?? {},
requestUrl: request.url,
- resolveChildSegments: __resolveChildSegments,
+ resolveChildSegments: __resolveAppPageChildSegments,
rootForbiddenModule: rootForbiddenModule,
rootLayouts: rootLayouts,
rootNotFoundModule: rootNotFoundModule,
@@ -2784,7 +2563,7 @@ async function renderErrorBoundaryPage(route, error, isRscRequest, request, matc
makeThenableParams,
matchedParams: matchedParams ?? route?.params ?? {},
requestUrl: request.url,
- resolveChildSegments: __resolveChildSegments,
+ resolveChildSegments: __resolveAppPageChildSegments,
rootLayouts: rootLayouts,
route,
renderToReadableStream,
@@ -2950,12 +2729,10 @@ async function buildPageElement(route, params, opts, searchParams) {
const resolvedMetadata = metadataList.length > 0 ? mergeMetadata(metadataList) : null;
const resolvedViewport = mergeViewport(viewportList);
- // Build nested layout tree from outermost to innermost.
- // Next.js 16 passes params/searchParams as Promises (async pattern)
- // but pre-16 code accesses them as plain objects (params.id).
- // makeThenableParams() normalises null-prototype + preserves both patterns.
- const asyncParams = makeThenableParams(params);
- const pageProps = { params: asyncParams };
+ // 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
+ // stays thin and the wiring logic can be unit tested directly.
+ const pageProps = { params: makeThenableParams(params) };
if (searchParams) {
// Always provide searchParams prop when the URL object is available, even
// when the query string is empty -- pages that do "await searchParams" need
@@ -2971,184 +2748,25 @@ async function buildPageElement(route, params, opts, searchParams) {
// dynamic, and this avoids false positives from React internals.
if (hasSearchParams) markDynamicUsage();
}
- let element = createElement(PageComponent, pageProps);
-
- // Wrap page with empty segment provider so useSelectedLayoutSegments()
- // returns [] when called from inside a page component (leaf node).
- element = createElement(LayoutSegmentProvider, { segmentMap: { children: [] } }, element);
-
- // Add metadata + viewport head tags (React 19 hoists title/meta/link to )
- // Next.js always injects charset and default viewport even when no metadata/viewport
- // is exported. We replicate that by always emitting these essential head elements.
- {
- const headElements = [];
- // Always emit — Next.js includes this on every page
- headElements.push(createElement("meta", { charSet: "utf-8" }));
- if (resolvedMetadata) headElements.push(createElement(MetadataHead, { metadata: resolvedMetadata }));
- headElements.push(createElement(ViewportHead, { viewport: resolvedViewport }));
- element = createElement(Fragment, null, ...headElements, element);
- }
-
- // Wrap with loading.tsx Suspense if present
- if (route.loading?.default) {
- element = createElement(
- Suspense,
- { fallback: createElement(route.loading.default) },
- element,
- );
- }
-
- // Wrap with the leaf's error.tsx ErrorBoundary if it's not already covered
- // by a per-layout error boundary (i.e., the leaf has error.tsx but no layout).
- // Per-layout error boundaries are interleaved with layouts below.
- {
- const lastLayoutError = route.errors ? route.errors[route.errors.length - 1] : null;
- if (route.error?.default && route.error !== lastLayoutError) {
- element = createElement(ErrorBoundary, {
- fallback: route.error.default,
- children: element,
- });
- }
- }
-
- // Wrap with NotFoundBoundary so client-side notFound() renders not-found.tsx
- // instead of crashing the React tree. Must be above ErrorBoundary since
- // ErrorBoundary re-throws notFound errors.
- // Pre-render the not-found component as a React element since it may be a
- // server component (not a client reference) and can't be passed as a function prop.
- {
- const NotFoundComponent = route.notFound?.default ?? null;
- if (NotFoundComponent) {
- element = createElement(NotFoundBoundary, {
- fallback: createElement(NotFoundComponent),
- children: element,
- });
- }
- }
-
- // Wrap with templates (innermost first, then outer)
- // Templates are like layouts but re-mount on navigation (client-side concern).
- // On the server, they just wrap the content like layouts do.
- if (route.templates) {
- for (let i = route.templates.length - 1; i >= 0; i--) {
- const TemplateComponent = route.templates[i]?.default;
- if (TemplateComponent) {
- element = createElement(TemplateComponent, { children: element, params });
- }
- }
- }
-
- // Wrap with layouts (innermost first, then outer).
- // At each layout level, first wrap with that level's error boundary (if any)
- // so the boundary is inside the layout and catches errors from children.
- // This matches Next.js behavior: Layout > ErrorBoundary > children.
- // Parallel slots are passed as named props to the innermost layout
- // (the layout at the same directory level as the page/slots)
- for (let i = route.layouts.length - 1; i >= 0; i--) {
- // Wrap with per-layout error boundary before wrapping with layout.
- // This places the ErrorBoundary inside the layout, catching errors
- // from child segments (matching Next.js per-segment error handling).
- if (route.errors && route.errors[i]?.default) {
- element = createElement(ErrorBoundary, {
- fallback: route.errors[i].default,
- children: element,
- });
- }
-
- const LayoutComponent = route.layouts[i]?.default;
- if (LayoutComponent) {
- // Per-layout NotFoundBoundary: wraps this layout's children so that
- // notFound() thrown from a child layout is caught here.
- // Matches Next.js behavior where each segment has its own boundary.
- // The boundary at level N catches errors from Layout[N+1] and below,
- // but NOT from Layout[N] itself (which propagates to level N-1).
- {
- const LayoutNotFound = route.notFounds?.[i]?.default;
- if (LayoutNotFound) {
- element = createElement(NotFoundBoundary, {
- fallback: createElement(LayoutNotFound),
- children: element,
- });
- }
- }
-
- const layoutProps = { children: element, params: makeThenableParams(params) };
-
- // Add parallel slot elements to the layout that defines them.
- // Each slot has a layoutIndex indicating which layout it belongs to.
- if (route.slots) {
- for (const [slotName, slotMod] of Object.entries(route.slots)) {
- // Attach slot to the layout at its layoutIndex, or to the innermost layout if -1
- const targetIdx = slotMod.layoutIndex >= 0 ? slotMod.layoutIndex : route.layouts.length - 1;
- if (i !== targetIdx) continue;
- // Check if this slot has an intercepting route that should activate
- let SlotPage = null;
- let slotParams = params;
-
- if (opts && opts.interceptSlot === slotName && opts.interceptPage) {
- // Use the intercepting route's page component
- SlotPage = opts.interceptPage.default;
- slotParams = opts.interceptParams || params;
- } else {
- SlotPage = slotMod.page?.default || slotMod.default?.default;
- }
-
- if (SlotPage) {
- let slotElement = createElement(SlotPage, { params: makeThenableParams(slotParams) });
- // Wrap with slot-specific layout if present.
- // In Next.js, @slot/layout.tsx wraps the slot's page content
- // before it is passed as a prop to the parent layout.
- const SlotLayout = slotMod.layout?.default;
- if (SlotLayout) {
- slotElement = createElement(SlotLayout, {
- children: slotElement,
- params: makeThenableParams(slotParams),
- });
- }
- // Wrap with slot-specific loading if present
- if (slotMod.loading?.default) {
- slotElement = createElement(Suspense,
- { fallback: createElement(slotMod.loading.default) },
- slotElement,
- );
- }
- // Wrap with slot-specific error boundary if present
- if (slotMod.error?.default) {
- slotElement = createElement(ErrorBoundary, {
- fallback: slotMod.error.default,
- children: slotElement,
- });
- }
- layoutProps[slotName] = slotElement;
+ return __buildAppPageRouteElement({
+ element: createElement(PageComponent, pageProps),
+ globalErrorModule: null,
+ makeThenableParams,
+ matchedParams: params,
+ resolvedMetadata,
+ resolvedViewport,
+ rootNotFoundModule: null,
+ route,
+ slotOverrides:
+ opts && opts.interceptSlot && opts.interceptPage
+ ? {
+ [opts.interceptSlot]: {
+ pageModule: opts.interceptPage,
+ params: opts.interceptParams || params,
+ },
}
- }
- }
-
- element = createElement(LayoutComponent, layoutProps);
-
- // Wrap the layout with LayoutSegmentProvider so useSelectedLayoutSegments()
- // called INSIDE this layout gets the correct child segments. We resolve the
- // route tree segments using actual param values and pass them through context.
- // We wrap the layout (not just children) because hooks are called from
- // components rendered inside the layout's own JSX.
- const treePos = route.layoutTreePositions ? route.layoutTreePositions[i] : 0;
- const childSegs = __resolveChildSegments(route.routeSegments || [], treePos, params);
- element = createElement(LayoutSegmentProvider, { segmentMap: { children: childSegs } }, element);
- }
- }
-
- // Wrap with global error boundary if app/global-error.tsx exists.
- // This must be present in both HTML and RSC paths so the component tree
- // structure matches — otherwise React reconciliation on client-side navigation
- // would see a mismatched tree and destroy/recreate the DOM.
- //
- // For RSC requests (client-side nav), this provides error recovery on the client.
- // For HTML requests (initial page load), the ErrorBoundary catches during SSR
- // but produces double / (root layout + global-error). The request
- // handler detects this via the rscOnError flag and re-renders without layouts.
-
-
- return element;
+ : null,
+ });
}
@@ -4432,13 +4050,11 @@ function renderToReadableStream(model, options) {
}
}));
}
-import { createElement, Suspense, Fragment } from "react";
+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 { ErrorBoundary, NotFoundBoundary } from "vinext/error-boundary";
-import { LayoutSegmentProvider } from "vinext/layout-segment-context";
-import { MetadataHead, mergeMetadata, resolveModuleMetadata, ViewportHead, mergeViewport, resolveModuleViewport } from "vinext/metadata";
+import { mergeMetadata, resolveModuleMetadata, mergeViewport, resolveModuleViewport } from "vinext/metadata";
@@ -4470,6 +4086,10 @@ import {
renderAppPageErrorBoundary as __renderAppPageErrorBoundary,
renderAppPageHttpAccessFallback as __renderAppPageHttpAccessFallback,
} from "/packages/vinext/src/server/app-page-boundary-render.js";
+import {
+ buildAppPageRouteElement as __buildAppPageRouteElement,
+ resolveAppPageChildSegments as __resolveAppPageChildSegments,
+} from "/packages/vinext/src/server/app-page-route-wiring.js";
import {
renderAppPageLifecycle as __renderAppPageLifecycle,
} from "/packages/vinext/src/server/app-page-render.js";
@@ -4637,38 +4257,6 @@ function makeThenableParams(obj) {
return Object.assign(Promise.resolve(plain), plain);
}
-// Resolve route tree segments to actual values using matched params.
-// Dynamic segments like [id] are replaced with param values, catch-all
-// segments like [...slug] are joined with "/", and route groups are kept as-is.
-function __resolveChildSegments(routeSegments, treePosition, params) {
- var raw = routeSegments.slice(treePosition);
- var result = [];
- for (var j = 0; j < raw.length; j++) {
- var seg = raw[j];
- // Optional catch-all: [[...param]]
- if (seg.indexOf("[[...") === 0 && seg.charAt(seg.length - 1) === "]" && seg.charAt(seg.length - 2) === "]") {
- var pn = seg.slice(5, -2);
- var v = params[pn];
- // Skip empty optional catch-all (e.g., visiting /blog on [[...slug]] route)
- if (Array.isArray(v) && v.length === 0) continue;
- if (v == null) continue;
- result.push(Array.isArray(v) ? v.join("/") : v);
- // Catch-all: [...param]
- } else if (seg.indexOf("[...") === 0 && seg.charAt(seg.length - 1) === "]") {
- var pn2 = seg.slice(4, -1);
- var v2 = params[pn2];
- result.push(Array.isArray(v2) ? v2.join("/") : (v2 || seg));
- // Dynamic: [param]
- } else if (seg.charAt(0) === "[" && seg.charAt(seg.length - 1) === "]" && seg.indexOf(".") === -1) {
- var pn3 = seg.slice(1, -1);
- result.push(params[pn3] || seg);
- } else {
- result.push(seg);
- }
- }
- return result;
-}
-
// djb2 hash — matches Next.js's stringHash for digest generation.
// Produces a stable numeric string from error message + stack.
function __errorDigest(str) {
@@ -4936,7 +4524,7 @@ async function renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, req
makeThenableParams,
matchedParams: opts?.matchedParams ?? route?.params ?? {},
requestUrl: request.url,
- resolveChildSegments: __resolveChildSegments,
+ resolveChildSegments: __resolveAppPageChildSegments,
rootForbiddenModule: rootForbiddenModule,
rootLayouts: rootLayouts,
rootNotFoundModule: rootNotFoundModule,
@@ -4982,7 +4570,7 @@ async function renderErrorBoundaryPage(route, error, isRscRequest, request, matc
makeThenableParams,
matchedParams: matchedParams ?? route?.params ?? {},
requestUrl: request.url,
- resolveChildSegments: __resolveChildSegments,
+ resolveChildSegments: __resolveAppPageChildSegments,
rootLayouts: rootLayouts,
route,
renderToReadableStream,
@@ -5148,12 +4736,10 @@ async function buildPageElement(route, params, opts, searchParams) {
const resolvedMetadata = metadataList.length > 0 ? mergeMetadata(metadataList) : null;
const resolvedViewport = mergeViewport(viewportList);
- // Build nested layout tree from outermost to innermost.
- // Next.js 16 passes params/searchParams as Promises (async pattern)
- // but pre-16 code accesses them as plain objects (params.id).
- // makeThenableParams() normalises null-prototype + preserves both patterns.
- const asyncParams = makeThenableParams(params);
- const pageProps = { params: asyncParams };
+ // 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
+ // stays thin and the wiring logic can be unit tested directly.
+ const pageProps = { params: makeThenableParams(params) };
if (searchParams) {
// Always provide searchParams prop when the URL object is available, even
// when the query string is empty -- pages that do "await searchParams" need
@@ -5169,192 +4755,25 @@ async function buildPageElement(route, params, opts, searchParams) {
// dynamic, and this avoids false positives from React internals.
if (hasSearchParams) markDynamicUsage();
}
- let element = createElement(PageComponent, pageProps);
-
- // Wrap page with empty segment provider so useSelectedLayoutSegments()
- // returns [] when called from inside a page component (leaf node).
- element = createElement(LayoutSegmentProvider, { segmentMap: { children: [] } }, element);
-
- // Add metadata + viewport head tags (React 19 hoists title/meta/link to )
- // Next.js always injects charset and default viewport even when no metadata/viewport
- // is exported. We replicate that by always emitting these essential head elements.
- {
- const headElements = [];
- // Always emit — Next.js includes this on every page
- headElements.push(createElement("meta", { charSet: "utf-8" }));
- if (resolvedMetadata) headElements.push(createElement(MetadataHead, { metadata: resolvedMetadata }));
- headElements.push(createElement(ViewportHead, { viewport: resolvedViewport }));
- element = createElement(Fragment, null, ...headElements, element);
- }
-
- // Wrap with loading.tsx Suspense if present
- if (route.loading?.default) {
- element = createElement(
- Suspense,
- { fallback: createElement(route.loading.default) },
- element,
- );
- }
-
- // Wrap with the leaf's error.tsx ErrorBoundary if it's not already covered
- // by a per-layout error boundary (i.e., the leaf has error.tsx but no layout).
- // Per-layout error boundaries are interleaved with layouts below.
- {
- const lastLayoutError = route.errors ? route.errors[route.errors.length - 1] : null;
- if (route.error?.default && route.error !== lastLayoutError) {
- element = createElement(ErrorBoundary, {
- fallback: route.error.default,
- children: element,
- });
- }
- }
-
- // Wrap with NotFoundBoundary so client-side notFound() renders not-found.tsx
- // instead of crashing the React tree. Must be above ErrorBoundary since
- // ErrorBoundary re-throws notFound errors.
- // Pre-render the not-found component as a React element since it may be a
- // server component (not a client reference) and can't be passed as a function prop.
- {
- const NotFoundComponent = route.notFound?.default ?? null;
- if (NotFoundComponent) {
- element = createElement(NotFoundBoundary, {
- fallback: createElement(NotFoundComponent),
- children: element,
- });
- }
- }
-
- // Wrap with templates (innermost first, then outer)
- // Templates are like layouts but re-mount on navigation (client-side concern).
- // On the server, they just wrap the content like layouts do.
- if (route.templates) {
- for (let i = route.templates.length - 1; i >= 0; i--) {
- const TemplateComponent = route.templates[i]?.default;
- if (TemplateComponent) {
- element = createElement(TemplateComponent, { children: element, params });
- }
- }
- }
-
- // Wrap with layouts (innermost first, then outer).
- // At each layout level, first wrap with that level's error boundary (if any)
- // so the boundary is inside the layout and catches errors from children.
- // This matches Next.js behavior: Layout > ErrorBoundary > children.
- // Parallel slots are passed as named props to the innermost layout
- // (the layout at the same directory level as the page/slots)
- for (let i = route.layouts.length - 1; i >= 0; i--) {
- // Wrap with per-layout error boundary before wrapping with layout.
- // This places the ErrorBoundary inside the layout, catching errors
- // from child segments (matching Next.js per-segment error handling).
- if (route.errors && route.errors[i]?.default) {
- element = createElement(ErrorBoundary, {
- fallback: route.errors[i].default,
- children: element,
- });
- }
-
- const LayoutComponent = route.layouts[i]?.default;
- if (LayoutComponent) {
- // Per-layout NotFoundBoundary: wraps this layout's children so that
- // notFound() thrown from a child layout is caught here.
- // Matches Next.js behavior where each segment has its own boundary.
- // The boundary at level N catches errors from Layout[N+1] and below,
- // but NOT from Layout[N] itself (which propagates to level N-1).
- {
- const LayoutNotFound = route.notFounds?.[i]?.default;
- if (LayoutNotFound) {
- element = createElement(NotFoundBoundary, {
- fallback: createElement(LayoutNotFound),
- children: element,
- });
- }
- }
-
- const layoutProps = { children: element, params: makeThenableParams(params) };
-
- // Add parallel slot elements to the layout that defines them.
- // Each slot has a layoutIndex indicating which layout it belongs to.
- if (route.slots) {
- for (const [slotName, slotMod] of Object.entries(route.slots)) {
- // Attach slot to the layout at its layoutIndex, or to the innermost layout if -1
- const targetIdx = slotMod.layoutIndex >= 0 ? slotMod.layoutIndex : route.layouts.length - 1;
- if (i !== targetIdx) continue;
- // Check if this slot has an intercepting route that should activate
- let SlotPage = null;
- let slotParams = params;
-
- if (opts && opts.interceptSlot === slotName && opts.interceptPage) {
- // Use the intercepting route's page component
- SlotPage = opts.interceptPage.default;
- slotParams = opts.interceptParams || params;
- } else {
- SlotPage = slotMod.page?.default || slotMod.default?.default;
- }
-
- if (SlotPage) {
- let slotElement = createElement(SlotPage, { params: makeThenableParams(slotParams) });
- // Wrap with slot-specific layout if present.
- // In Next.js, @slot/layout.tsx wraps the slot's page content
- // before it is passed as a prop to the parent layout.
- const SlotLayout = slotMod.layout?.default;
- if (SlotLayout) {
- slotElement = createElement(SlotLayout, {
- children: slotElement,
- params: makeThenableParams(slotParams),
- });
- }
- // Wrap with slot-specific loading if present
- if (slotMod.loading?.default) {
- slotElement = createElement(Suspense,
- { fallback: createElement(slotMod.loading.default) },
- slotElement,
- );
- }
- // Wrap with slot-specific error boundary if present
- if (slotMod.error?.default) {
- slotElement = createElement(ErrorBoundary, {
- fallback: slotMod.error.default,
- children: slotElement,
- });
- }
- layoutProps[slotName] = slotElement;
+ return __buildAppPageRouteElement({
+ element: createElement(PageComponent, pageProps),
+ globalErrorModule: mod_11,
+ makeThenableParams,
+ matchedParams: params,
+ resolvedMetadata,
+ resolvedViewport,
+ rootNotFoundModule: null,
+ route,
+ slotOverrides:
+ opts && opts.interceptSlot && opts.interceptPage
+ ? {
+ [opts.interceptSlot]: {
+ pageModule: opts.interceptPage,
+ params: opts.interceptParams || params,
+ },
}
- }
- }
-
- element = createElement(LayoutComponent, layoutProps);
-
- // Wrap the layout with LayoutSegmentProvider so useSelectedLayoutSegments()
- // called INSIDE this layout gets the correct child segments. We resolve the
- // route tree segments using actual param values and pass them through context.
- // We wrap the layout (not just children) because hooks are called from
- // components rendered inside the layout's own JSX.
- const treePos = route.layoutTreePositions ? route.layoutTreePositions[i] : 0;
- const childSegs = __resolveChildSegments(route.routeSegments || [], treePos, params);
- element = createElement(LayoutSegmentProvider, { segmentMap: { children: childSegs } }, element);
- }
- }
-
- // Wrap with global error boundary if app/global-error.tsx exists.
- // This must be present in both HTML and RSC paths so the component tree
- // structure matches — otherwise React reconciliation on client-side navigation
- // would see a mismatched tree and destroy/recreate the DOM.
- //
- // For RSC requests (client-side nav), this provides error recovery on the client.
- // For HTML requests (initial page load), the ErrorBoundary catches during SSR
- // but produces double / (root layout + global-error). The request
- // handler detects this via the rscOnError flag and re-renders without layouts.
-
- const GlobalErrorComponent = mod_11.default;
- if (GlobalErrorComponent) {
- element = createElement(ErrorBoundary, {
- fallback: GlobalErrorComponent,
- children: element,
- });
- }
-
-
- return element;
+ : null,
+ });
}
@@ -6635,13 +6054,11 @@ function renderToReadableStream(model, options) {
}
}));
}
-import { createElement, Suspense, Fragment } from "react";
+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 { ErrorBoundary, NotFoundBoundary } from "vinext/error-boundary";
-import { LayoutSegmentProvider } from "vinext/layout-segment-context";
-import { MetadataHead, mergeMetadata, resolveModuleMetadata, ViewportHead, mergeViewport, resolveModuleViewport } from "vinext/metadata";
+import { mergeMetadata, resolveModuleMetadata, mergeViewport, resolveModuleViewport } from "vinext/metadata";
import * as _instrumentation from "/tmp/test/instrumentation.ts";
@@ -6673,6 +6090,10 @@ import {
renderAppPageErrorBoundary as __renderAppPageErrorBoundary,
renderAppPageHttpAccessFallback as __renderAppPageHttpAccessFallback,
} from "/packages/vinext/src/server/app-page-boundary-render.js";
+import {
+ buildAppPageRouteElement as __buildAppPageRouteElement,
+ resolveAppPageChildSegments as __resolveAppPageChildSegments,
+} from "/packages/vinext/src/server/app-page-route-wiring.js";
import {
renderAppPageLifecycle as __renderAppPageLifecycle,
} from "/packages/vinext/src/server/app-page-render.js";
@@ -6840,38 +6261,6 @@ function makeThenableParams(obj) {
return Object.assign(Promise.resolve(plain), plain);
}
-// Resolve route tree segments to actual values using matched params.
-// Dynamic segments like [id] are replaced with param values, catch-all
-// segments like [...slug] are joined with "/", and route groups are kept as-is.
-function __resolveChildSegments(routeSegments, treePosition, params) {
- var raw = routeSegments.slice(treePosition);
- var result = [];
- for (var j = 0; j < raw.length; j++) {
- var seg = raw[j];
- // Optional catch-all: [[...param]]
- if (seg.indexOf("[[...") === 0 && seg.charAt(seg.length - 1) === "]" && seg.charAt(seg.length - 2) === "]") {
- var pn = seg.slice(5, -2);
- var v = params[pn];
- // Skip empty optional catch-all (e.g., visiting /blog on [[...slug]] route)
- if (Array.isArray(v) && v.length === 0) continue;
- if (v == null) continue;
- result.push(Array.isArray(v) ? v.join("/") : v);
- // Catch-all: [...param]
- } else if (seg.indexOf("[...") === 0 && seg.charAt(seg.length - 1) === "]") {
- var pn2 = seg.slice(4, -1);
- var v2 = params[pn2];
- result.push(Array.isArray(v2) ? v2.join("/") : (v2 || seg));
- // Dynamic: [param]
- } else if (seg.charAt(0) === "[" && seg.charAt(seg.length - 1) === "]" && seg.indexOf(".") === -1) {
- var pn3 = seg.slice(1, -1);
- result.push(params[pn3] || seg);
- } else {
- result.push(seg);
- }
- }
- return result;
-}
-
// djb2 hash — matches Next.js's stringHash for digest generation.
// Produces a stable numeric string from error message + stack.
function __errorDigest(str) {
@@ -7168,7 +6557,7 @@ async function renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, req
makeThenableParams,
matchedParams: opts?.matchedParams ?? route?.params ?? {},
requestUrl: request.url,
- resolveChildSegments: __resolveChildSegments,
+ resolveChildSegments: __resolveAppPageChildSegments,
rootForbiddenModule: rootForbiddenModule,
rootLayouts: rootLayouts,
rootNotFoundModule: rootNotFoundModule,
@@ -7214,7 +6603,7 @@ async function renderErrorBoundaryPage(route, error, isRscRequest, request, matc
makeThenableParams,
matchedParams: matchedParams ?? route?.params ?? {},
requestUrl: request.url,
- resolveChildSegments: __resolveChildSegments,
+ resolveChildSegments: __resolveAppPageChildSegments,
rootLayouts: rootLayouts,
route,
renderToReadableStream,
@@ -7380,12 +6769,10 @@ async function buildPageElement(route, params, opts, searchParams) {
const resolvedMetadata = metadataList.length > 0 ? mergeMetadata(metadataList) : null;
const resolvedViewport = mergeViewport(viewportList);
- // Build nested layout tree from outermost to innermost.
- // Next.js 16 passes params/searchParams as Promises (async pattern)
- // but pre-16 code accesses them as plain objects (params.id).
- // makeThenableParams() normalises null-prototype + preserves both patterns.
- const asyncParams = makeThenableParams(params);
- const pageProps = { params: asyncParams };
+ // 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
+ // stays thin and the wiring logic can be unit tested directly.
+ const pageProps = { params: makeThenableParams(params) };
if (searchParams) {
// Always provide searchParams prop when the URL object is available, even
// when the query string is empty -- pages that do "await searchParams" need
@@ -7401,184 +6788,25 @@ async function buildPageElement(route, params, opts, searchParams) {
// dynamic, and this avoids false positives from React internals.
if (hasSearchParams) markDynamicUsage();
}
- let element = createElement(PageComponent, pageProps);
-
- // Wrap page with empty segment provider so useSelectedLayoutSegments()
- // returns [] when called from inside a page component (leaf node).
- element = createElement(LayoutSegmentProvider, { segmentMap: { children: [] } }, element);
-
- // Add metadata + viewport head tags (React 19 hoists title/meta/link to )
- // Next.js always injects charset and default viewport even when no metadata/viewport
- // is exported. We replicate that by always emitting these essential head elements.
- {
- const headElements = [];
- // Always emit — Next.js includes this on every page
- headElements.push(createElement("meta", { charSet: "utf-8" }));
- if (resolvedMetadata) headElements.push(createElement(MetadataHead, { metadata: resolvedMetadata }));
- headElements.push(createElement(ViewportHead, { viewport: resolvedViewport }));
- element = createElement(Fragment, null, ...headElements, element);
- }
-
- // Wrap with loading.tsx Suspense if present
- if (route.loading?.default) {
- element = createElement(
- Suspense,
- { fallback: createElement(route.loading.default) },
- element,
- );
- }
-
- // Wrap with the leaf's error.tsx ErrorBoundary if it's not already covered
- // by a per-layout error boundary (i.e., the leaf has error.tsx but no layout).
- // Per-layout error boundaries are interleaved with layouts below.
- {
- const lastLayoutError = route.errors ? route.errors[route.errors.length - 1] : null;
- if (route.error?.default && route.error !== lastLayoutError) {
- element = createElement(ErrorBoundary, {
- fallback: route.error.default,
- children: element,
- });
- }
- }
-
- // Wrap with NotFoundBoundary so client-side notFound() renders not-found.tsx
- // instead of crashing the React tree. Must be above ErrorBoundary since
- // ErrorBoundary re-throws notFound errors.
- // Pre-render the not-found component as a React element since it may be a
- // server component (not a client reference) and can't be passed as a function prop.
- {
- const NotFoundComponent = route.notFound?.default ?? null;
- if (NotFoundComponent) {
- element = createElement(NotFoundBoundary, {
- fallback: createElement(NotFoundComponent),
- children: element,
- });
- }
- }
-
- // Wrap with templates (innermost first, then outer)
- // Templates are like layouts but re-mount on navigation (client-side concern).
- // On the server, they just wrap the content like layouts do.
- if (route.templates) {
- for (let i = route.templates.length - 1; i >= 0; i--) {
- const TemplateComponent = route.templates[i]?.default;
- if (TemplateComponent) {
- element = createElement(TemplateComponent, { children: element, params });
- }
- }
- }
-
- // Wrap with layouts (innermost first, then outer).
- // At each layout level, first wrap with that level's error boundary (if any)
- // so the boundary is inside the layout and catches errors from children.
- // This matches Next.js behavior: Layout > ErrorBoundary > children.
- // Parallel slots are passed as named props to the innermost layout
- // (the layout at the same directory level as the page/slots)
- for (let i = route.layouts.length - 1; i >= 0; i--) {
- // Wrap with per-layout error boundary before wrapping with layout.
- // This places the ErrorBoundary inside the layout, catching errors
- // from child segments (matching Next.js per-segment error handling).
- if (route.errors && route.errors[i]?.default) {
- element = createElement(ErrorBoundary, {
- fallback: route.errors[i].default,
- children: element,
- });
- }
-
- const LayoutComponent = route.layouts[i]?.default;
- if (LayoutComponent) {
- // Per-layout NotFoundBoundary: wraps this layout's children so that
- // notFound() thrown from a child layout is caught here.
- // Matches Next.js behavior where each segment has its own boundary.
- // The boundary at level N catches errors from Layout[N+1] and below,
- // but NOT from Layout[N] itself (which propagates to level N-1).
- {
- const LayoutNotFound = route.notFounds?.[i]?.default;
- if (LayoutNotFound) {
- element = createElement(NotFoundBoundary, {
- fallback: createElement(LayoutNotFound),
- children: element,
- });
- }
- }
-
- const layoutProps = { children: element, params: makeThenableParams(params) };
-
- // Add parallel slot elements to the layout that defines them.
- // Each slot has a layoutIndex indicating which layout it belongs to.
- if (route.slots) {
- for (const [slotName, slotMod] of Object.entries(route.slots)) {
- // Attach slot to the layout at its layoutIndex, or to the innermost layout if -1
- const targetIdx = slotMod.layoutIndex >= 0 ? slotMod.layoutIndex : route.layouts.length - 1;
- if (i !== targetIdx) continue;
- // Check if this slot has an intercepting route that should activate
- let SlotPage = null;
- let slotParams = params;
-
- if (opts && opts.interceptSlot === slotName && opts.interceptPage) {
- // Use the intercepting route's page component
- SlotPage = opts.interceptPage.default;
- slotParams = opts.interceptParams || params;
- } else {
- SlotPage = slotMod.page?.default || slotMod.default?.default;
- }
-
- if (SlotPage) {
- let slotElement = createElement(SlotPage, { params: makeThenableParams(slotParams) });
- // Wrap with slot-specific layout if present.
- // In Next.js, @slot/layout.tsx wraps the slot's page content
- // before it is passed as a prop to the parent layout.
- const SlotLayout = slotMod.layout?.default;
- if (SlotLayout) {
- slotElement = createElement(SlotLayout, {
- children: slotElement,
- params: makeThenableParams(slotParams),
- });
- }
- // Wrap with slot-specific loading if present
- if (slotMod.loading?.default) {
- slotElement = createElement(Suspense,
- { fallback: createElement(slotMod.loading.default) },
- slotElement,
- );
- }
- // Wrap with slot-specific error boundary if present
- if (slotMod.error?.default) {
- slotElement = createElement(ErrorBoundary, {
- fallback: slotMod.error.default,
- children: slotElement,
- });
- }
- layoutProps[slotName] = slotElement;
+ return __buildAppPageRouteElement({
+ element: createElement(PageComponent, pageProps),
+ globalErrorModule: null,
+ makeThenableParams,
+ matchedParams: params,
+ resolvedMetadata,
+ resolvedViewport,
+ rootNotFoundModule: null,
+ route,
+ slotOverrides:
+ opts && opts.interceptSlot && opts.interceptPage
+ ? {
+ [opts.interceptSlot]: {
+ pageModule: opts.interceptPage,
+ params: opts.interceptParams || params,
+ },
}
- }
- }
-
- element = createElement(LayoutComponent, layoutProps);
-
- // Wrap the layout with LayoutSegmentProvider so useSelectedLayoutSegments()
- // called INSIDE this layout gets the correct child segments. We resolve the
- // route tree segments using actual param values and pass them through context.
- // We wrap the layout (not just children) because hooks are called from
- // components rendered inside the layout's own JSX.
- const treePos = route.layoutTreePositions ? route.layoutTreePositions[i] : 0;
- const childSegs = __resolveChildSegments(route.routeSegments || [], treePos, params);
- element = createElement(LayoutSegmentProvider, { segmentMap: { children: childSegs } }, element);
- }
- }
-
- // Wrap with global error boundary if app/global-error.tsx exists.
- // This must be present in both HTML and RSC paths so the component tree
- // structure matches — otherwise React reconciliation on client-side navigation
- // would see a mismatched tree and destroy/recreate the DOM.
- //
- // For RSC requests (client-side nav), this provides error recovery on the client.
- // For HTML requests (initial page load), the ErrorBoundary catches during SSR
- // but produces double / (root layout + global-error). The request
- // handler detects this via the rscOnError flag and re-renders without layouts.
-
-
- return element;
+ : null,
+ });
}
@@ -8862,13 +8090,11 @@ function renderToReadableStream(model, options) {
}
}));
}
-import { createElement, Suspense, Fragment } from "react";
+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 { ErrorBoundary, NotFoundBoundary } from "vinext/error-boundary";
-import { LayoutSegmentProvider } from "vinext/layout-segment-context";
-import { MetadataHead, mergeMetadata, resolveModuleMetadata, ViewportHead, mergeViewport, resolveModuleViewport } from "vinext/metadata";
+import { mergeMetadata, resolveModuleMetadata, mergeViewport, resolveModuleViewport } from "vinext/metadata";
import { sitemapToXml, robotsToText, manifestToJson } from "/packages/vinext/src/server/metadata-routes.js";
@@ -8900,6 +8126,10 @@ import {
renderAppPageErrorBoundary as __renderAppPageErrorBoundary,
renderAppPageHttpAccessFallback as __renderAppPageHttpAccessFallback,
} from "/packages/vinext/src/server/app-page-boundary-render.js";
+import {
+ buildAppPageRouteElement as __buildAppPageRouteElement,
+ resolveAppPageChildSegments as __resolveAppPageChildSegments,
+} from "/packages/vinext/src/server/app-page-route-wiring.js";
import {
renderAppPageLifecycle as __renderAppPageLifecycle,
} from "/packages/vinext/src/server/app-page-render.js";
@@ -9067,38 +8297,6 @@ function makeThenableParams(obj) {
return Object.assign(Promise.resolve(plain), plain);
}
-// Resolve route tree segments to actual values using matched params.
-// Dynamic segments like [id] are replaced with param values, catch-all
-// segments like [...slug] are joined with "/", and route groups are kept as-is.
-function __resolveChildSegments(routeSegments, treePosition, params) {
- var raw = routeSegments.slice(treePosition);
- var result = [];
- for (var j = 0; j < raw.length; j++) {
- var seg = raw[j];
- // Optional catch-all: [[...param]]
- if (seg.indexOf("[[...") === 0 && seg.charAt(seg.length - 1) === "]" && seg.charAt(seg.length - 2) === "]") {
- var pn = seg.slice(5, -2);
- var v = params[pn];
- // Skip empty optional catch-all (e.g., visiting /blog on [[...slug]] route)
- if (Array.isArray(v) && v.length === 0) continue;
- if (v == null) continue;
- result.push(Array.isArray(v) ? v.join("/") : v);
- // Catch-all: [...param]
- } else if (seg.indexOf("[...") === 0 && seg.charAt(seg.length - 1) === "]") {
- var pn2 = seg.slice(4, -1);
- var v2 = params[pn2];
- result.push(Array.isArray(v2) ? v2.join("/") : (v2 || seg));
- // Dynamic: [param]
- } else if (seg.charAt(0) === "[" && seg.charAt(seg.length - 1) === "]" && seg.indexOf(".") === -1) {
- var pn3 = seg.slice(1, -1);
- result.push(params[pn3] || seg);
- } else {
- result.push(seg);
- }
- }
- return result;
-}
-
// djb2 hash — matches Next.js's stringHash for digest generation.
// Produces a stable numeric string from error message + stack.
function __errorDigest(str) {
@@ -9372,7 +8570,7 @@ async function renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, req
makeThenableParams,
matchedParams: opts?.matchedParams ?? route?.params ?? {},
requestUrl: request.url,
- resolveChildSegments: __resolveChildSegments,
+ resolveChildSegments: __resolveAppPageChildSegments,
rootForbiddenModule: rootForbiddenModule,
rootLayouts: rootLayouts,
rootNotFoundModule: rootNotFoundModule,
@@ -9418,7 +8616,7 @@ async function renderErrorBoundaryPage(route, error, isRscRequest, request, matc
makeThenableParams,
matchedParams: matchedParams ?? route?.params ?? {},
requestUrl: request.url,
- resolveChildSegments: __resolveChildSegments,
+ resolveChildSegments: __resolveAppPageChildSegments,
rootLayouts: rootLayouts,
route,
renderToReadableStream,
@@ -9584,12 +8782,10 @@ async function buildPageElement(route, params, opts, searchParams) {
const resolvedMetadata = metadataList.length > 0 ? mergeMetadata(metadataList) : null;
const resolvedViewport = mergeViewport(viewportList);
- // Build nested layout tree from outermost to innermost.
- // Next.js 16 passes params/searchParams as Promises (async pattern)
- // but pre-16 code accesses them as plain objects (params.id).
- // makeThenableParams() normalises null-prototype + preserves both patterns.
- const asyncParams = makeThenableParams(params);
- const pageProps = { params: asyncParams };
+ // 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
+ // stays thin and the wiring logic can be unit tested directly.
+ const pageProps = { params: makeThenableParams(params) };
if (searchParams) {
// Always provide searchParams prop when the URL object is available, even
// when the query string is empty -- pages that do "await searchParams" need
@@ -9605,184 +8801,25 @@ async function buildPageElement(route, params, opts, searchParams) {
// dynamic, and this avoids false positives from React internals.
if (hasSearchParams) markDynamicUsage();
}
- let element = createElement(PageComponent, pageProps);
-
- // Wrap page with empty segment provider so useSelectedLayoutSegments()
- // returns [] when called from inside a page component (leaf node).
- element = createElement(LayoutSegmentProvider, { segmentMap: { children: [] } }, element);
-
- // Add metadata + viewport head tags (React 19 hoists title/meta/link to )
- // Next.js always injects charset and default viewport even when no metadata/viewport
- // is exported. We replicate that by always emitting these essential head elements.
- {
- const headElements = [];
- // Always emit — Next.js includes this on every page
- headElements.push(createElement("meta", { charSet: "utf-8" }));
- if (resolvedMetadata) headElements.push(createElement(MetadataHead, { metadata: resolvedMetadata }));
- headElements.push(createElement(ViewportHead, { viewport: resolvedViewport }));
- element = createElement(Fragment, null, ...headElements, element);
- }
-
- // Wrap with loading.tsx Suspense if present
- if (route.loading?.default) {
- element = createElement(
- Suspense,
- { fallback: createElement(route.loading.default) },
- element,
- );
- }
-
- // Wrap with the leaf's error.tsx ErrorBoundary if it's not already covered
- // by a per-layout error boundary (i.e., the leaf has error.tsx but no layout).
- // Per-layout error boundaries are interleaved with layouts below.
- {
- const lastLayoutError = route.errors ? route.errors[route.errors.length - 1] : null;
- if (route.error?.default && route.error !== lastLayoutError) {
- element = createElement(ErrorBoundary, {
- fallback: route.error.default,
- children: element,
- });
- }
- }
-
- // Wrap with NotFoundBoundary so client-side notFound() renders not-found.tsx
- // instead of crashing the React tree. Must be above ErrorBoundary since
- // ErrorBoundary re-throws notFound errors.
- // Pre-render the not-found component as a React element since it may be a
- // server component (not a client reference) and can't be passed as a function prop.
- {
- const NotFoundComponent = route.notFound?.default ?? null;
- if (NotFoundComponent) {
- element = createElement(NotFoundBoundary, {
- fallback: createElement(NotFoundComponent),
- children: element,
- });
- }
- }
-
- // Wrap with templates (innermost first, then outer)
- // Templates are like layouts but re-mount on navigation (client-side concern).
- // On the server, they just wrap the content like layouts do.
- if (route.templates) {
- for (let i = route.templates.length - 1; i >= 0; i--) {
- const TemplateComponent = route.templates[i]?.default;
- if (TemplateComponent) {
- element = createElement(TemplateComponent, { children: element, params });
- }
- }
- }
-
- // Wrap with layouts (innermost first, then outer).
- // At each layout level, first wrap with that level's error boundary (if any)
- // so the boundary is inside the layout and catches errors from children.
- // This matches Next.js behavior: Layout > ErrorBoundary > children.
- // Parallel slots are passed as named props to the innermost layout
- // (the layout at the same directory level as the page/slots)
- for (let i = route.layouts.length - 1; i >= 0; i--) {
- // Wrap with per-layout error boundary before wrapping with layout.
- // This places the ErrorBoundary inside the layout, catching errors
- // from child segments (matching Next.js per-segment error handling).
- if (route.errors && route.errors[i]?.default) {
- element = createElement(ErrorBoundary, {
- fallback: route.errors[i].default,
- children: element,
- });
- }
-
- const LayoutComponent = route.layouts[i]?.default;
- if (LayoutComponent) {
- // Per-layout NotFoundBoundary: wraps this layout's children so that
- // notFound() thrown from a child layout is caught here.
- // Matches Next.js behavior where each segment has its own boundary.
- // The boundary at level N catches errors from Layout[N+1] and below,
- // but NOT from Layout[N] itself (which propagates to level N-1).
- {
- const LayoutNotFound = route.notFounds?.[i]?.default;
- if (LayoutNotFound) {
- element = createElement(NotFoundBoundary, {
- fallback: createElement(LayoutNotFound),
- children: element,
- });
- }
- }
-
- const layoutProps = { children: element, params: makeThenableParams(params) };
-
- // Add parallel slot elements to the layout that defines them.
- // Each slot has a layoutIndex indicating which layout it belongs to.
- if (route.slots) {
- for (const [slotName, slotMod] of Object.entries(route.slots)) {
- // Attach slot to the layout at its layoutIndex, or to the innermost layout if -1
- const targetIdx = slotMod.layoutIndex >= 0 ? slotMod.layoutIndex : route.layouts.length - 1;
- if (i !== targetIdx) continue;
- // Check if this slot has an intercepting route that should activate
- let SlotPage = null;
- let slotParams = params;
-
- if (opts && opts.interceptSlot === slotName && opts.interceptPage) {
- // Use the intercepting route's page component
- SlotPage = opts.interceptPage.default;
- slotParams = opts.interceptParams || params;
- } else {
- SlotPage = slotMod.page?.default || slotMod.default?.default;
- }
-
- if (SlotPage) {
- let slotElement = createElement(SlotPage, { params: makeThenableParams(slotParams) });
- // Wrap with slot-specific layout if present.
- // In Next.js, @slot/layout.tsx wraps the slot's page content
- // before it is passed as a prop to the parent layout.
- const SlotLayout = slotMod.layout?.default;
- if (SlotLayout) {
- slotElement = createElement(SlotLayout, {
- children: slotElement,
- params: makeThenableParams(slotParams),
- });
- }
- // Wrap with slot-specific loading if present
- if (slotMod.loading?.default) {
- slotElement = createElement(Suspense,
- { fallback: createElement(slotMod.loading.default) },
- slotElement,
- );
- }
- // Wrap with slot-specific error boundary if present
- if (slotMod.error?.default) {
- slotElement = createElement(ErrorBoundary, {
- fallback: slotMod.error.default,
- children: slotElement,
- });
- }
- layoutProps[slotName] = slotElement;
+ return __buildAppPageRouteElement({
+ element: createElement(PageComponent, pageProps),
+ globalErrorModule: null,
+ makeThenableParams,
+ matchedParams: params,
+ resolvedMetadata,
+ resolvedViewport,
+ rootNotFoundModule: null,
+ route,
+ slotOverrides:
+ opts && opts.interceptSlot && opts.interceptPage
+ ? {
+ [opts.interceptSlot]: {
+ pageModule: opts.interceptPage,
+ params: opts.interceptParams || params,
+ },
}
- }
- }
-
- element = createElement(LayoutComponent, layoutProps);
-
- // Wrap the layout with LayoutSegmentProvider so useSelectedLayoutSegments()
- // called INSIDE this layout gets the correct child segments. We resolve the
- // route tree segments using actual param values and pass them through context.
- // We wrap the layout (not just children) because hooks are called from
- // components rendered inside the layout's own JSX.
- const treePos = route.layoutTreePositions ? route.layoutTreePositions[i] : 0;
- const childSegs = __resolveChildSegments(route.routeSegments || [], treePos, params);
- element = createElement(LayoutSegmentProvider, { segmentMap: { children: childSegs } }, element);
- }
- }
-
- // Wrap with global error boundary if app/global-error.tsx exists.
- // This must be present in both HTML and RSC paths so the component tree
- // structure matches — otherwise React reconciliation on client-side navigation
- // would see a mismatched tree and destroy/recreate the DOM.
- //
- // For RSC requests (client-side nav), this provides error recovery on the client.
- // For HTML requests (initial page load), the ErrorBoundary catches during SSR
- // but produces double / (root layout + global-error). The request
- // handler detects this via the rscOnError flag and re-renders without layouts.
-
-
- return element;
+ : null,
+ });
}
@@ -11063,13 +10100,11 @@ function renderToReadableStream(model, options) {
}
}));
}
-import { createElement, Suspense, Fragment } from "react";
+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 { ErrorBoundary, NotFoundBoundary } from "vinext/error-boundary";
-import { LayoutSegmentProvider } from "vinext/layout-segment-context";
-import { MetadataHead, mergeMetadata, resolveModuleMetadata, ViewportHead, mergeViewport, resolveModuleViewport } from "vinext/metadata";
+import { mergeMetadata, resolveModuleMetadata, mergeViewport, resolveModuleViewport } from "vinext/metadata";
import * as middlewareModule from "/tmp/test/middleware.ts";
@@ -11101,6 +10136,10 @@ import {
renderAppPageErrorBoundary as __renderAppPageErrorBoundary,
renderAppPageHttpAccessFallback as __renderAppPageHttpAccessFallback,
} from "/packages/vinext/src/server/app-page-boundary-render.js";
+import {
+ buildAppPageRouteElement as __buildAppPageRouteElement,
+ resolveAppPageChildSegments as __resolveAppPageChildSegments,
+} from "/packages/vinext/src/server/app-page-route-wiring.js";
import {
renderAppPageLifecycle as __renderAppPageLifecycle,
} from "/packages/vinext/src/server/app-page-render.js";
@@ -11268,38 +10307,6 @@ function makeThenableParams(obj) {
return Object.assign(Promise.resolve(plain), plain);
}
-// Resolve route tree segments to actual values using matched params.
-// Dynamic segments like [id] are replaced with param values, catch-all
-// segments like [...slug] are joined with "/", and route groups are kept as-is.
-function __resolveChildSegments(routeSegments, treePosition, params) {
- var raw = routeSegments.slice(treePosition);
- var result = [];
- for (var j = 0; j < raw.length; j++) {
- var seg = raw[j];
- // Optional catch-all: [[...param]]
- if (seg.indexOf("[[...") === 0 && seg.charAt(seg.length - 1) === "]" && seg.charAt(seg.length - 2) === "]") {
- var pn = seg.slice(5, -2);
- var v = params[pn];
- // Skip empty optional catch-all (e.g., visiting /blog on [[...slug]] route)
- if (Array.isArray(v) && v.length === 0) continue;
- if (v == null) continue;
- result.push(Array.isArray(v) ? v.join("/") : v);
- // Catch-all: [...param]
- } else if (seg.indexOf("[...") === 0 && seg.charAt(seg.length - 1) === "]") {
- var pn2 = seg.slice(4, -1);
- var v2 = params[pn2];
- result.push(Array.isArray(v2) ? v2.join("/") : (v2 || seg));
- // Dynamic: [param]
- } else if (seg.charAt(0) === "[" && seg.charAt(seg.length - 1) === "]" && seg.indexOf(".") === -1) {
- var pn3 = seg.slice(1, -1);
- result.push(params[pn3] || seg);
- } else {
- result.push(seg);
- }
- }
- return result;
-}
-
// djb2 hash — matches Next.js's stringHash for digest generation.
// Produces a stable numeric string from error message + stack.
function __errorDigest(str) {
@@ -11566,7 +10573,7 @@ async function renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, req
makeThenableParams,
matchedParams: opts?.matchedParams ?? route?.params ?? {},
requestUrl: request.url,
- resolveChildSegments: __resolveChildSegments,
+ resolveChildSegments: __resolveAppPageChildSegments,
rootForbiddenModule: rootForbiddenModule,
rootLayouts: rootLayouts,
rootNotFoundModule: rootNotFoundModule,
@@ -11612,7 +10619,7 @@ async function renderErrorBoundaryPage(route, error, isRscRequest, request, matc
makeThenableParams,
matchedParams: matchedParams ?? route?.params ?? {},
requestUrl: request.url,
- resolveChildSegments: __resolveChildSegments,
+ resolveChildSegments: __resolveAppPageChildSegments,
rootLayouts: rootLayouts,
route,
renderToReadableStream,
@@ -11778,12 +10785,10 @@ async function buildPageElement(route, params, opts, searchParams) {
const resolvedMetadata = metadataList.length > 0 ? mergeMetadata(metadataList) : null;
const resolvedViewport = mergeViewport(viewportList);
- // Build nested layout tree from outermost to innermost.
- // Next.js 16 passes params/searchParams as Promises (async pattern)
- // but pre-16 code accesses them as plain objects (params.id).
- // makeThenableParams() normalises null-prototype + preserves both patterns.
- const asyncParams = makeThenableParams(params);
- const pageProps = { params: asyncParams };
+ // 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
+ // stays thin and the wiring logic can be unit tested directly.
+ const pageProps = { params: makeThenableParams(params) };
if (searchParams) {
// Always provide searchParams prop when the URL object is available, even
// when the query string is empty -- pages that do "await searchParams" need
@@ -11799,184 +10804,25 @@ async function buildPageElement(route, params, opts, searchParams) {
// dynamic, and this avoids false positives from React internals.
if (hasSearchParams) markDynamicUsage();
}
- let element = createElement(PageComponent, pageProps);
-
- // Wrap page with empty segment provider so useSelectedLayoutSegments()
- // returns [] when called from inside a page component (leaf node).
- element = createElement(LayoutSegmentProvider, { segmentMap: { children: [] } }, element);
-
- // Add metadata + viewport head tags (React 19 hoists title/meta/link to )
- // Next.js always injects charset and default viewport even when no metadata/viewport
- // is exported. We replicate that by always emitting these essential head elements.
- {
- const headElements = [];
- // Always emit — Next.js includes this on every page
- headElements.push(createElement("meta", { charSet: "utf-8" }));
- if (resolvedMetadata) headElements.push(createElement(MetadataHead, { metadata: resolvedMetadata }));
- headElements.push(createElement(ViewportHead, { viewport: resolvedViewport }));
- element = createElement(Fragment, null, ...headElements, element);
- }
-
- // Wrap with loading.tsx Suspense if present
- if (route.loading?.default) {
- element = createElement(
- Suspense,
- { fallback: createElement(route.loading.default) },
- element,
- );
- }
-
- // Wrap with the leaf's error.tsx ErrorBoundary if it's not already covered
- // by a per-layout error boundary (i.e., the leaf has error.tsx but no layout).
- // Per-layout error boundaries are interleaved with layouts below.
- {
- const lastLayoutError = route.errors ? route.errors[route.errors.length - 1] : null;
- if (route.error?.default && route.error !== lastLayoutError) {
- element = createElement(ErrorBoundary, {
- fallback: route.error.default,
- children: element,
- });
- }
- }
-
- // Wrap with NotFoundBoundary so client-side notFound() renders not-found.tsx
- // instead of crashing the React tree. Must be above ErrorBoundary since
- // ErrorBoundary re-throws notFound errors.
- // Pre-render the not-found component as a React element since it may be a
- // server component (not a client reference) and can't be passed as a function prop.
- {
- const NotFoundComponent = route.notFound?.default ?? null;
- if (NotFoundComponent) {
- element = createElement(NotFoundBoundary, {
- fallback: createElement(NotFoundComponent),
- children: element,
- });
- }
- }
-
- // Wrap with templates (innermost first, then outer)
- // Templates are like layouts but re-mount on navigation (client-side concern).
- // On the server, they just wrap the content like layouts do.
- if (route.templates) {
- for (let i = route.templates.length - 1; i >= 0; i--) {
- const TemplateComponent = route.templates[i]?.default;
- if (TemplateComponent) {
- element = createElement(TemplateComponent, { children: element, params });
- }
- }
- }
-
- // Wrap with layouts (innermost first, then outer).
- // At each layout level, first wrap with that level's error boundary (if any)
- // so the boundary is inside the layout and catches errors from children.
- // This matches Next.js behavior: Layout > ErrorBoundary > children.
- // Parallel slots are passed as named props to the innermost layout
- // (the layout at the same directory level as the page/slots)
- for (let i = route.layouts.length - 1; i >= 0; i--) {
- // Wrap with per-layout error boundary before wrapping with layout.
- // This places the ErrorBoundary inside the layout, catching errors
- // from child segments (matching Next.js per-segment error handling).
- if (route.errors && route.errors[i]?.default) {
- element = createElement(ErrorBoundary, {
- fallback: route.errors[i].default,
- children: element,
- });
- }
-
- const LayoutComponent = route.layouts[i]?.default;
- if (LayoutComponent) {
- // Per-layout NotFoundBoundary: wraps this layout's children so that
- // notFound() thrown from a child layout is caught here.
- // Matches Next.js behavior where each segment has its own boundary.
- // The boundary at level N catches errors from Layout[N+1] and below,
- // but NOT from Layout[N] itself (which propagates to level N-1).
- {
- const LayoutNotFound = route.notFounds?.[i]?.default;
- if (LayoutNotFound) {
- element = createElement(NotFoundBoundary, {
- fallback: createElement(LayoutNotFound),
- children: element,
- });
- }
- }
-
- const layoutProps = { children: element, params: makeThenableParams(params) };
-
- // Add parallel slot elements to the layout that defines them.
- // Each slot has a layoutIndex indicating which layout it belongs to.
- if (route.slots) {
- for (const [slotName, slotMod] of Object.entries(route.slots)) {
- // Attach slot to the layout at its layoutIndex, or to the innermost layout if -1
- const targetIdx = slotMod.layoutIndex >= 0 ? slotMod.layoutIndex : route.layouts.length - 1;
- if (i !== targetIdx) continue;
- // Check if this slot has an intercepting route that should activate
- let SlotPage = null;
- let slotParams = params;
-
- if (opts && opts.interceptSlot === slotName && opts.interceptPage) {
- // Use the intercepting route's page component
- SlotPage = opts.interceptPage.default;
- slotParams = opts.interceptParams || params;
- } else {
- SlotPage = slotMod.page?.default || slotMod.default?.default;
- }
-
- if (SlotPage) {
- let slotElement = createElement(SlotPage, { params: makeThenableParams(slotParams) });
- // Wrap with slot-specific layout if present.
- // In Next.js, @slot/layout.tsx wraps the slot's page content
- // before it is passed as a prop to the parent layout.
- const SlotLayout = slotMod.layout?.default;
- if (SlotLayout) {
- slotElement = createElement(SlotLayout, {
- children: slotElement,
- params: makeThenableParams(slotParams),
- });
- }
- // Wrap with slot-specific loading if present
- if (slotMod.loading?.default) {
- slotElement = createElement(Suspense,
- { fallback: createElement(slotMod.loading.default) },
- slotElement,
- );
- }
- // Wrap with slot-specific error boundary if present
- if (slotMod.error?.default) {
- slotElement = createElement(ErrorBoundary, {
- fallback: slotMod.error.default,
- children: slotElement,
- });
- }
- layoutProps[slotName] = slotElement;
+ return __buildAppPageRouteElement({
+ element: createElement(PageComponent, pageProps),
+ globalErrorModule: null,
+ makeThenableParams,
+ matchedParams: params,
+ resolvedMetadata,
+ resolvedViewport,
+ rootNotFoundModule: null,
+ route,
+ slotOverrides:
+ opts && opts.interceptSlot && opts.interceptPage
+ ? {
+ [opts.interceptSlot]: {
+ pageModule: opts.interceptPage,
+ params: opts.interceptParams || params,
+ },
}
- }
- }
-
- element = createElement(LayoutComponent, layoutProps);
-
- // Wrap the layout with LayoutSegmentProvider so useSelectedLayoutSegments()
- // called INSIDE this layout gets the correct child segments. We resolve the
- // route tree segments using actual param values and pass them through context.
- // We wrap the layout (not just children) because hooks are called from
- // components rendered inside the layout's own JSX.
- const treePos = route.layoutTreePositions ? route.layoutTreePositions[i] : 0;
- const childSegs = __resolveChildSegments(route.routeSegments || [], treePos, params);
- element = createElement(LayoutSegmentProvider, { segmentMap: { children: childSegs } }, element);
- }
- }
-
- // Wrap with global error boundary if app/global-error.tsx exists.
- // This must be present in both HTML and RSC paths so the component tree
- // structure matches — otherwise React reconciliation on client-side navigation
- // would see a mismatched tree and destroy/recreate the DOM.
- //
- // For RSC requests (client-side nav), this provides error recovery on the client.
- // For HTML requests (initial page load), the ErrorBoundary catches during SSR
- // but produces double / (root layout + global-error). The request
- // handler detects this via the rscOnError flag and re-renders without layouts.
-
-
- return element;
+ : null,
+ });
}
diff --git a/tests/app-page-route-wiring.test.ts b/tests/app-page-route-wiring.test.ts
new file mode 100644
index 000000000..0e3826922
--- /dev/null
+++ b/tests/app-page-route-wiring.test.ts
@@ -0,0 +1,152 @@
+import { createElement, isValidElement, type ReactNode } from "react";
+import ReactDOMServer from "react-dom/server";
+import { describe, expect, it } from "vite-plus/test";
+import { useSelectedLayoutSegments } from "../packages/vinext/src/shims/navigation.js";
+import {
+ buildAppPageRouteElement,
+ createAppPageLayoutEntries,
+ resolveAppPageChildSegments,
+} from "../packages/vinext/src/server/app-page-route-wiring.js";
+
+function readNode(value: unknown): string {
+ return typeof value === "string" ? value : "";
+}
+
+function readChildren(value: unknown): ReactNode {
+ if (
+ value === null ||
+ value === undefined ||
+ typeof value === "string" ||
+ typeof value === "number" ||
+ typeof value === "boolean"
+ ) {
+ return value;
+ }
+
+ if (Array.isArray(value)) {
+ return value.map((item) => readChildren(item));
+ }
+
+ if (isValidElement(value)) {
+ return value;
+ }
+
+ return null;
+}
+
+function RootLayout(props: Record) {
+ const segments = useSelectedLayoutSegments();
+ return createElement(
+ "div",
+ {
+ "data-layout": "root",
+ "data-segments": segments.join("|"),
+ },
+ createElement("aside", { "data-slot": "sidebar" }, readChildren(props.sidebar)),
+ readChildren(props.children),
+ );
+}
+
+function GroupLayout(props: Record) {
+ const segments = useSelectedLayoutSegments();
+ return createElement(
+ "section",
+ {
+ "data-layout": "group",
+ "data-segments": segments.join("|"),
+ },
+ readChildren(props.children),
+ );
+}
+
+function SlotLayout(props: Record) {
+ return createElement("div", { "data-slot-layout": "sidebar" }, readChildren(props.children));
+}
+
+function SlotPage(props: Record) {
+ return createElement("p", { "data-slot-page": readNode(props.label) }, readNode(props.label));
+}
+
+function Template(props: Record) {
+ return createElement("div", { "data-template": "group" }, readChildren(props.children));
+}
+
+function PageProbe() {
+ const segments = useSelectedLayoutSegments();
+ return createElement("main", { "data-page-segments": segments.join("|") }, "Page");
+}
+
+describe("app page route wiring helpers", () => {
+ it("resolves child segments from tree positions and preserves route groups", () => {
+ expect(
+ resolveAppPageChildSegments(["(marketing)", "blog", "[slug]", "[...parts]"], 1, {
+ parts: ["a", "b"],
+ slug: "post",
+ }),
+ ).toEqual(["blog", "post", "a/b"]);
+ });
+
+ it("builds layout entries from tree paths instead of visible URL segments", () => {
+ const entries = createAppPageLayoutEntries({
+ layouts: [{ default: RootLayout }, { default: GroupLayout }],
+ layoutTreePositions: [0, 1],
+ notFounds: [null, null],
+ routeSegments: ["(marketing)", "blog", "[slug]"],
+ });
+
+ expect(entries.map((entry) => entry.id)).toEqual(["layout:/", "layout:/(marketing)"]);
+ expect(entries.map((entry) => entry.treePath)).toEqual(["/", "/(marketing)"]);
+ });
+
+ it("wires templates, slots, and layout segment providers from the route tree", () => {
+ const element = buildAppPageRouteElement({
+ element: createElement(PageProbe),
+ makeThenableParams(params) {
+ return Promise.resolve(params);
+ },
+ matchedParams: { slug: "post" },
+ resolvedMetadata: null,
+ resolvedViewport: {},
+ route: {
+ error: null,
+ errors: [null, null],
+ layoutTreePositions: [0, 1],
+ layouts: [{ default: RootLayout }, { default: GroupLayout }],
+ loading: null,
+ notFound: null,
+ notFounds: [null, null],
+ routeSegments: ["(marketing)", "blog", "[slug]"],
+ slots: {
+ sidebar: {
+ default: null,
+ error: null,
+ layout: { default: SlotLayout },
+ layoutIndex: 0,
+ loading: null,
+ page: { default: SlotPage },
+ },
+ },
+ templates: [{ default: Template }],
+ },
+ rootNotFoundModule: null,
+ slotOverrides: {
+ sidebar: {
+ pageModule: { default: SlotPage },
+ params: { slug: "post" },
+ props: { label: "intercepted" },
+ },
+ },
+ });
+
+ const html = ReactDOMServer.renderToStaticMarkup(element);
+
+ expect(html).toContain('data-layout="root"');
+ expect(html).toContain('data-layout="group"');
+ expect(html).toContain('data-template="group"');
+ expect(html).toContain('data-slot-layout="sidebar"');
+ expect(html).toContain('data-slot-page="intercepted"');
+ expect(html).toContain('data-page-segments=""');
+ expect(html).toContain('data-segments="(marketing)|blog|post"');
+ expect(html).toContain('data-segments="blog|post"');
+ });
+});
diff --git a/tests/error-boundary.test.ts b/tests/error-boundary.test.ts
index 3639bb9fc..c0a308acc 100644
--- a/tests/error-boundary.test.ts
+++ b/tests/error-boundary.test.ts
@@ -19,47 +19,72 @@ vi.mock("next/navigation", () => ({
}));
// The error boundary is primarily a client-side component.
+type ErrorBoundaryInnerConstructor = {
+ getDerivedStateFromError(error: Error): {
+ error: Error | null;
+ previousPathname: string;
+ };
+ getDerivedStateFromProps(
+ props: {
+ children: React.ReactNode;
+ fallback: React.ComponentType<{ error: Error; reset: () => void }>;
+ pathname: string;
+ },
+ state: {
+ error: Error | null;
+ previousPathname: string;
+ },
+ ): {
+ error: Error | null;
+ previousPathname: string;
+ } | null;
+};
+
+function isErrorBoundaryInnerConstructor(value: unknown): value is ErrorBoundaryInnerConstructor {
+ return value !== null && typeof value === "function";
+}
+
+function createErrorWithDigest(message: string, digest: string) {
+ return Object.assign(new Error(message), { digest });
+}
+
// Test the digest detection patterns used by the boundaries
describe("ErrorBoundary digest patterns", () => {
it("NEXT_NOT_FOUND digest matches legacy not-found pattern", () => {
- const error = new Error("Not Found");
- (error as any).digest = "NEXT_NOT_FOUND";
-
- // The ErrorBoundary re-throws errors with these digests
- const digest = (error as any).digest;
- expect(digest === "NEXT_NOT_FOUND").toBe(true);
+ const error = createErrorWithDigest("Not Found", "NEXT_NOT_FOUND");
+ expect(Reflect.get(error, "digest")).toBe("NEXT_NOT_FOUND");
});
it("NEXT_HTTP_ERROR_FALLBACK;404 matches new not-found pattern", () => {
- const error = new Error("Not Found");
- (error as any).digest = "NEXT_HTTP_ERROR_FALLBACK;404";
+ const digest = "NEXT_HTTP_ERROR_FALLBACK;404";
+ const error = createErrorWithDigest("Not Found", digest);
- const digest = (error as any).digest;
+ expect(Reflect.get(error, "digest")).toBe(digest);
expect(digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")).toBe(true);
expect(digest).toBe("NEXT_HTTP_ERROR_FALLBACK;404");
});
it("NEXT_HTTP_ERROR_FALLBACK;403 matches forbidden pattern", () => {
- const error = new Error("Forbidden");
- (error as any).digest = "NEXT_HTTP_ERROR_FALLBACK;403";
+ const digest = "NEXT_HTTP_ERROR_FALLBACK;403";
+ const error = createErrorWithDigest("Forbidden", digest);
- const digest = (error as any).digest;
+ expect(Reflect.get(error, "digest")).toBe(digest);
expect(digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")).toBe(true);
});
it("NEXT_HTTP_ERROR_FALLBACK;401 matches unauthorized pattern", () => {
- const error = new Error("Unauthorized");
- (error as any).digest = "NEXT_HTTP_ERROR_FALLBACK;401";
+ const digest = "NEXT_HTTP_ERROR_FALLBACK;401";
+ const error = createErrorWithDigest("Unauthorized", digest);
- const digest = (error as any).digest;
+ expect(Reflect.get(error, "digest")).toBe(digest);
expect(digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")).toBe(true);
});
it("NEXT_REDIRECT digest matches redirect pattern", () => {
- const error = new Error("Redirect");
- (error as any).digest = "NEXT_REDIRECT;replace;/login;307;";
+ const digest = "NEXT_REDIRECT;replace;/login;307;";
+ const error = createErrorWithDigest("Redirect", digest);
- const digest = (error as any).digest;
+ expect(Reflect.get(error, "digest")).toBe(digest);
expect(digest.startsWith("NEXT_REDIRECT;")).toBe(true);
});
@@ -70,12 +95,12 @@ describe("ErrorBoundary digest patterns", () => {
});
it("errors with non-special digests are caught by ErrorBoundary", () => {
- const error = new Error("Custom error");
- (error as any).digest = "SOME_CUSTOM_DIGEST";
+ const digest = "SOME_CUSTOM_DIGEST";
+ const error = createErrorWithDigest("Custom error", digest);
- const digest = (error as any).digest;
+ expect(Reflect.get(error, "digest")).toBe(digest);
// These should NOT be re-thrown — they should be caught
- expect(digest === "NEXT_NOT_FOUND").toBe(false);
+ expect(digest).not.toBe("NEXT_NOT_FOUND");
expect(digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")).toBe(false);
expect(digest.startsWith("NEXT_REDIRECT;")).toBe(false);
});
@@ -85,53 +110,94 @@ describe("ErrorBoundary digest patterns", () => {
// The real method THROWS for digest errors (re-throwing them past the boundary)
// and returns { error } for regular errors (catching them).
describe("ErrorBoundary digest classification (actual class)", () => {
- let ErrorBoundary: any;
+ let ErrorBoundaryInnerClass: ErrorBoundaryInnerConstructor | null = null;
+ let ErrorBoundaryInner: ErrorBoundaryInnerConstructor | null = null;
beforeAll(async () => {
const mod = await import("../packages/vinext/src/shims/error-boundary.js");
- ErrorBoundary = mod.ErrorBoundary;
+ const maybeInner = Reflect.get(mod, "ErrorBoundaryInner");
+ if (isErrorBoundaryInnerConstructor(maybeInner)) {
+ ErrorBoundaryInnerClass = maybeInner;
+ ErrorBoundaryInner = maybeInner;
+ }
});
it("rethrows NEXT_NOT_FOUND", () => {
const e = Object.assign(new Error(), { digest: "NEXT_NOT_FOUND" });
- expect(() => ErrorBoundary.getDerivedStateFromError(e)).toThrow(e);
+ expect(ErrorBoundaryInnerClass).not.toBeNull();
+ expect(() => ErrorBoundaryInnerClass?.getDerivedStateFromError(e)).toThrow(e);
});
it("rethrows NEXT_HTTP_ERROR_FALLBACK;404", () => {
const e = Object.assign(new Error(), { digest: "NEXT_HTTP_ERROR_FALLBACK;404" });
- expect(() => ErrorBoundary.getDerivedStateFromError(e)).toThrow(e);
+ expect(ErrorBoundaryInnerClass).not.toBeNull();
+ expect(() => ErrorBoundaryInnerClass?.getDerivedStateFromError(e)).toThrow(e);
});
it("rethrows NEXT_HTTP_ERROR_FALLBACK;403", () => {
const e = Object.assign(new Error(), { digest: "NEXT_HTTP_ERROR_FALLBACK;403" });
- expect(() => ErrorBoundary.getDerivedStateFromError(e)).toThrow(e);
+ expect(ErrorBoundaryInnerClass).not.toBeNull();
+ expect(() => ErrorBoundaryInnerClass?.getDerivedStateFromError(e)).toThrow(e);
});
it("rethrows NEXT_HTTP_ERROR_FALLBACK;401", () => {
const e = Object.assign(new Error(), { digest: "NEXT_HTTP_ERROR_FALLBACK;401" });
- expect(() => ErrorBoundary.getDerivedStateFromError(e)).toThrow(e);
+ expect(ErrorBoundaryInnerClass).not.toBeNull();
+ expect(() => ErrorBoundaryInnerClass?.getDerivedStateFromError(e)).toThrow(e);
});
it("rethrows NEXT_REDIRECT", () => {
const e = Object.assign(new Error(), { digest: "NEXT_REDIRECT;replace;/login;307;" });
- expect(() => ErrorBoundary.getDerivedStateFromError(e)).toThrow(e);
+ expect(ErrorBoundaryInnerClass).not.toBeNull();
+ expect(() => ErrorBoundaryInnerClass?.getDerivedStateFromError(e)).toThrow(e);
});
it("catches regular errors (no digest)", () => {
const e = new Error("oops");
- const state = ErrorBoundary.getDerivedStateFromError(e);
- expect(state).toEqual({ error: e });
+ expect(ErrorBoundaryInnerClass).not.toBeNull();
+ const state = ErrorBoundaryInnerClass?.getDerivedStateFromError(e);
+ expect(state).toMatchObject({ error: e });
});
it("catches errors with unknown digest", () => {
const e = Object.assign(new Error(), { digest: "CUSTOM_ERROR" });
- const state = ErrorBoundary.getDerivedStateFromError(e);
- expect(state).toEqual({ error: e });
+ expect(ErrorBoundaryInnerClass).not.toBeNull();
+ const state = ErrorBoundaryInnerClass?.getDerivedStateFromError(e);
+ expect(state).toMatchObject({ error: e });
});
it("catches errors with empty digest", () => {
const e = Object.assign(new Error(), { digest: "" });
- const state = ErrorBoundary.getDerivedStateFromError(e);
- expect(state).toEqual({ error: e });
+ expect(ErrorBoundaryInnerClass).not.toBeNull();
+ const state = ErrorBoundaryInnerClass?.getDerivedStateFromError(e);
+ expect(state).toMatchObject({ error: e });
+ });
+
+ it("resets caught errors when the pathname changes", () => {
+ expect(ErrorBoundaryInner).not.toBeNull();
+ if (!ErrorBoundaryInner) {
+ throw new Error("Expected ErrorBoundaryInner export");
+ }
+
+ function Fallback() {
+ return null;
+ }
+
+ const state = ErrorBoundaryInner.getDerivedStateFromProps(
+ {
+ children: null,
+ fallback: Fallback,
+ pathname: "/next",
+ },
+ {
+ error: new Error("stuck"),
+ previousPathname: "/previous",
+ },
+ );
+
+ expect(state).toEqual({
+ error: null,
+ previousPathname: "/next",
+ });
});
});
From be33773470b44b3767e6f3b2ed4aecceefa075b4 Mon Sep 17 00:00:00 2001
From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com>
Date: Thu, 2 Apr 2026 12:42:18 +1100
Subject: [PATCH 2/7] Fix app page error boundary serialization
---
.../src/server/app-page-route-wiring.tsx | 83 +++++++++++--------
1 file changed, 50 insertions(+), 33 deletions(-)
diff --git a/packages/vinext/src/server/app-page-route-wiring.tsx b/packages/vinext/src/server/app-page-route-wiring.tsx
index 936826612..8936afd29 100644
--- a/packages/vinext/src/server/app-page-route-wiring.tsx
+++ b/packages/vinext/src/server/app-page-route-wiring.tsx
@@ -12,31 +12,41 @@ type AppPageComponentProps = {
} & Record;
type AppPageComponent = ComponentType;
-type ErrorBoundaryFallbackComponent = ComponentType<{ error: Error; reset: () => void }>;
+type AppPageErrorComponent = ComponentType<{ error: Error; reset: () => void }>;
export type AppPageModule = Record & {
default?: AppPageComponent | null | undefined;
};
-export type AppPageRouteWiringSlot = {
+export type AppPageErrorModule = Record & {
+ default?: AppPageErrorComponent | null | undefined;
+};
+
+export type AppPageRouteWiringSlot<
+ TModule extends AppPageModule = AppPageModule,
+ TErrorModule extends AppPageErrorModule = AppPageErrorModule,
+> = {
default?: TModule | null;
- error?: TModule | null;
+ error?: TErrorModule | null;
layout?: TModule | null;
layoutIndex: number;
loading?: TModule | null;
page?: TModule | null;
};
-export type AppPageRouteWiringRoute = {
- error?: TModule | null;
- errors?: readonly (TModule | null | undefined)[] | null;
+export type AppPageRouteWiringRoute<
+ TModule extends AppPageModule = AppPageModule,
+ TErrorModule extends AppPageErrorModule = AppPageErrorModule,
+> = {
+ error?: TErrorModule | null;
+ errors?: readonly (TErrorModule | null | undefined)[] | null;
layoutTreePositions?: readonly number[] | null;
layouts: readonly (TModule | null | undefined)[];
loading?: TModule | null;
notFound?: TModule | null;
notFounds?: readonly (TModule | null | undefined)[] | null;
routeSegments?: readonly string[];
- slots?: Readonly>> | null;
+ slots?: Readonly>> | null;
templates?: readonly (TModule | null | undefined)[] | null;
};
@@ -46,8 +56,11 @@ export type AppPageSlotOverride =
props?: Readonly>;
};
-export type AppPageLayoutEntry = {
- errorModule?: TModule | null | undefined;
+export type AppPageLayoutEntry<
+ TModule extends AppPageModule = AppPageModule,
+ TErrorModule extends AppPageErrorModule = AppPageErrorModule,
+> = {
+ errorModule?: TErrorModule | null | undefined;
id: string;
layoutModule?: TModule | null | undefined;
notFoundModule?: TModule | null | undefined;
@@ -55,15 +68,18 @@ export type AppPageLayoutEntry =
treePosition: number;
};
-export type BuildAppPageRouteElementOptions = {
+export type BuildAppPageRouteElementOptions<
+ TModule extends AppPageModule = AppPageModule,
+ TErrorModule extends AppPageErrorModule = AppPageErrorModule,
+> = {
element: ReactNode;
- globalErrorModule?: TModule | null;
+ globalErrorModule?: TErrorModule | null;
makeThenableParams: (params: AppPageParams) => unknown;
matchedParams: AppPageParams;
resolvedMetadata: Metadata | null;
resolvedViewport: Viewport;
rootNotFoundModule?: TModule | null;
- route: AppPageRouteWiringRoute;
+ route: AppPageRouteWiringRoute;
slotOverrides?: Readonly>> | null;
};
@@ -73,13 +89,10 @@ function getDefaultExport(
return module?.default ?? null;
}
-function wrapWithErrorBoundary(fallback: AppPageComponent, children: ReactNode): ReactNode {
- const FallbackBoundary: ErrorBoundaryFallbackComponent = ({ error, reset }) => {
- const FallbackComponent = fallback;
- return ;
- };
-
- return {children};
+function getErrorBoundaryExport(
+ module: TModule | null | undefined,
+): AppPageErrorComponent | null {
+ return module?.default ?? null;
}
export function createAppPageTreePath(
@@ -93,12 +106,15 @@ export function createAppPageTreePath(
return `/${treePathSegments.join("/")}`;
}
-export function createAppPageLayoutEntries(
+export function createAppPageLayoutEntries<
+ TModule extends AppPageModule,
+ TErrorModule extends AppPageErrorModule,
+>(
route: Pick<
- AppPageRouteWiringRoute,
+ AppPageRouteWiringRoute,
"errors" | "layoutTreePositions" | "layouts" | "notFounds" | "routeSegments"
>,
-): AppPageLayoutEntry[] {
+): AppPageLayoutEntry[] {
return route.layouts.map((layoutModule, index) => {
const treePosition = route.layoutTreePositions?.[index] ?? 0;
const treePath = createAppPageTreePath(route.routeSegments, treePosition);
@@ -165,9 +181,10 @@ export function resolveAppPageChildSegments(
return resolvedSegments;
}
-export function buildAppPageRouteElement(
- options: BuildAppPageRouteElementOptions,
-): ReactNode {
+export function buildAppPageRouteElement<
+ TModule extends AppPageModule,
+ TErrorModule extends AppPageErrorModule,
+>(options: BuildAppPageRouteElementOptions): ReactNode {
let element: ReactNode = (
{options.element}
);
@@ -191,9 +208,9 @@ export function buildAppPageRouteElement(
options.route.errors && options.route.errors.length > 0
? options.route.errors[options.route.errors.length - 1]
: null;
- const pageErrorComponent = getDefaultExport(options.route.error);
+ const pageErrorComponent = getErrorBoundaryExport(options.route.error);
if (pageErrorComponent && options.route.error !== lastLayoutErrorModule) {
- element = wrapWithErrorBoundary(pageErrorComponent, element);
+ element = {element};
}
const notFoundComponent =
@@ -219,9 +236,9 @@ export function buildAppPageRouteElement(
for (let index = layoutEntries.length - 1; index >= 0; index--) {
const layoutEntry = layoutEntries[index];
- const layoutErrorComponent = getDefaultExport(layoutEntry.errorModule);
+ const layoutErrorComponent = getErrorBoundaryExport(layoutEntry.errorModule);
if (layoutErrorComponent) {
- element = wrapWithErrorBoundary(layoutErrorComponent, element);
+ element = {element};
}
const layoutComponent = getDefaultExport(layoutEntry.layoutModule);
@@ -283,9 +300,9 @@ export function buildAppPageRouteElement(
slotElement = }>{slotElement};
}
- const slotErrorComponent = getDefaultExport(slot.error);
+ const slotErrorComponent = getErrorBoundaryExport(slot.error);
if (slotErrorComponent) {
- slotElement = wrapWithErrorBoundary(slotErrorComponent, slotElement);
+ slotElement = {slotElement};
}
layoutProps[slotName] = slotElement;
@@ -308,9 +325,9 @@ export function buildAppPageRouteElement(
);
}
- const globalErrorComponent = getDefaultExport(options.globalErrorModule);
+ const globalErrorComponent = getErrorBoundaryExport(options.globalErrorModule);
if (globalErrorComponent) {
- element = wrapWithErrorBoundary(globalErrorComponent, element);
+ element = {element};
}
return element;
From ca40d05f70e9fad6b5e557d5233cbf020573a081 Mon Sep 17 00:00:00 2001
From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com>
Date: Thu, 2 Apr 2026 12:55:24 +1100
Subject: [PATCH 3/7] Fix client error boundary pathname reset
---
packages/vinext/src/shims/error-boundary.tsx | 4 +--
tests/error-boundary.test.ts | 35 ++++++++++++++++++++
2 files changed, 37 insertions(+), 2 deletions(-)
diff --git a/packages/vinext/src/shims/error-boundary.tsx b/packages/vinext/src/shims/error-boundary.tsx
index b7eb76fd5..cadcbdb92 100644
--- a/packages/vinext/src/shims/error-boundary.tsx
+++ b/packages/vinext/src/shims/error-boundary.tsx
@@ -42,7 +42,7 @@ export class ErrorBoundaryInner extends React.Component<
return { error: state.error, previousPathname: props.pathname };
}
- static getDerivedStateFromError(error: Error): ErrorBoundaryState {
+ static getDerivedStateFromError(error: Error): Partial {
// notFound(), forbidden(), unauthorized(), and redirect() must propagate
// past error boundaries. Re-throw them so they bubble up to the
// framework's HTTP access fallback / redirect handler.
@@ -56,7 +56,7 @@ export class ErrorBoundaryInner extends React.Component<
throw error;
}
}
- return { error, previousPathname: "" };
+ return { error };
}
reset = () => {
diff --git a/tests/error-boundary.test.ts b/tests/error-boundary.test.ts
index c0a308acc..8e5958fb9 100644
--- a/tests/error-boundary.test.ts
+++ b/tests/error-boundary.test.ts
@@ -200,4 +200,39 @@ describe("ErrorBoundary digest classification (actual class)", () => {
previousPathname: "/next",
});
});
+
+ it("does not immediately clear a caught error on the same pathname", () => {
+ expect(ErrorBoundaryInner).not.toBeNull();
+ if (!ErrorBoundaryInner) {
+ throw new Error("Expected ErrorBoundaryInner export");
+ }
+
+ const error = new Error("stuck");
+ const baseState = {
+ error: null,
+ previousPathname: "/error-test",
+ };
+ const stateAfterError = {
+ ...baseState,
+ ...ErrorBoundaryInner.getDerivedStateFromError(error),
+ };
+
+ function Fallback() {
+ return null;
+ }
+
+ const stateAfterProps = ErrorBoundaryInner.getDerivedStateFromProps(
+ {
+ children: null,
+ fallback: Fallback,
+ pathname: "/error-test",
+ },
+ stateAfterError,
+ );
+
+ expect(stateAfterProps).toEqual({
+ error,
+ previousPathname: "/error-test",
+ });
+ });
});
From bddda39ac3ee2504bd99df2b71248024b5d2efe8 Mon Sep 17 00:00:00 2001
From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com>
Date: Thu, 2 Apr 2026 13:13:04 +1100
Subject: [PATCH 4/7] Document Next.js error boundary verification
---
tests/error-boundary.test.ts | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/tests/error-boundary.test.ts b/tests/error-boundary.test.ts
index 8e5958fb9..65fcb3fb5 100644
--- a/tests/error-boundary.test.ts
+++ b/tests/error-boundary.test.ts
@@ -18,6 +18,14 @@ vi.mock("next/navigation", () => ({
usePathname: () => "/",
}));
// The error boundary is primarily a client-side component.
+//
+// Verified against Next.js source:
+// - packages/next/src/client/components/error-boundary.tsx
+// - packages/next/src/client/components/navigation.ts
+//
+// Next.js resets segment error boundaries on pathname changes using a
+// previousPathname field, and usePathname() is pathname-only rather than
+// query-aware. These tests lock our shim to that behavior.
type ErrorBoundaryInnerConstructor = {
getDerivedStateFromError(error: Error): {
From f7b35a0897481901988b3999f8661e520fc9294a Mon Sep 17 00:00:00 2001
From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com>
Date: Thu, 2 Apr 2026 21:39:07 +1100
Subject: [PATCH 5/7] Address bonk review: edge case tests and readability nit
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Fix optional catch-all length check readability (> len-1 → >= len)
- Add dedicated tests for behavioral changes: strict undefined check
on optional catch-all params, nullish coalescing preserving empty
strings instead of falling back to raw segment syntax
- Add edge case tests: route groups, empty array, undefined dynamic
params
- Add Next.js source reference comments on pathname-reset error
boundary tests (no direct Next.js test equivalent exists)
---
.../src/server/app-page-route-wiring.tsx | 2 +-
tests/app-page-route-wiring.test.ts | 33 +++++++++++++++++++
tests/error-boundary.test.ts | 9 +++++
3 files changed, 43 insertions(+), 1 deletion(-)
diff --git a/packages/vinext/src/server/app-page-route-wiring.tsx b/packages/vinext/src/server/app-page-route-wiring.tsx
index 8936afd29..21bc1e194 100644
--- a/packages/vinext/src/server/app-page-route-wiring.tsx
+++ b/packages/vinext/src/server/app-page-route-wiring.tsx
@@ -141,7 +141,7 @@ export function resolveAppPageChildSegments(
if (
segment.startsWith("[[...") &&
segment.endsWith("]]") &&
- segment.length > "[[...x]]".length - 1
+ segment.length >= "[[...x]]".length
) {
const paramName = segment.slice(5, -2);
const paramValue = params[paramName];
diff --git a/tests/app-page-route-wiring.test.ts b/tests/app-page-route-wiring.test.ts
index 0e3826922..0dee6d25e 100644
--- a/tests/app-page-route-wiring.test.ts
+++ b/tests/app-page-route-wiring.test.ts
@@ -86,6 +86,39 @@ describe("app page route wiring helpers", () => {
).toEqual(["blog", "post", "a/b"]);
});
+ it("passes route group segments through unchanged", () => {
+ expect(resolveAppPageChildSegments(["(auth)", "login"], 0, {})).toEqual(["(auth)", "login"]);
+ });
+
+ it("skips optional catch-all when param is undefined", () => {
+ expect(resolveAppPageChildSegments(["docs", "[[...slug]]"], 0, {})).toEqual(["docs"]);
+ });
+
+ it("skips optional catch-all when param is an empty array", () => {
+ expect(resolveAppPageChildSegments(["docs", "[[...slug]]"], 0, { slug: [] })).toEqual(["docs"]);
+ });
+
+ it("pushes null param value for optional catch-all (strict undefined check)", () => {
+ // Optional catch-all uses `=== undefined` (not `== null`) to decide whether to
+ // skip the segment. null is not a valid AppPageParams value, but if it leaks in,
+ // it passes through rather than being silently dropped.
+ expect(
+ resolveAppPageChildSegments(["docs", "[[...slug]]"], 0, {
+ slug: null as unknown as string,
+ }),
+ ).toEqual(["docs", null]);
+ });
+
+ it("falls back to raw segment for dynamic param with undefined value", () => {
+ expect(resolveAppPageChildSegments(["blog", "[id]"], 0, {})).toEqual(["blog", "[id]"]);
+ });
+
+ it("preserves empty-string param instead of falling back to raw segment", () => {
+ expect(
+ resolveAppPageChildSegments(["blog", "[...slug]"], 0, { slug: "" as unknown as string }),
+ ).toEqual(["blog", ""]);
+ });
+
it("builds layout entries from tree paths instead of visible URL segments", () => {
const entries = createAppPageLayoutEntries({
layouts: [{ default: RootLayout }, { default: GroupLayout }],
diff --git a/tests/error-boundary.test.ts b/tests/error-boundary.test.ts
index 65fcb3fb5..6d651c59e 100644
--- a/tests/error-boundary.test.ts
+++ b/tests/error-boundary.test.ts
@@ -181,6 +181,11 @@ describe("ErrorBoundary digest classification (actual class)", () => {
expect(state).toMatchObject({ error: e });
});
+ // No direct Next.js test equivalent; behavior inferred from
+ // packages/next/src/client/components/error-boundary.tsx (getDerivedStateFromProps).
+ // Next.js uses the same pathname !== previousPathname guard at line 93 to clear
+ // error state on navigation. Their E2E test (test/e2e/app-dir/errors/index.test.ts)
+ // only covers the button-click reset path, not pathname-based reset.
it("resets caught errors when the pathname changes", () => {
expect(ErrorBoundaryInner).not.toBeNull();
if (!ErrorBoundaryInner) {
@@ -209,6 +214,10 @@ describe("ErrorBoundary digest classification (actual class)", () => {
});
});
+ // Validates the getDerivedStateFromError → getDerivedStateFromProps sequence
+ // on the same pathname. Inferred from Next.js error-boundary.tsx: getDerivedStateFromError
+ // returns { error } (partial state), React merges it preserving previousPathname, then
+ // getDerivedStateFromProps sees matching pathnames and preserves the error.
it("does not immediately clear a caught error on the same pathname", () => {
expect(ErrorBoundaryInner).not.toBeNull();
if (!ErrorBoundaryInner) {
From 175ae936575e1e5ad3cf8a510ccedc3d4b43ee44 Mon Sep 17 00:00:00 2001
From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com>
Date: Thu, 2 Apr 2026 21:52:45 +1100
Subject: [PATCH 6/7] Address second-round review: type honesty and test
cleanup
- Widen resolveAppPageChildSegments params to include undefined,
matching runtime behavior of Record indexing without
noUncheckedIndexedAccess
- Fix ErrorBoundaryInnerConstructor test type to match actual
Partial return from getDerivedStateFromError
- Remove null param test case (impossible at runtime given
AppPageParams type, testing unreachable behavior)
- Remove vestigial type cast on empty-string test
---
.../src/server/app-page-route-wiring.tsx | 2 +-
tests/app-page-route-wiring.test.ts | 18 ++++--------------
tests/error-boundary.test.ts | 4 ++--
3 files changed, 7 insertions(+), 17 deletions(-)
diff --git a/packages/vinext/src/server/app-page-route-wiring.tsx b/packages/vinext/src/server/app-page-route-wiring.tsx
index 21bc1e194..00939606e 100644
--- a/packages/vinext/src/server/app-page-route-wiring.tsx
+++ b/packages/vinext/src/server/app-page-route-wiring.tsx
@@ -132,7 +132,7 @@ export function createAppPageLayoutEntries<
export function resolveAppPageChildSegments(
routeSegments: readonly string[],
treePosition: number,
- params: AppPageParams,
+ params: Readonly>,
): string[] {
const rawSegments = routeSegments.slice(treePosition);
const resolvedSegments: string[] = [];
diff --git a/tests/app-page-route-wiring.test.ts b/tests/app-page-route-wiring.test.ts
index 0dee6d25e..1bc1956a4 100644
--- a/tests/app-page-route-wiring.test.ts
+++ b/tests/app-page-route-wiring.test.ts
@@ -98,25 +98,15 @@ describe("app page route wiring helpers", () => {
expect(resolveAppPageChildSegments(["docs", "[[...slug]]"], 0, { slug: [] })).toEqual(["docs"]);
});
- it("pushes null param value for optional catch-all (strict undefined check)", () => {
- // Optional catch-all uses `=== undefined` (not `== null`) to decide whether to
- // skip the segment. null is not a valid AppPageParams value, but if it leaks in,
- // it passes through rather than being silently dropped.
- expect(
- resolveAppPageChildSegments(["docs", "[[...slug]]"], 0, {
- slug: null as unknown as string,
- }),
- ).toEqual(["docs", null]);
- });
-
it("falls back to raw segment for dynamic param with undefined value", () => {
expect(resolveAppPageChildSegments(["blog", "[id]"], 0, {})).toEqual(["blog", "[id]"]);
});
it("preserves empty-string param instead of falling back to raw segment", () => {
- expect(
- resolveAppPageChildSegments(["blog", "[...slug]"], 0, { slug: "" as unknown as string }),
- ).toEqual(["blog", ""]);
+ expect(resolveAppPageChildSegments(["blog", "[...slug]"], 0, { slug: "" })).toEqual([
+ "blog",
+ "",
+ ]);
});
it("builds layout entries from tree paths instead of visible URL segments", () => {
diff --git a/tests/error-boundary.test.ts b/tests/error-boundary.test.ts
index 6d651c59e..330cc0141 100644
--- a/tests/error-boundary.test.ts
+++ b/tests/error-boundary.test.ts
@@ -28,10 +28,10 @@ vi.mock("next/navigation", () => ({
// query-aware. These tests lock our shim to that behavior.
type ErrorBoundaryInnerConstructor = {
- getDerivedStateFromError(error: Error): {
+ getDerivedStateFromError(error: Error): Partial<{
error: Error | null;
previousPathname: string;
- };
+ }>;
getDerivedStateFromProps(
props: {
children: React.ReactNode;
From 98174cabee846780f94ab3e9e0df80f14f147bd5 Mon Sep 17 00:00:00 2001
From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com>
Date: Thu, 2 Apr 2026 23:37:01 +1100
Subject: [PATCH 7/7] Add comment explaining template vs layout param asymmetry
Templates receive raw params (Next.js doesn't pass params to
templates at all), while layouts receive thenable params matching
the Next.js 15+ async params contract.
---
packages/vinext/src/server/app-page-route-wiring.tsx | 3 +++
1 file changed, 3 insertions(+)
diff --git a/packages/vinext/src/server/app-page-route-wiring.tsx b/packages/vinext/src/server/app-page-route-wiring.tsx
index 00939606e..91e7fe0f3 100644
--- a/packages/vinext/src/server/app-page-route-wiring.tsx
+++ b/packages/vinext/src/server/app-page-route-wiring.tsx
@@ -227,6 +227,9 @@ export function buildAppPageRouteElement<
continue;
}
const TemplateComponent = templateComponent;
+ // Next.js doesn't pass params to templates at all (createElement(Template, null, children)).
+ // We pass raw params here for convenience; layouts below receive thenable (Promise) params
+ // to match the Next.js 15+ async params contract.
element = {element};
}