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 01/19] 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 5d8525b9f72a9f5df0054cf2b46bfaf0c08c9a97 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 02/19] Add slot client primitives
---
packages/vinext/src/shims/slot.tsx | 78 +++++++++
tests/slot.test.ts | 265 +++++++++++++++++++++++++++++
2 files changed, 343 insertions(+)
create mode 100644 packages/vinext/src/shims/slot.tsx
create mode 100644 tests/slot.test.ts
diff --git a/packages/vinext/src/shims/slot.tsx b/packages/vinext/src/shims/slot.tsx
new file mode 100644
index 000000000..19de9c528
--- /dev/null
+++ b/packages/vinext/src/shims/slot.tsx
@@ -0,0 +1,78 @@
+"use client";
+
+import * as React from "react";
+import { notFound } from "./navigation.js";
+
+type Elements = Record;
+
+const EMPTY_ELEMENTS_PROMISE = Promise.resolve({});
+const mergeCache = new WeakMap, WeakMap, Promise>>();
+
+export const UNMATCHED_SLOT = Symbol.for("vinext.unmatchedSlot");
+
+export const ElementsContext = React.createContext>(EMPTY_ELEMENTS_PROMISE);
+
+export const ChildrenContext = React.createContext(null);
+
+export const ParallelSlotsContext = React.createContext
+> | null>(null);
+
+export function mergeElementsPromise(
+ prev: Promise,
+ next: Promise,
+): Promise {
+ let nextCache = mergeCache.get(prev);
+ if (!nextCache) {
+ nextCache = new WeakMap();
+ mergeCache.set(prev, nextCache);
+ }
+
+ const cached = nextCache.get(next);
+ if (cached) {
+ return cached;
+ }
+
+ const merged = Promise.all([prev, next]).then(([prevElements, nextElements]) => ({
+ ...prevElements,
+ ...nextElements,
+ }));
+ nextCache.set(next, merged);
+ return merged;
+}
+
+export function Slot({
+ id,
+ children,
+ parallelSlots,
+}: {
+ id: string;
+ children?: React.ReactNode;
+ parallelSlots?: Readonly>;
+}) {
+ const elements = React.use(React.useContext(ElementsContext));
+
+ if (!(id in elements)) {
+ return null;
+ }
+
+ const element = elements[id];
+ if (element === UNMATCHED_SLOT) {
+ notFound();
+ }
+
+ return (
+
+ {element}
+
+ );
+}
+
+export function Children() {
+ return React.useContext(ChildrenContext);
+}
+
+export function ParallelSlot({ name }: { name: string }) {
+ const slots = React.useContext(ParallelSlotsContext);
+ return slots?.[name] ?? null;
+}
diff --git a/tests/slot.test.ts b/tests/slot.test.ts
new file mode 100644
index 000000000..62d52b40a
--- /dev/null
+++ b/tests/slot.test.ts
@@ -0,0 +1,265 @@
+import React, { Suspense } from "react";
+import { renderToReadableStream } from "react-dom/server.edge";
+import { describe, expect, it, vi } from "vite-plus/test";
+
+vi.mock("next/navigation", () => ({
+ usePathname: () => "/",
+}));
+
+type Deferred = {
+ promise: Promise;
+ resolve: (value: T) => void;
+};
+
+function createContextProvider(
+ context: React.Context,
+ value: TValue,
+ child: React.ReactNode,
+): React.ReactElement {
+ return React.createElement(context.Provider, { value }, child);
+}
+
+function createDeferred(): Deferred {
+ let resolvePromise: ((value: T) => void) | undefined;
+ const promise = new Promise((resolve) => {
+ resolvePromise = resolve;
+ });
+ if (!resolvePromise) {
+ throw new Error("Deferred promise resolver was not created");
+ }
+ return {
+ promise,
+ resolve: resolvePromise,
+ };
+}
+
+async function readStream(stream: ReadableStream): Promise {
+ const reader = stream.getReader();
+ const decoder = new TextDecoder();
+ let text = "";
+
+ for (;;) {
+ const { done, value } = await reader.read();
+ if (done) {
+ break;
+ }
+ text += decoder.decode(value, { stream: true });
+ }
+
+ return text + decoder.decode();
+}
+
+async function renderHtml(element: React.ReactElement): Promise {
+ const stream = await renderToReadableStream(element);
+ await stream.allReady;
+ return readStream(stream);
+}
+
+describe("slot primitives", () => {
+ it("exports the client primitives", async () => {
+ const mod = await import("../packages/vinext/src/shims/slot.js");
+
+ expect(typeof mod.Slot).toBe("function");
+ expect(typeof mod.Children).toBe("function");
+ expect(typeof mod.ParallelSlot).toBe("function");
+ expect(typeof mod.mergeElementsPromise).toBe("function");
+ expect(mod.ElementsContext).toBeDefined();
+ expect(mod.ChildrenContext).toBeDefined();
+ expect(mod.ParallelSlotsContext).toBeDefined();
+ expect(mod.UNMATCHED_SLOT).toBe(Symbol.for("vinext.unmatchedSlot"));
+ });
+
+ it("Children renders null outside a Slot provider", async () => {
+ const { Children } = await import("../packages/vinext/src/shims/slot.js");
+
+ const html = await renderHtml(React.createElement(Children));
+ expect(html).toBe("");
+ });
+
+ it("ParallelSlot renders null outside a Slot provider", async () => {
+ const { ParallelSlot } = await import("../packages/vinext/src/shims/slot.js");
+
+ const html = await renderHtml(React.createElement(ParallelSlot, { name: "modal" }));
+ expect(html).toBe("");
+ });
+
+ it("Slot renders the matched element and provides children and parallel slots", async () => {
+ const mod = await import("../packages/vinext/src/shims/slot.js");
+
+ function LayoutShell(): React.ReactElement {
+ return React.createElement(
+ "div",
+ null,
+ React.createElement("main", null, React.createElement(mod.Children)),
+ React.createElement(
+ "aside",
+ null,
+ React.createElement(mod.ParallelSlot, { name: "modal" }),
+ ),
+ );
+ }
+
+ const slotElement = createContextProvider(
+ mod.ElementsContext,
+ Promise.resolve({ "layout:/": React.createElement(LayoutShell) }),
+ React.createElement(
+ mod.Slot,
+ {
+ id: "layout:/",
+ parallelSlots: {
+ modal: React.createElement("em", null, "modal content"),
+ },
+ },
+ React.createElement("span", null, "child content"),
+ ),
+ );
+
+ const html = await renderHtml(slotElement);
+ expect(html).toContain("child content");
+ expect(html).toContain("modal content");
+ });
+
+ it("Slot returns null when the entry is absent", async () => {
+ const mod = await import("../packages/vinext/src/shims/slot.js");
+
+ const html = await renderHtml(
+ createContextProvider(
+ mod.ElementsContext,
+ Promise.resolve({}),
+ React.createElement(mod.Slot, { id: "slot:modal:/" }),
+ ),
+ );
+
+ expect(html).toBe("");
+ });
+
+ it("Slot throws the notFound signal for an unmatched slot sentinel", async () => {
+ const mod = await import("../packages/vinext/src/shims/slot.js");
+ const renderPromise = renderHtml(
+ createContextProvider(
+ mod.ElementsContext,
+ Promise.resolve({ "slot:modal:/": mod.UNMATCHED_SLOT }),
+ React.createElement(mod.Slot, { id: "slot:modal:/" }),
+ ),
+ );
+ const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
+
+ try {
+ await expect(renderPromise).rejects.toMatchObject({ digest: "NEXT_HTTP_ERROR_FALLBACK;404" });
+ } finally {
+ consoleError.mockRestore();
+ }
+ });
+
+ it("Slot renders a present null entry without triggering notFound", async () => {
+ const mod = await import("../packages/vinext/src/shims/slot.js");
+ const errors: Error[] = [];
+
+ const stream = await renderToReadableStream(
+ createContextProvider(
+ mod.ElementsContext,
+ Promise.resolve({ "slot:modal:/": null }),
+ React.createElement(mod.Slot, { id: "slot:modal:/" }),
+ ),
+ {
+ onError(error: unknown) {
+ if (error instanceof Error) {
+ errors.push(error);
+ }
+ },
+ },
+ );
+
+ await stream.allReady;
+ const html = await readStream(stream);
+
+ expect(html).toBe("");
+ expect(errors).toEqual([]);
+ });
+
+ it("mergeElementsPromise shallow-merges previous and next elements", async () => {
+ const { mergeElementsPromise } = await import("../packages/vinext/src/shims/slot.js");
+
+ const merged = await mergeElementsPromise(
+ Promise.resolve({
+ "layout:/": React.createElement("div", null, "layout"),
+ "slot:modal:/": React.createElement("div", null, "previous slot"),
+ }),
+ Promise.resolve({
+ "page:/blog/hello": React.createElement("div", null, "page"),
+ "slot:modal:/": React.createElement("div", null, "next slot"),
+ }),
+ );
+
+ expect(Object.keys(merged)).toEqual(["layout:/", "slot:modal:/", "page:/blog/hello"]);
+ expect(merged["layout:/"]).toBeDefined();
+ expect(merged["page:/blog/hello"]).toBeDefined();
+ expect(merged["slot:modal:/"]).not.toBeNull();
+ });
+
+ it("mergeElementsPromise caches by input promise pair", async () => {
+ const { mergeElementsPromise } = await import("../packages/vinext/src/shims/slot.js");
+
+ const previous = Promise.resolve({ "layout:/": React.createElement("div", null, "layout") });
+ const next = Promise.resolve({ "page:/blog/hello": React.createElement("div", null, "page") });
+
+ const first = mergeElementsPromise(previous, next);
+ const second = mergeElementsPromise(previous, next);
+ const third = mergeElementsPromise(previous, Promise.resolve({}));
+
+ expect(first).toBe(second);
+ expect(first).not.toBe(third);
+ });
+
+ it("Slot suspends on the elements promise and streams the Suspense fallback first", async () => {
+ const mod = await import("../packages/vinext/src/shims/slot.js");
+ const deferred = createDeferred>>();
+
+ const stream = await renderToReadableStream(
+ React.createElement(
+ Suspense,
+ { fallback: React.createElement("p", null, "loading slot") },
+ createContextProvider(
+ mod.ElementsContext,
+ deferred.promise,
+ React.createElement(mod.Slot, { id: "layout:/" }),
+ ),
+ ),
+ );
+
+ const reader = stream.getReader();
+ const decoder = new TextDecoder();
+ const firstChunkPromise = reader.read();
+ const firstReadState = await Promise.race([
+ firstChunkPromise.then(() => "resolved"),
+ Promise.resolve("pending"),
+ ]);
+
+ expect(firstReadState).toBe("pending");
+
+ const resolvedPromise = new Promise((resolve) => {
+ setTimeout(() => {
+ deferred.resolve({
+ "layout:/": React.createElement("div", null, "resolved slot"),
+ });
+ resolve();
+ }, 20);
+ });
+
+ const firstChunk = await firstChunkPromise;
+ const firstHtml = decoder.decode(firstChunk.value, { stream: true });
+ await resolvedPromise;
+
+ let rest = "";
+ for (;;) {
+ const { done, value } = await reader.read();
+ if (done) {
+ break;
+ }
+ rest += decoder.decode(value, { stream: true });
+ }
+ rest += decoder.decode();
+
+ expect(firstHtml + rest).toContain("resolved slot");
+ }, 10000);
+});
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 03/19] 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 04/19] 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 05/19] 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 d488978d6505d079f8cbc05793561a1625fa8e28 Mon Sep 17 00:00:00 2001
From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com>
Date: Thu, 2 Apr 2026 13:55:20 +1100
Subject: [PATCH 06/19] Implement flat App Router payload for layout
persistence
---
packages/vinext/src/entries/app-rsc-entry.ts | 37 ++-
packages/vinext/src/routing/app-router.ts | 5 +
.../vinext/src/server/app-browser-entry.ts | 273 ++++++++++------
.../vinext/src/server/app-browser-state.ts | 124 +++++++
packages/vinext/src/server/app-elements.ts | 46 +++
.../src/server/app-page-boundary-render.ts | 63 +++-
.../src/server/app-page-route-wiring.tsx | 309 ++++++++++++++++++
packages/vinext/src/server/app-ssr-entry.ts | 23 +-
packages/vinext/src/shims/slot.tsx | 20 +-
.../entry-templates.test.ts.snap | 240 +++++++++++---
tests/app-browser-entry.test.ts | 163 +++++++++
tests/app-elements.test.ts | 68 ++++
tests/app-page-boundary-render.test.ts | 81 ++++-
tests/app-page-route-wiring.test.ts | 53 +++
tests/app-router.test.ts | 18 +
tests/entry-templates.test.ts | 4 +
tests/slot.test.ts | 14 +
17 files changed, 1373 insertions(+), 168 deletions(-)
create mode 100644 packages/vinext/src/server/app-browser-state.ts
create mode 100644 packages/vinext/src/server/app-elements.ts
create mode 100644 tests/app-browser-entry.test.ts
create mode 100644 tests/app-elements.test.ts
diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts
index 52b21e0ba..fe18feddf 100644
--- a/packages/vinext/src/entries/app-rsc-entry.ts
+++ b/packages/vinext/src/entries/app-rsc-entry.ts
@@ -210,6 +210,7 @@ ${interceptEntries.join(",\n")}
routeHandler: ${route.routePath ? getImportVar(route.routePath) : "null"},
layouts: [${layoutVars.join(", ")}],
routeSegments: ${JSON.stringify(route.routeSegments)},
+ templateTreePositions: ${JSON.stringify(route.templateTreePositions)},
layoutTreePositions: ${JSON.stringify(route.layoutTreePositions)},
templates: [${templateVars.join(", ")}],
errors: [${layoutErrorVars.join(", ")}],
@@ -378,7 +379,7 @@ import {
renderAppPageHttpAccessFallback as __renderAppPageHttpAccessFallback,
} from ${JSON.stringify(appPageBoundaryRenderPath)};
import {
- buildAppPageRouteElement as __buildAppPageRouteElement,
+ buildAppPageElements as __buildAppPageElements,
resolveAppPageChildSegments as __resolveAppPageChildSegments,
} from ${JSON.stringify(appPageRouteWiringPath)};
import {
@@ -881,7 +882,7 @@ function findIntercept(pathname) {
return null;
}
-async function buildPageElement(route, params, opts, searchParams) {
+async function buildPageElements(route, params, routePath, opts, searchParams) {
const PageComponent = route.page?.default;
if (!PageComponent) {
return createElement("div", null, "Page has no default export");
@@ -982,13 +983,13 @@ async function buildPageElement(route, params, opts, searchParams) {
// dynamic, and this avoids false positives from React internals.
if (hasSearchParams) markDynamicUsage();
}
- return __buildAppPageRouteElement({
+ return __buildAppPageElements({
element: createElement(PageComponent, pageProps),
- globalErrorModule: ${globalErrorVar ? globalErrorVar : "null"},
makeThenableParams,
matchedParams: params,
resolvedMetadata,
resolvedViewport,
+ routePath,
rootNotFoundModule: ${rootNotFoundVar ? rootNotFoundVar : "null"},
route,
slotOverrides:
@@ -1701,7 +1702,13 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
searchParams: url.searchParams,
params: actionParams,
});
- element = buildPageElement(actionRoute, actionParams, undefined, url.searchParams);
+ element = buildPageElements(
+ actionRoute,
+ actionParams,
+ cleanPathname,
+ undefined,
+ url.searchParams,
+ );
} else {
element = createElement("div", null, "Page not found");
}
@@ -2055,7 +2062,13 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
return _runWithUnifiedCtx(__revalUCtx, async () => {
_ensureFetchPatch();
setNavigationContext({ pathname: cleanPathname, searchParams: new URLSearchParams(), params });
- const __revalElement = await buildPageElement(route, params, undefined, new URLSearchParams());
+ const __revalElement = await buildPageElements(
+ route,
+ params,
+ cleanPathname,
+ undefined,
+ new URLSearchParams(),
+ );
const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern);
const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError });
const __revalRscCapture = __teeAppPageRscStreamForCapture(__revalRscStream, true);
@@ -2104,7 +2117,15 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
// If the target URL matches an intercepting route in a parallel slot,
// render the source route with the intercepting page in the slot.
const __interceptResult = await __resolveAppPageIntercept({
- buildPageElement,
+ buildPageElement(interceptRoute, interceptParams, interceptOpts, interceptSearchParams) {
+ return buildPageElements(
+ interceptRoute,
+ interceptParams,
+ cleanPathname,
+ interceptOpts,
+ interceptSearchParams,
+ );
+ },
cleanPathname,
currentRoute: route,
findIntercept,
@@ -2152,7 +2173,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
const __pageBuildResult = await __buildAppPageElement({
buildPageElement() {
- return buildPageElement(route, params, interceptOpts, url.searchParams);
+ return buildPageElements(route, params, cleanPathname, interceptOpts, url.searchParams);
},
renderErrorBoundaryPage(buildErr) {
return renderErrorBoundaryPage(route, buildErr, isRscRequest, request, params);
diff --git a/packages/vinext/src/routing/app-router.ts b/packages/vinext/src/routing/app-router.ts
index c9142a36a..7e85341fe 100644
--- a/packages/vinext/src/routing/app-router.ts
+++ b/packages/vinext/src/routing/app-router.ts
@@ -104,6 +104,8 @@ export type AppRoute = {
* Used at render time to compute the child segments for useSelectedLayoutSegments().
*/
routeSegments: string[];
+ /** Tree position (directory depth from app/ root) for each template. */
+ templateTreePositions?: number[];
/**
* Tree position (directory depth from app/ root) for each layout.
* Used to slice routeSegments and determine which segments are below each layout.
@@ -327,6 +329,7 @@ function discoverSlotSubRoutes(
forbiddenPath: parentRoute.forbiddenPath,
unauthorizedPath: parentRoute.unauthorizedPath,
routeSegments: [...parentRoute.routeSegments, ...rawSegments],
+ templateTreePositions: parentRoute.templateTreePositions,
layoutTreePositions: parentRoute.layoutTreePositions,
isDynamic: parentRoute.isDynamic || subIsDynamic,
params: [...parentRoute.params, ...subParams],
@@ -405,6 +408,7 @@ function fileToAppRoute(
// Discover layouts and templates from root to leaf
const layouts = discoverLayouts(segments, appDir, matcher);
const templates = discoverTemplates(segments, appDir, matcher);
+ const templateTreePositions = computeLayoutTreePositions(appDir, templates);
// Compute the tree position (directory depth) for each layout.
const layoutTreePositions = computeLayoutTreePositions(appDir, layouts);
@@ -449,6 +453,7 @@ function fileToAppRoute(
forbiddenPath,
unauthorizedPath,
routeSegments: segments,
+ templateTreePositions,
layoutTreePositions,
isDynamic,
params,
diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts
index dd74e35e8..49c3bb15f 100644
--- a/packages/vinext/src/server/app-browser-entry.ts
+++ b/packages/vinext/src/server/app-browser-entry.ts
@@ -5,10 +5,9 @@ import {
startTransition,
use,
useLayoutEffect,
- useState,
+ useReducer,
type Dispatch,
type ReactNode,
- type SetStateAction,
} from "react";
import {
createFromFetch,
@@ -46,22 +45,31 @@ import {
createProgressiveRscStream,
getVinextBrowserGlobal,
} from "./app-browser-stream.js";
+import {
+ normalizeAppElements,
+ readAppElementsMetadata,
+ type AppElements,
+ type AppWireElements,
+} from "./app-elements.js";
+import {
+ createPendingNavigationCommit,
+ routerReducer,
+ shouldHardNavigate,
+ type AppRouterAction,
+ type AppRouterState,
+} from "./app-browser-state.js";
+import { ElementsContext, Slot } from "../shims/slot.js";
type SearchParamInput = ConstructorParameters[0];
type ServerActionResult = {
- root: ReactNode;
+ root: AppWireElements;
returnValue?: {
ok: boolean;
data: unknown;
};
};
-type BrowserTreeState = {
- renderId: number;
- node: ReactNode;
- navigationSnapshot: ClientNavigationRenderSnapshot;
-};
type NavigationKind = "navigate" | "traverse" | "refresh";
type HistoryUpdateMode = "push" | "replace";
type VisitedResponseCacheEntry = {
@@ -89,7 +97,8 @@ let nextNavigationRenderId = 0;
let activeNavigationId = 0;
const pendingNavigationCommits = new Map void>();
const pendingNavigationPrePaintEffects = new Map void>();
-let setBrowserTreeState: Dispatch> | null = null;
+let dispatchBrowserRouterAction: Dispatch | null = null;
+let readBrowserRouterState: (() => AppRouterState) | null = null;
let latestClientParams: Record = {};
const visitedResponseCache = new Map();
@@ -97,11 +106,18 @@ function isServerActionResult(value: unknown): value is ServerActionResult {
return !!value && typeof value === "object" && "root" in value;
}
-function getBrowserTreeStateSetter(): Dispatch> {
- if (!setBrowserTreeState) {
- throw new Error("[vinext] Browser tree state is not initialized");
+function getBrowserRouterDispatch(): Dispatch {
+ if (!dispatchBrowserRouterAction) {
+ throw new Error("[vinext] Browser router dispatch is not initialized");
+ }
+ return dispatchBrowserRouterAction;
+}
+
+function getBrowserRouterState(): AppRouterState {
+ if (!readBrowserRouterState) {
+ throw new Error("[vinext] Browser router state is not initialized");
}
- return setBrowserTreeState;
+ return readBrowserRouterState();
}
function applyClientParams(params: Record): void {
@@ -171,9 +187,11 @@ function drainPrePaintEffects(upToRenderId: number): void {
function createNavigationCommitEffect(
href: string,
historyUpdateMode: HistoryUpdateMode | undefined,
+ params: Record,
): () => void {
return () => {
const targetHref = new URL(href, window.location.origin).href;
+ stageClientParams(params);
if (historyUpdateMode === "replace" && window.location.href !== targetHref) {
replaceHistoryStateWithoutNotify(null, "", href);
@@ -286,34 +304,46 @@ function NavigationCommitSignal({
return children;
}
+function normalizeAppElementsPromise(payload: Promise): Promise {
+ return payload.then((elements) => normalizeAppElements(elements));
+}
+
function BrowserRoot({
- initialNode,
+ initialElements,
initialNavigationSnapshot,
}: {
- initialNode: ReactNode | Promise;
+ initialElements: Promise;
initialNavigationSnapshot: ClientNavigationRenderSnapshot;
}) {
- const resolvedNode = use(initialNode as Promise);
- const [treeState, setTreeState] = useState({
- renderId: 0,
- node: resolvedNode,
+ const resolvedElements = use(initialElements);
+ const initialMetadata = readAppElementsMetadata(resolvedElements);
+ const [treeState, dispatchTreeState] = useReducer(routerReducer, {
+ elements: Promise.resolve(resolvedElements),
navigationSnapshot: initialNavigationSnapshot,
+ renderId: 0,
+ rootLayoutTreePath: initialMetadata.rootLayoutTreePath,
+ routeId: initialMetadata.routeId,
});
// Assign the module-level setter via useLayoutEffect instead of during render
// to avoid side effects that React Strict Mode / concurrent features may
// call multiple times. useLayoutEffect fires synchronously during commit,
- // before hydrateRoot returns to main(), so setBrowserTreeState is available
- // before __VINEXT_RSC_NAVIGATE__ is assigned. setTreeState is referentially
+ // before hydrateRoot returns to main(), so the router dispatch is available
+ // before __VINEXT_RSC_NAVIGATE__ is assigned. dispatchTreeState is referentially
// stable so the effect only runs on mount.
useLayoutEffect(() => {
- setBrowserTreeState = setTreeState;
- }, []); // eslint-disable-line react-hooks/exhaustive-deps -- setTreeState is referentially stable
+ dispatchBrowserRouterAction = dispatchTreeState;
+ readBrowserRouterState = () => treeState;
+ }, [dispatchTreeState, treeState]);
const committedTree = createElement(
NavigationCommitSignal,
{ renderId: treeState.renderId },
- treeState.node,
+ createElement(
+ ElementsContext.Provider,
+ { value: treeState.elements },
+ createElement(Slot, { id: treeState.routeId }),
+ ),
);
const ClientNavigationRenderContext = getClientNavigationRenderContext();
@@ -328,18 +358,17 @@ function BrowserRoot({
);
}
-function updateBrowserTree(
- node: ReactNode | Promise,
+function dispatchBrowserTree(
+ elements: Promise,
navigationSnapshot: ClientNavigationRenderSnapshot,
renderId: number,
+ actionType: "navigate" | "replace",
+ routeId: string,
+ rootLayoutTreePath: string | null,
useTransitionMode: boolean,
snapshotActivated = false,
): void {
- const setter = getBrowserTreeStateSetter();
-
- const resolvedThenSet = (resolvedNode: ReactNode) => {
- setter({ renderId, node: resolvedNode, navigationSnapshot });
- };
+ const dispatch = getBrowserRouterDispatch();
// Balance the activate/commit pairing if the async payload rejects after
// activateNavigationSnapshot() was called. Only decrement when snapshotActivated
@@ -356,47 +385,68 @@ function updateBrowserTree(
resolve?.();
};
- if (node != null && typeof (node as PromiseLike).then === "function") {
- const thenable = node as PromiseLike;
+ const applyAction = () =>
+ dispatch({
+ elements,
+ navigationSnapshot,
+ renderId,
+ rootLayoutTreePath,
+ routeId,
+ type: actionType,
+ });
+
+ void elements.then(() => {
if (useTransitionMode) {
- void thenable.then(
- (resolved) => startTransition(() => resolvedThenSet(resolved)),
- handleAsyncError,
- );
+ startTransition(applyAction);
} else {
- void thenable.then(resolvedThenSet, handleAsyncError);
+ applyAction();
}
- return;
- }
-
- const syncNode = node as ReactNode;
- if (useTransitionMode) {
- startTransition(() => resolvedThenSet(syncNode));
- return;
- }
-
- resolvedThenSet(syncNode);
+ }, handleAsyncError);
}
-function renderNavigationPayload(
- payload: Promise | ReactNode,
+async function renderNavigationPayload(
+ payload: Promise,
navigationSnapshot: ClientNavigationRenderSnapshot,
+ targetHref: string,
prePaintEffect: (() => void) | null = null,
useTransition = true,
+ actionType: "navigate" | "replace" = "navigate",
): Promise {
const renderId = ++nextNavigationRenderId;
- queuePrePaintNavigationEffect(renderId, prePaintEffect);
-
const committed = new Promise((resolve) => {
pendingNavigationCommits.set(renderId, resolve);
});
- activateNavigationSnapshot();
-
// Wrap updateBrowserTree in try-catch to ensure counter is decremented
// if a synchronous error occurs before the async promise chain is established.
try {
- updateBrowserTree(payload, navigationSnapshot, renderId, useTransition, true);
+ const currentState = getBrowserRouterState();
+ const pending = await createPendingNavigationCommit({
+ currentState,
+ nextElements: payload,
+ navigationSnapshot,
+ renderId,
+ type: actionType,
+ });
+
+ if (shouldHardNavigate(currentState.rootLayoutTreePath, pending.rootLayoutTreePath)) {
+ pendingNavigationCommits.delete(renderId);
+ window.location.assign(targetHref);
+ return;
+ }
+
+ queuePrePaintNavigationEffect(renderId, prePaintEffect);
+ activateNavigationSnapshot();
+ dispatchBrowserTree(
+ pending.action.elements,
+ navigationSnapshot,
+ renderId,
+ actionType,
+ pending.routeId,
+ pending.rootLayoutTreePath,
+ useTransition,
+ true,
+ );
} catch (error) {
// Clean up pending state and decrement counter on synchronous error.
pendingNavigationPrePaintEffects.delete(renderId);
@@ -534,7 +584,7 @@ function registerServerActionCallback(): void {
clearClientNavigationCaches();
- const result = await createFromFetch(
+ const result = await createFromFetch(
Promise.resolve(fetchResponse),
{ temporaryReferences },
);
@@ -548,10 +598,24 @@ function registerServerActionCallback(): void {
// If server actions ever trigger URL changes via RSC payload (instead of hard
// redirects), this would need renderNavigationPayload() + snapshotActivated=true.
if (isServerActionResult(result)) {
- updateBrowserTree(
- result.root,
- createClientNavigationRenderSnapshot(window.location.href, latestClientParams),
- ++nextNavigationRenderId,
+ const navigationSnapshot = createClientNavigationRenderSnapshot(
+ window.location.href,
+ latestClientParams,
+ );
+ const pending = await createPendingNavigationCommit({
+ currentState: getBrowserRouterState(),
+ nextElements: Promise.resolve(normalizeAppElements(result.root)),
+ navigationSnapshot,
+ renderId: ++nextNavigationRenderId,
+ type: "navigate",
+ });
+ dispatchBrowserTree(
+ pending.action.elements,
+ navigationSnapshot,
+ pending.action.renderId,
+ "navigate",
+ pending.routeId,
+ pending.rootLayoutTreePath,
false,
);
if (result.returnValue) {
@@ -561,11 +625,24 @@ function registerServerActionCallback(): void {
return undefined;
}
- // Same reasoning as above: snapshotActivated omitted intentionally.
- updateBrowserTree(
- result,
- createClientNavigationRenderSnapshot(window.location.href, latestClientParams),
- ++nextNavigationRenderId,
+ const navigationSnapshot = createClientNavigationRenderSnapshot(
+ window.location.href,
+ latestClientParams,
+ );
+ const pending = await createPendingNavigationCommit({
+ currentState: getBrowserRouterState(),
+ nextElements: Promise.resolve(normalizeAppElements(result)),
+ navigationSnapshot,
+ renderId: ++nextNavigationRenderId,
+ type: "navigate",
+ });
+ dispatchBrowserTree(
+ pending.action.elements,
+ navigationSnapshot,
+ pending.action.renderId,
+ "navigate",
+ pending.routeId,
+ pending.rootLayoutTreePath,
false,
);
return result;
@@ -576,7 +653,7 @@ async function main(): Promise {
registerServerActionCallback();
const rscStream = await readInitialRscStream();
- const root = createFromReadableStream(rscStream);
+ const root = normalizeAppElementsPromise(createFromReadableStream(rscStream));
const initialNavigationSnapshot = createClientNavigationRenderSnapshot(
window.location.href,
latestClientParams,
@@ -585,7 +662,7 @@ async function main(): Promise {
window.__VINEXT_RSC_ROOT__ = hydrateRoot(
document,
createElement(BrowserRoot, {
- initialNode: root,
+ initialElements: root,
initialNavigationSnapshot,
}),
import.meta.env.DEV ? { onCaughtError() {} } : undefined,
@@ -625,8 +702,6 @@ async function main(): Promise {
stripBasePath(url.pathname, __basePath) ===
stripBasePath(window.location.pathname, __basePath);
const cachedRoute = getVisitedResponse(rscUrl, navigationKind);
- const navigationCommitEffect = createNavigationCommitEffect(href, historyUpdateMode);
-
if (cachedRoute) {
// Check stale-navigation before and after createFromFetch. The pre-check
// avoids wasted parse work; the post-check catches supersessions that
@@ -642,23 +717,19 @@ async function main(): Promise {
// wrapping only) — no stale-navigation recheck needed between here and the
// next await.
const cachedNavigationSnapshot = createClientNavigationRenderSnapshot(href, cachedParams);
- const cachedPayload = await createFromFetch(
- Promise.resolve(restoreRscResponse(cachedRoute.response)),
+ const cachedPayload = normalizeAppElementsPromise(
+ createFromFetch(
+ Promise.resolve(restoreRscResponse(cachedRoute.response)),
+ ),
);
if (navId !== activeNavigationId) return;
- // Stage params only after confirming this navigation hasn't been superseded.
- // Set _snapshotPending before stageClientParams: if renderNavigationPayload
- // throws synchronously, its inner catch calls commitClientNavigationState()
- // which would flush pendingClientParams for a route that never rendered.
- // Ordering _snapshotPending first makes the intent explicit — params are
- // staged as part of an in-flight snapshot, not as a standalone side-effect.
_snapshotPending = true; // Set before renderNavigationPayload
- stageClientParams(cachedParams); // NB: if this throws, outer catch hard-navigates, resetting all JS state
try {
await renderNavigationPayload(
cachedPayload,
cachedNavigationSnapshot,
- navigationCommitEffect,
+ href,
+ createNavigationCommitEffect(href, historyUpdateMode, cachedParams),
isSameRoute,
);
} finally {
@@ -726,23 +797,19 @@ async function main(): Promise {
if (navId !== activeNavigationId) return;
- const rscPayload = await createFromFetch(
- Promise.resolve(restoreRscResponse(responseSnapshot)),
+ const rscPayload = normalizeAppElementsPromise(
+ createFromFetch(Promise.resolve(restoreRscResponse(responseSnapshot))),
);
if (navId !== activeNavigationId) return;
- // Stage params only after confirming this navigation hasn't been superseded
- // (avoids stale cache entries). Set _snapshotPending before stageClientParams
- // for the same reason as the cached path above: ensures params are only staged
- // as part of an in-flight snapshot.
_snapshotPending = true; // Set before renderNavigationPayload
- stageClientParams(navParams); // NB: if this throws, outer catch hard-navigates, resetting all JS state
try {
await renderNavigationPayload(
rscPayload,
navigationSnapshot,
- navigationCommitEffect,
+ href,
+ createNavigationCommitEffect(href, historyUpdateMode, navParams),
isSameRoute,
);
} finally {
@@ -801,14 +868,28 @@ async function main(): Promise {
import.meta.hot.on("rsc:update", async () => {
try {
clearClientNavigationCaches();
- const rscPayload = await createFromFetch(
- fetch(toRscUrl(window.location.pathname + window.location.search)),
+ const navigationSnapshot = createClientNavigationRenderSnapshot(
+ window.location.href,
+ latestClientParams,
);
- // HMR updates skip renderNavigationPayload — no snapshot activated.
- updateBrowserTree(
- rscPayload,
- createClientNavigationRenderSnapshot(window.location.href, latestClientParams),
- ++nextNavigationRenderId,
+ const pending = await createPendingNavigationCommit({
+ currentState: getBrowserRouterState(),
+ nextElements: normalizeAppElementsPromise(
+ createFromFetch(
+ fetch(toRscUrl(window.location.pathname + window.location.search)),
+ ),
+ ),
+ navigationSnapshot,
+ renderId: ++nextNavigationRenderId,
+ type: "replace",
+ });
+ dispatchBrowserTree(
+ pending.action.elements,
+ navigationSnapshot,
+ pending.action.renderId,
+ "replace",
+ pending.routeId,
+ pending.rootLayoutTreePath,
false,
);
} catch (error) {
@@ -818,4 +899,6 @@ async function main(): Promise {
}
}
-void main();
+if (typeof document !== "undefined") {
+ void main();
+}
diff --git a/packages/vinext/src/server/app-browser-state.ts b/packages/vinext/src/server/app-browser-state.ts
new file mode 100644
index 000000000..10ea50649
--- /dev/null
+++ b/packages/vinext/src/server/app-browser-state.ts
@@ -0,0 +1,124 @@
+import type { ReactNode } from "react";
+import { mergeElementsPromise } from "../shims/slot.js";
+import { readAppElementsMetadata, type AppElements } from "./app-elements.js";
+import type { ClientNavigationRenderSnapshot } from "../shims/navigation.js";
+
+export type AppRouterState = {
+ elements: Promise;
+ renderId: number;
+ navigationSnapshot: ClientNavigationRenderSnapshot;
+ rootLayoutTreePath: string | null;
+ routeId: string;
+};
+
+export type AppRouterAction = {
+ elements: Promise;
+ navigationSnapshot: ClientNavigationRenderSnapshot;
+ renderId: number;
+ rootLayoutTreePath: string | null;
+ routeId: string;
+ type: "navigate" | "replace";
+};
+
+export type PendingNavigationCommit = {
+ action: AppRouterAction;
+ rootLayoutTreePath: string | null;
+ routeId: string;
+};
+
+export function routerReducer(state: AppRouterState, action: AppRouterAction): AppRouterState {
+ switch (action.type) {
+ case "navigate":
+ return {
+ elements: mergeElementsPromise(state.elements, action.elements),
+ navigationSnapshot: action.navigationSnapshot,
+ renderId: action.renderId,
+ rootLayoutTreePath: action.rootLayoutTreePath,
+ routeId: action.routeId,
+ };
+ case "replace":
+ return {
+ elements: action.elements,
+ navigationSnapshot: action.navigationSnapshot,
+ renderId: action.renderId,
+ rootLayoutTreePath: action.rootLayoutTreePath,
+ routeId: action.routeId,
+ };
+ }
+}
+
+export function shouldHardNavigate(
+ currentRootLayoutTreePath: string | null,
+ nextRootLayoutTreePath: string | null,
+): boolean {
+ return (
+ currentRootLayoutTreePath !== null &&
+ nextRootLayoutTreePath !== null &&
+ currentRootLayoutTreePath !== nextRootLayoutTreePath
+ );
+}
+
+export async function createPendingNavigationCommit(options: {
+ currentState: AppRouterState;
+ nextElements: Promise;
+ navigationSnapshot: ClientNavigationRenderSnapshot;
+ renderId?: number;
+ type: "navigate" | "replace";
+}): Promise {
+ const elements = await options.nextElements;
+ const metadata = readAppElementsMetadata(elements);
+
+ return {
+ action: {
+ elements: Promise.resolve(elements),
+ navigationSnapshot: options.navigationSnapshot,
+ renderId: options.renderId ?? options.currentState.renderId + 1,
+ rootLayoutTreePath: metadata.rootLayoutTreePath,
+ routeId: metadata.routeId,
+ type: options.type,
+ },
+ rootLayoutTreePath: metadata.rootLayoutTreePath,
+ routeId: metadata.routeId,
+ };
+}
+
+export async function applyAppRouterStateUpdate(options: {
+ commit: () => void;
+ currentState: AppRouterState;
+ dispatch: (action: AppRouterAction) => void;
+ nextElements: Promise;
+ navigationSnapshot?: ClientNavigationRenderSnapshot;
+ onHardNavigate: (href: string) => void;
+ targetHref: string;
+ transition: (callback: () => void) => void;
+ type?: "navigate" | "replace";
+}): Promise<{ type: "dispatched" | "hard-navigate" }> {
+ const pending = await createPendingNavigationCommit({
+ currentState: options.currentState,
+ nextElements: options.nextElements,
+ navigationSnapshot: options.navigationSnapshot ?? options.currentState.navigationSnapshot,
+ type: options.type ?? "navigate",
+ });
+
+ if (shouldHardNavigate(options.currentState.rootLayoutTreePath, pending.rootLayoutTreePath)) {
+ options.onHardNavigate(options.targetHref);
+ return { type: "hard-navigate" };
+ }
+
+ options.transition(() => {
+ options.commit();
+ options.dispatch(pending.action);
+ });
+
+ return { type: "dispatched" };
+}
+
+export function createRouteNodeSnapshot(
+ elements: Promise,
+ routeId: string,
+): { elements: Promise; routeId: string } {
+ return { elements, routeId };
+}
+
+export type AppRouteNodeSnapshot = ReturnType;
+export type AppRouteNodeValue = ReactNode;
diff --git a/packages/vinext/src/server/app-elements.ts b/packages/vinext/src/server/app-elements.ts
new file mode 100644
index 000000000..e04828971
--- /dev/null
+++ b/packages/vinext/src/server/app-elements.ts
@@ -0,0 +1,46 @@
+import type { ReactNode } from "react";
+
+export const APP_ROUTE_KEY = "__route";
+export const APP_ROOT_LAYOUT_KEY = "__rootLayout";
+export const APP_UNMATCHED_SLOT_WIRE_VALUE = "__VINEXT_UNMATCHED_SLOT__";
+
+export const UNMATCHED_SLOT = Symbol.for("vinext.unmatchedSlot");
+
+export type AppElementValue = ReactNode | typeof UNMATCHED_SLOT | string | null;
+export type AppWireElementValue = ReactNode | string | null;
+
+export type AppElements = Readonly>;
+export type AppWireElements = Readonly>;
+
+export type AppElementsMetadata = {
+ routeId: string;
+ rootLayoutTreePath: string | null;
+};
+
+export function normalizeAppElements(elements: AppWireElements): AppElements {
+ const normalized: Record = {};
+
+ for (const [key, value] of Object.entries(elements)) {
+ normalized[key] =
+ key.startsWith("slot:") && value === APP_UNMATCHED_SLOT_WIRE_VALUE ? UNMATCHED_SLOT : value;
+ }
+
+ return normalized;
+}
+
+export function readAppElementsMetadata(elements: AppElements): AppElementsMetadata {
+ const routeId = elements[APP_ROUTE_KEY];
+ if (typeof routeId !== "string") {
+ throw new Error("[vinext] Missing __route string in App Router payload");
+ }
+
+ const rootLayoutTreePath = elements[APP_ROOT_LAYOUT_KEY];
+ if (rootLayoutTreePath !== null && typeof rootLayoutTreePath !== "string") {
+ throw new Error("[vinext] Invalid __rootLayout in App Router payload");
+ }
+
+ return {
+ routeId,
+ rootLayoutTreePath,
+ };
+}
diff --git a/packages/vinext/src/server/app-page-boundary-render.ts b/packages/vinext/src/server/app-page-boundary-render.ts
index 1aca237a3..871ded9de 100644
--- a/packages/vinext/src/server/app-page-boundary-render.ts
+++ b/packages/vinext/src/server/app-page-boundary-render.ts
@@ -24,6 +24,8 @@ import {
renderAppPageHtmlResponse,
type AppPageSsrHandler,
} from "./app-page-stream.js";
+import { APP_ROOT_LAYOUT_KEY, APP_ROUTE_KEY, type AppElements } from "./app-elements.js";
+import { createAppPageLayoutEntries } from "./app-page-route-wiring.js";
// oxlint-disable-next-line @typescript-eslint/no-explicit-any
type AppPageComponent = ComponentType;
@@ -36,6 +38,13 @@ type AppPageBoundaryOnError = (
errorContext: unknown,
) => unknown;
+type AppPageBoundaryRscPayloadOptions = {
+ element: ReactNode;
+ layoutModules: readonly (TModule | null | undefined)[];
+ pathname: string;
+ route?: AppPageBoundaryRoute | null;
+};
+
export type AppPageBoundaryRoute = {
error?: TModule | null;
errors?: readonly (TModule | null | undefined)[] | null;
@@ -62,7 +71,7 @@ type AppPageBoundaryRenderCommonOptions Promise;
makeThenableParams: (params: AppPageParams) => unknown;
renderToReadableStream: (
- element: ReactNode,
+ element: ReactNode | AppElements,
options: { onError: AppPageBoundaryOnError },
) => ReadableStream;
requestUrl: string;
@@ -200,14 +209,60 @@ function wrapRenderedBoundaryElement(
});
}
+function resolveAppPageBoundaryRootLayoutTreePath(
+ route: AppPageBoundaryRoute | null | undefined,
+ layoutModules: readonly (TModule | null | undefined)[],
+): string | null {
+ if (route?.layouts) {
+ const rootLayoutEntry = createAppPageLayoutEntries({
+ errors: route.errors,
+ layoutTreePositions: route.layoutTreePositions,
+ layouts: route.layouts,
+ notFounds: null,
+ routeSegments: route.routeSegments,
+ })[0];
+
+ if (rootLayoutEntry) {
+ return rootLayoutEntry.treePath;
+ }
+ }
+
+ return layoutModules.length > 0 ? "/" : null;
+}
+
+function createAppPageBoundaryRscPayload(
+ options: AppPageBoundaryRscPayloadOptions,
+): AppElements {
+ const routeId = `route:${options.pathname}`;
+
+ return {
+ [APP_ROUTE_KEY]: routeId,
+ [APP_ROOT_LAYOUT_KEY]: resolveAppPageBoundaryRootLayoutTreePath(
+ options.route,
+ options.layoutModules,
+ ),
+ [routeId]: options.element,
+ };
+}
+
async function renderAppPageBoundaryElementResponse(
options: AppPageBoundaryRenderCommonOptions & {
element: ReactNode;
+ layoutModules: readonly (TModule | null | undefined)[];
+ route?: AppPageBoundaryRoute | null;
routePattern?: string;
status: number;
},
): Promise {
const pathname = new URL(options.requestUrl).pathname;
+ const payload = options.isRscRequest
+ ? createAppPageBoundaryRscPayload({
+ element: options.element,
+ layoutModules: options.layoutModules,
+ pathname,
+ route: options.route,
+ })
+ : options.element;
return renderAppPageBoundaryResponse({
async createHtmlResponse(rscStream, responseStatus) {
@@ -230,7 +285,7 @@ async function renderAppPageBoundaryElementResponse(
return renderAppPageBoundaryElementResponse({
...options,
element,
+ layoutModules,
+ route: options.route,
routePattern: options.route?.pattern,
status: 200,
});
diff --git a/packages/vinext/src/server/app-page-route-wiring.tsx b/packages/vinext/src/server/app-page-route-wiring.tsx
index 8936afd29..a605fe877 100644
--- a/packages/vinext/src/server/app-page-route-wiring.tsx
+++ b/packages/vinext/src/server/app-page-route-wiring.tsx
@@ -1,7 +1,14 @@
import { Suspense, type ComponentType, type ReactNode } from "react";
+import {
+ APP_ROOT_LAYOUT_KEY,
+ APP_ROUTE_KEY,
+ APP_UNMATCHED_SLOT_WIRE_VALUE,
+ type AppElements,
+} from "./app-elements.js";
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 { Children, ParallelSlot, Slot } from "../shims/slot.js";
import type { AppPageParams } from "./app-page-boundary.js";
type AppPageComponentProps = {
@@ -47,6 +54,7 @@ export type AppPageRouteWiringRoute<
notFounds?: readonly (TModule | null | undefined)[] | null;
routeSegments?: readonly string[];
slots?: Readonly>> | null;
+ templateTreePositions?: readonly number[] | null;
templates?: readonly (TModule | null | undefined)[] | null;
};
@@ -83,6 +91,20 @@ export type BuildAppPageRouteElementOptions<
slotOverrides?: Readonly>> | null;
};
+export type BuildAppPageElementsOptions<
+ TModule extends AppPageModule = AppPageModule,
+ TErrorModule extends AppPageErrorModule = AppPageErrorModule,
+> = Omit, "globalErrorModule"> & {
+ routePath: string;
+};
+
+type AppPageTemplateEntry = {
+ id: string;
+ templateModule?: TModule | null | undefined;
+ treePath: string;
+ treePosition: number;
+};
+
function getDefaultExport(
module: TModule | null | undefined,
): AppPageComponent | null {
@@ -129,6 +151,24 @@ export function createAppPageLayoutEntries<
});
}
+export function createAppPageTemplateEntries(
+ route: Pick<
+ AppPageRouteWiringRoute,
+ "routeSegments" | "templateTreePositions" | "templates"
+ >,
+): AppPageTemplateEntry[] {
+ return (route.templates ?? []).map((templateModule, index) => {
+ const treePosition = route.templateTreePositions?.[index] ?? 0;
+ const treePath = createAppPageTreePath(route.routeSegments, treePosition);
+ return {
+ id: `template:${treePath}`,
+ templateModule,
+ treePath,
+ treePosition,
+ };
+ });
+}
+
export function resolveAppPageChildSegments(
routeSegments: readonly string[],
treePosition: number,
@@ -181,10 +221,279 @@ export function resolveAppPageChildSegments(
return resolvedSegments;
}
+function resolveAppPageVisibleSegments(
+ routeSegments: readonly string[],
+ params: AppPageParams,
+): string[] {
+ const resolvedSegments = resolveAppPageChildSegments(routeSegments, 0, params);
+ return resolvedSegments.filter((segment) => !(segment.startsWith("(") && segment.endsWith(")")));
+}
+
+function resolveAppPageTemplateKey(
+ routeSegments: readonly string[],
+ treePosition: number,
+ params: AppPageParams,
+): string {
+ const visibleSegments = resolveAppPageVisibleSegments(routeSegments.slice(treePosition), params);
+ return visibleSegments[0] ?? "";
+}
+
+function createAppPageParallelSlotEntries<
+ TModule extends AppPageModule,
+ TErrorModule extends AppPageErrorModule,
+>(
+ layoutIndex: number,
+ layoutEntries: readonly AppPageLayoutEntry[],
+ route: AppPageRouteWiringRoute,
+): Readonly> | undefined {
+ const parallelSlots: Record = {};
+
+ for (const [slotName, slot] of Object.entries(route.slots ?? {})) {
+ const targetIndex = slot.layoutIndex >= 0 ? slot.layoutIndex : layoutEntries.length - 1;
+ if (targetIndex !== layoutIndex) {
+ continue;
+ }
+
+ const layoutEntry = layoutEntries[targetIndex];
+ const treePath = layoutEntry?.treePath ?? "/";
+ parallelSlots[slotName] = (
+
+
+
+ );
+ }
+
+ return Object.keys(parallelSlots).length > 0 ? parallelSlots : undefined;
+}
+
+function createAppPageRouteHead(metadata: Metadata | null, viewport: Viewport): ReactNode {
+ return (
+ <>
+
+ {metadata ? : null}
+
+ >
+ );
+}
+
+export function buildAppPageElements<
+ TModule extends AppPageModule,
+ TErrorModule extends AppPageErrorModule,
+>(options: BuildAppPageElementsOptions): AppElements {
+ const elements: Record = {};
+ const routeId = `route:${options.routePath}`;
+ const pageId = `page:${options.routePath}`;
+ const layoutEntries = createAppPageLayoutEntries(options.route);
+ const templateEntries = createAppPageTemplateEntries(options.route);
+ const routeThenableParams = options.makeThenableParams(options.matchedParams);
+ const rootLayoutTreePath = layoutEntries[0]?.treePath ?? null;
+
+ elements[APP_ROUTE_KEY] = routeId;
+ elements[APP_ROOT_LAYOUT_KEY] = rootLayoutTreePath;
+ elements[pageId] = options.element;
+
+ for (const templateEntry of templateEntries) {
+ const templateComponent = getDefaultExport(templateEntry.templateModule);
+ if (!templateComponent) {
+ continue;
+ }
+ const TemplateComponent = templateComponent;
+ elements[templateEntry.id] = (
+ {}
+ );
+ }
+
+ for (let index = 0; index < layoutEntries.length; index++) {
+ const layoutEntry = layoutEntries[index];
+ const layoutComponent = getDefaultExport(layoutEntry.layoutModule);
+ if (!layoutComponent) {
+ continue;
+ }
+
+ const layoutProps: Record = {
+ params: routeThenableParams,
+ };
+
+ for (const [slotName, slot] of Object.entries(options.route.slots ?? {})) {
+ const targetIndex = slot.layoutIndex >= 0 ? slot.layoutIndex : layoutEntries.length - 1;
+ if (targetIndex !== index) {
+ continue;
+ }
+ layoutProps[slotName] = ;
+ }
+
+ const LayoutComponent = layoutComponent;
+ elements[layoutEntry.id] = (
+
+
+
+ );
+ }
+
+ for (const [slotName, slot] of Object.entries(options.route.slots ?? {})) {
+ const targetIndex = slot.layoutIndex >= 0 ? slot.layoutIndex : layoutEntries.length - 1;
+ const treePath = layoutEntries[targetIndex]?.treePath ?? "/";
+ const slotId = `slot:${slotName}:${treePath}`;
+ const slotOverride = options.slotOverrides?.[slotName];
+ const slotParams = slotOverride?.params ?? options.matchedParams;
+ const slotComponent =
+ getDefaultExport(slotOverride?.pageModule) ??
+ getDefaultExport(slot.page) ??
+ getDefaultExport(slot.default);
+
+ if (!slotComponent) {
+ elements[slotId] = APP_UNMATCHED_SLOT_WIRE_VALUE;
+ 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 = getErrorBoundaryExport(slot.error);
+ if (slotErrorComponent) {
+ slotElement = {slotElement};
+ }
+
+ elements[slotId] = slotElement;
+ }
+
+ let routeChildren: ReactNode = (
+
+
+
+ );
+
+ const routeLoadingComponent = getDefaultExport(options.route.loading);
+ if (routeLoadingComponent) {
+ const RouteLoadingComponent = routeLoadingComponent;
+ routeChildren = }>{routeChildren};
+ }
+
+ const lastLayoutErrorModule =
+ options.route.errors && options.route.errors.length > 0
+ ? options.route.errors[options.route.errors.length - 1]
+ : null;
+ const pageErrorComponent = getErrorBoundaryExport(options.route.error);
+ if (pageErrorComponent && options.route.error !== lastLayoutErrorModule) {
+ routeChildren = {routeChildren};
+ }
+
+ const notFoundComponent =
+ getDefaultExport(options.route.notFound) ?? getDefaultExport(options.rootNotFoundModule);
+ if (notFoundComponent) {
+ const NotFoundComponent = notFoundComponent;
+ routeChildren = (
+ }>{routeChildren}
+ );
+ }
+
+ for (let index = layoutEntries.length - 1; index >= 0; index--) {
+ const layoutEntry = layoutEntries[index];
+ let layoutChildren = routeChildren;
+ const templateEntry = templateEntries.find(
+ (entry) => entry.treePosition === layoutEntry.treePosition,
+ );
+ if (templateEntry) {
+ layoutChildren = (
+
+ {layoutChildren}
+
+ );
+ }
+
+ const layoutErrorComponent = getErrorBoundaryExport(layoutEntry.errorModule);
+ if (layoutErrorComponent) {
+ layoutChildren = (
+ {layoutChildren}
+ );
+ }
+
+ const layoutNotFoundComponent = getDefaultExport(layoutEntry.notFoundModule);
+ if (layoutNotFoundComponent) {
+ const LayoutNotFoundComponent = layoutNotFoundComponent;
+ layoutChildren = (
+ }>{layoutChildren}
+ );
+ }
+
+ routeChildren = (
+ {
+ const targetIndex =
+ slot.layoutIndex >= 0 ? slot.layoutIndex : layoutEntries.length - 1;
+ return targetIndex === index;
+ })
+ .map(([slotName]) => [slotName, []]),
+ ),
+ }}
+ >
+
+ {layoutChildren}
+
+
+ );
+ }
+
+ elements[routeId] = (
+ <>
+ {createAppPageRouteHead(options.resolvedMetadata, options.resolvedViewport)}
+ {routeChildren}
+ >
+ );
+
+ return elements;
+}
+
export function buildAppPageRouteElement<
TModule extends AppPageModule,
TErrorModule extends AppPageErrorModule,
>(options: BuildAppPageRouteElementOptions): ReactNode {
+ /**
+ * @deprecated PR 2c introduces buildAppPageElements() for the flat payload
+ * cutover. Keep this helper during the transition so intermediate test runs
+ * remain stable, then delete it only after all call sites have switched.
+ */
let element: ReactNode = (
{options.element}
);
diff --git a/packages/vinext/src/server/app-ssr-entry.ts b/packages/vinext/src/server/app-ssr-entry.ts
index 32d754c47..a9a37e0e9 100644
--- a/packages/vinext/src/server/app-ssr-entry.ts
+++ b/packages/vinext/src/server/app-ssr-entry.ts
@@ -1,7 +1,7 @@
///
import type { ReactNode } from "react";
-import { Fragment, createElement as createReactElement } from "react";
+import { Fragment, createElement as createReactElement, use } from "react";
import { createFromReadableStream } from "@vitejs/plugin-rsc/ssr";
import { renderToReadableStream, renderToStaticMarkup } from "react-dom/server.edge";
import * as clientReferences from "virtual:vite-rsc/client-references";
@@ -16,6 +16,13 @@ import {
import { runWithNavigationContext } from "../shims/navigation-state.js";
import { safeJsonStringify } from "./html.js";
import { createRscEmbedTransform, createTickBufferedTransform } from "./app-ssr-stream.js";
+import {
+ normalizeAppElements,
+ readAppElementsMetadata,
+ type AppElements,
+ type AppWireElements,
+} from "./app-elements.js";
+import { ElementsContext, Slot } from "../shims/slot.js";
export type FontPreload = {
href: string;
@@ -167,13 +174,21 @@ export async function handleSsr(
const [ssrStream, embedStream] = rscStream.tee();
const rscEmbed = createRscEmbedTransform(embedStream);
- let flightRoot: Promise | null = null;
+ let flightRoot: Promise | null = null;
function VinextFlightRoot(): ReactNode {
if (!flightRoot) {
- flightRoot = createFromReadableStream(ssrStream);
+ flightRoot = createFromReadableStream(ssrStream).then((elements) =>
+ normalizeAppElements(elements),
+ );
}
- return flightRoot as unknown as ReactNode;
+ const elements = use(flightRoot);
+ const metadata = readAppElementsMetadata(elements);
+ return createReactElement(
+ ElementsContext.Provider,
+ { value: Promise.resolve(elements) },
+ createReactElement(Slot, { id: metadata.routeId }),
+ );
}
const root = createReactElement(VinextFlightRoot);
diff --git a/packages/vinext/src/shims/slot.tsx b/packages/vinext/src/shims/slot.tsx
index 19de9c528..d5c4c6365 100644
--- a/packages/vinext/src/shims/slot.tsx
+++ b/packages/vinext/src/shims/slot.tsx
@@ -1,16 +1,18 @@
"use client";
import * as React from "react";
+import { UNMATCHED_SLOT, type AppElements } from "../server/app-elements.js";
import { notFound } from "./navigation.js";
-type Elements = Record;
+const EMPTY_ELEMENTS_PROMISE = Promise.resolve({});
+const mergeCache = new WeakMap<
+ Promise,
+ WeakMap, Promise>
+>();
-const EMPTY_ELEMENTS_PROMISE = Promise.resolve({});
-const mergeCache = new WeakMap, WeakMap, Promise>>();
+export { UNMATCHED_SLOT };
-export const UNMATCHED_SLOT = Symbol.for("vinext.unmatchedSlot");
-
-export const ElementsContext = React.createContext>(EMPTY_ELEMENTS_PROMISE);
+export const ElementsContext = React.createContext>(EMPTY_ELEMENTS_PROMISE);
export const ChildrenContext = React.createContext(null);
@@ -19,9 +21,9 @@ export const ParallelSlotsContext = React.createContext | null>(null);
export function mergeElementsPromise(
- prev: Promise,
- next: Promise,
-): Promise {
+ prev: Promise,
+ next: Promise,
+): Promise {
let nextCache = mergeCache.get(prev);
if (!nextCache) {
nextCache = new WeakMap();
diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap
index 96989ffff..fa31701cc 100644
--- a/tests/__snapshots__/entry-templates.test.ts.snap
+++ b/tests/__snapshots__/entry-templates.test.ts.snap
@@ -78,7 +78,7 @@ import {
renderAppPageHttpAccessFallback as __renderAppPageHttpAccessFallback,
} from "/packages/vinext/src/server/app-page-boundary-render.js";
import {
- buildAppPageRouteElement as __buildAppPageRouteElement,
+ buildAppPageElements as __buildAppPageElements,
resolveAppPageChildSegments as __resolveAppPageChildSegments,
} from "/packages/vinext/src/server/app-page-route-wiring.js";
import {
@@ -392,6 +392,7 @@ const routes = [
routeHandler: null,
layouts: [mod_1],
routeSegments: [],
+ templateTreePositions: [],
layoutTreePositions: [0],
templates: [],
errors: [null],
@@ -414,6 +415,7 @@ const routes = [
routeHandler: null,
layouts: [mod_1],
routeSegments: ["about"],
+ templateTreePositions: [],
layoutTreePositions: [0],
templates: [],
errors: [null],
@@ -436,6 +438,7 @@ const routes = [
routeHandler: null,
layouts: [mod_1, mod_4],
routeSegments: ["blog",":slug"],
+ templateTreePositions: [],
layoutTreePositions: [0,1],
templates: [],
errors: [null, null],
@@ -458,6 +461,7 @@ const routes = [
routeHandler: null,
layouts: [mod_1, mod_6],
routeSegments: ["dashboard"],
+ templateTreePositions: [1],
layoutTreePositions: [0,1],
templates: [mod_7],
errors: [null, mod_9],
@@ -644,7 +648,7 @@ function findIntercept(pathname) {
return null;
}
-async function buildPageElement(route, params, opts, searchParams) {
+async function buildPageElements(route, params, routePath, opts, searchParams) {
const PageComponent = route.page?.default;
if (!PageComponent) {
return createElement("div", null, "Page has no default export");
@@ -745,13 +749,13 @@ async function buildPageElement(route, params, opts, searchParams) {
// dynamic, and this avoids false positives from React internals.
if (hasSearchParams) markDynamicUsage();
}
- return __buildAppPageRouteElement({
+ return __buildAppPageElements({
element: createElement(PageComponent, pageProps),
- globalErrorModule: null,
makeThenableParams,
matchedParams: params,
resolvedMetadata,
resolvedViewport,
+ routePath,
rootNotFoundModule: null,
route,
slotOverrides:
@@ -1435,7 +1439,13 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
searchParams: url.searchParams,
params: actionParams,
});
- element = buildPageElement(actionRoute, actionParams, undefined, url.searchParams);
+ element = buildPageElements(
+ actionRoute,
+ actionParams,
+ cleanPathname,
+ undefined,
+ url.searchParams,
+ );
} else {
element = createElement("div", null, "Page not found");
}
@@ -1759,7 +1769,13 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
return _runWithUnifiedCtx(__revalUCtx, async () => {
_ensureFetchPatch();
setNavigationContext({ pathname: cleanPathname, searchParams: new URLSearchParams(), params });
- const __revalElement = await buildPageElement(route, params, undefined, new URLSearchParams());
+ const __revalElement = await buildPageElements(
+ route,
+ params,
+ cleanPathname,
+ undefined,
+ new URLSearchParams(),
+ );
const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern);
const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError });
const __revalRscCapture = __teeAppPageRscStreamForCapture(__revalRscStream, true);
@@ -1808,7 +1824,15 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
// If the target URL matches an intercepting route in a parallel slot,
// render the source route with the intercepting page in the slot.
const __interceptResult = await __resolveAppPageIntercept({
- buildPageElement,
+ buildPageElement(interceptRoute, interceptParams, interceptOpts, interceptSearchParams) {
+ return buildPageElements(
+ interceptRoute,
+ interceptParams,
+ cleanPathname,
+ interceptOpts,
+ interceptSearchParams,
+ );
+ },
cleanPathname,
currentRoute: route,
findIntercept,
@@ -1856,7 +1880,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
const __pageBuildResult = await __buildAppPageElement({
buildPageElement() {
- return buildPageElement(route, params, interceptOpts, url.searchParams);
+ return buildPageElements(route, params, cleanPathname, interceptOpts, url.searchParams);
},
renderErrorBoundaryPage(buildErr) {
return renderErrorBoundaryPage(route, buildErr, isRscRequest, request, params);
@@ -2081,7 +2105,7 @@ import {
renderAppPageHttpAccessFallback as __renderAppPageHttpAccessFallback,
} from "/packages/vinext/src/server/app-page-boundary-render.js";
import {
- buildAppPageRouteElement as __buildAppPageRouteElement,
+ buildAppPageElements as __buildAppPageElements,
resolveAppPageChildSegments as __resolveAppPageChildSegments,
} from "/packages/vinext/src/server/app-page-route-wiring.js";
import {
@@ -2395,6 +2419,7 @@ const routes = [
routeHandler: null,
layouts: [mod_1],
routeSegments: [],
+ templateTreePositions: [],
layoutTreePositions: [0],
templates: [],
errors: [null],
@@ -2417,6 +2442,7 @@ const routes = [
routeHandler: null,
layouts: [mod_1],
routeSegments: ["about"],
+ templateTreePositions: [],
layoutTreePositions: [0],
templates: [],
errors: [null],
@@ -2439,6 +2465,7 @@ const routes = [
routeHandler: null,
layouts: [mod_1, mod_4],
routeSegments: ["blog",":slug"],
+ templateTreePositions: [],
layoutTreePositions: [0,1],
templates: [],
errors: [null, null],
@@ -2461,6 +2488,7 @@ const routes = [
routeHandler: null,
layouts: [mod_1, mod_6],
routeSegments: ["dashboard"],
+ templateTreePositions: [1],
layoutTreePositions: [0,1],
templates: [mod_7],
errors: [null, mod_9],
@@ -2647,7 +2675,7 @@ function findIntercept(pathname) {
return null;
}
-async function buildPageElement(route, params, opts, searchParams) {
+async function buildPageElements(route, params, routePath, opts, searchParams) {
const PageComponent = route.page?.default;
if (!PageComponent) {
return createElement("div", null, "Page has no default export");
@@ -2748,13 +2776,13 @@ async function buildPageElement(route, params, opts, searchParams) {
// dynamic, and this avoids false positives from React internals.
if (hasSearchParams) markDynamicUsage();
}
- return __buildAppPageRouteElement({
+ return __buildAppPageElements({
element: createElement(PageComponent, pageProps),
- globalErrorModule: null,
makeThenableParams,
matchedParams: params,
resolvedMetadata,
resolvedViewport,
+ routePath,
rootNotFoundModule: null,
route,
slotOverrides:
@@ -3441,7 +3469,13 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
searchParams: url.searchParams,
params: actionParams,
});
- element = buildPageElement(actionRoute, actionParams, undefined, url.searchParams);
+ element = buildPageElements(
+ actionRoute,
+ actionParams,
+ cleanPathname,
+ undefined,
+ url.searchParams,
+ );
} else {
element = createElement("div", null, "Page not found");
}
@@ -3765,7 +3799,13 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
return _runWithUnifiedCtx(__revalUCtx, async () => {
_ensureFetchPatch();
setNavigationContext({ pathname: cleanPathname, searchParams: new URLSearchParams(), params });
- const __revalElement = await buildPageElement(route, params, undefined, new URLSearchParams());
+ const __revalElement = await buildPageElements(
+ route,
+ params,
+ cleanPathname,
+ undefined,
+ new URLSearchParams(),
+ );
const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern);
const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError });
const __revalRscCapture = __teeAppPageRscStreamForCapture(__revalRscStream, true);
@@ -3814,7 +3854,15 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
// If the target URL matches an intercepting route in a parallel slot,
// render the source route with the intercepting page in the slot.
const __interceptResult = await __resolveAppPageIntercept({
- buildPageElement,
+ buildPageElement(interceptRoute, interceptParams, interceptOpts, interceptSearchParams) {
+ return buildPageElements(
+ interceptRoute,
+ interceptParams,
+ cleanPathname,
+ interceptOpts,
+ interceptSearchParams,
+ );
+ },
cleanPathname,
currentRoute: route,
findIntercept,
@@ -3862,7 +3910,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
const __pageBuildResult = await __buildAppPageElement({
buildPageElement() {
- return buildPageElement(route, params, interceptOpts, url.searchParams);
+ return buildPageElements(route, params, cleanPathname, interceptOpts, url.searchParams);
},
renderErrorBoundaryPage(buildErr) {
return renderErrorBoundaryPage(route, buildErr, isRscRequest, request, params);
@@ -4087,7 +4135,7 @@ import {
renderAppPageHttpAccessFallback as __renderAppPageHttpAccessFallback,
} from "/packages/vinext/src/server/app-page-boundary-render.js";
import {
- buildAppPageRouteElement as __buildAppPageRouteElement,
+ buildAppPageElements as __buildAppPageElements,
resolveAppPageChildSegments as __resolveAppPageChildSegments,
} from "/packages/vinext/src/server/app-page-route-wiring.js";
import {
@@ -4402,6 +4450,7 @@ const routes = [
routeHandler: null,
layouts: [mod_1],
routeSegments: [],
+ templateTreePositions: [],
layoutTreePositions: [0],
templates: [],
errors: [null],
@@ -4424,6 +4473,7 @@ const routes = [
routeHandler: null,
layouts: [mod_1],
routeSegments: ["about"],
+ templateTreePositions: [],
layoutTreePositions: [0],
templates: [],
errors: [null],
@@ -4446,6 +4496,7 @@ const routes = [
routeHandler: null,
layouts: [mod_1, mod_4],
routeSegments: ["blog",":slug"],
+ templateTreePositions: [],
layoutTreePositions: [0,1],
templates: [],
errors: [null, null],
@@ -4468,6 +4519,7 @@ const routes = [
routeHandler: null,
layouts: [mod_1, mod_6],
routeSegments: ["dashboard"],
+ templateTreePositions: [1],
layoutTreePositions: [0,1],
templates: [mod_7],
errors: [null, mod_9],
@@ -4654,7 +4706,7 @@ function findIntercept(pathname) {
return null;
}
-async function buildPageElement(route, params, opts, searchParams) {
+async function buildPageElements(route, params, routePath, opts, searchParams) {
const PageComponent = route.page?.default;
if (!PageComponent) {
return createElement("div", null, "Page has no default export");
@@ -4755,13 +4807,13 @@ async function buildPageElement(route, params, opts, searchParams) {
// dynamic, and this avoids false positives from React internals.
if (hasSearchParams) markDynamicUsage();
}
- return __buildAppPageRouteElement({
+ return __buildAppPageElements({
element: createElement(PageComponent, pageProps),
- globalErrorModule: mod_11,
makeThenableParams,
matchedParams: params,
resolvedMetadata,
resolvedViewport,
+ routePath,
rootNotFoundModule: null,
route,
slotOverrides:
@@ -5445,7 +5497,13 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
searchParams: url.searchParams,
params: actionParams,
});
- element = buildPageElement(actionRoute, actionParams, undefined, url.searchParams);
+ element = buildPageElements(
+ actionRoute,
+ actionParams,
+ cleanPathname,
+ undefined,
+ url.searchParams,
+ );
} else {
element = createElement("div", null, "Page not found");
}
@@ -5769,7 +5827,13 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
return _runWithUnifiedCtx(__revalUCtx, async () => {
_ensureFetchPatch();
setNavigationContext({ pathname: cleanPathname, searchParams: new URLSearchParams(), params });
- const __revalElement = await buildPageElement(route, params, undefined, new URLSearchParams());
+ const __revalElement = await buildPageElements(
+ route,
+ params,
+ cleanPathname,
+ undefined,
+ new URLSearchParams(),
+ );
const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern);
const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError });
const __revalRscCapture = __teeAppPageRscStreamForCapture(__revalRscStream, true);
@@ -5818,7 +5882,15 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
// If the target URL matches an intercepting route in a parallel slot,
// render the source route with the intercepting page in the slot.
const __interceptResult = await __resolveAppPageIntercept({
- buildPageElement,
+ buildPageElement(interceptRoute, interceptParams, interceptOpts, interceptSearchParams) {
+ return buildPageElements(
+ interceptRoute,
+ interceptParams,
+ cleanPathname,
+ interceptOpts,
+ interceptSearchParams,
+ );
+ },
cleanPathname,
currentRoute: route,
findIntercept,
@@ -5866,7 +5938,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
const __pageBuildResult = await __buildAppPageElement({
buildPageElement() {
- return buildPageElement(route, params, interceptOpts, url.searchParams);
+ return buildPageElements(route, params, cleanPathname, interceptOpts, url.searchParams);
},
renderErrorBoundaryPage(buildErr) {
return renderErrorBoundaryPage(route, buildErr, isRscRequest, request, params);
@@ -6091,7 +6163,7 @@ import {
renderAppPageHttpAccessFallback as __renderAppPageHttpAccessFallback,
} from "/packages/vinext/src/server/app-page-boundary-render.js";
import {
- buildAppPageRouteElement as __buildAppPageRouteElement,
+ buildAppPageElements as __buildAppPageElements,
resolveAppPageChildSegments as __resolveAppPageChildSegments,
} from "/packages/vinext/src/server/app-page-route-wiring.js";
import {
@@ -6435,6 +6507,7 @@ const routes = [
routeHandler: null,
layouts: [mod_1],
routeSegments: [],
+ templateTreePositions: [],
layoutTreePositions: [0],
templates: [],
errors: [null],
@@ -6457,6 +6530,7 @@ const routes = [
routeHandler: null,
layouts: [mod_1],
routeSegments: ["about"],
+ templateTreePositions: [],
layoutTreePositions: [0],
templates: [],
errors: [null],
@@ -6479,6 +6553,7 @@ const routes = [
routeHandler: null,
layouts: [mod_1, mod_4],
routeSegments: ["blog",":slug"],
+ templateTreePositions: [],
layoutTreePositions: [0,1],
templates: [],
errors: [null, null],
@@ -6501,6 +6576,7 @@ const routes = [
routeHandler: null,
layouts: [mod_1, mod_6],
routeSegments: ["dashboard"],
+ templateTreePositions: [1],
layoutTreePositions: [0,1],
templates: [mod_7],
errors: [null, mod_9],
@@ -6687,7 +6763,7 @@ function findIntercept(pathname) {
return null;
}
-async function buildPageElement(route, params, opts, searchParams) {
+async function buildPageElements(route, params, routePath, opts, searchParams) {
const PageComponent = route.page?.default;
if (!PageComponent) {
return createElement("div", null, "Page has no default export");
@@ -6788,13 +6864,13 @@ async function buildPageElement(route, params, opts, searchParams) {
// dynamic, and this avoids false positives from React internals.
if (hasSearchParams) markDynamicUsage();
}
- return __buildAppPageRouteElement({
+ return __buildAppPageElements({
element: createElement(PageComponent, pageProps),
- globalErrorModule: null,
makeThenableParams,
matchedParams: params,
resolvedMetadata,
resolvedViewport,
+ routePath,
rootNotFoundModule: null,
route,
slotOverrides:
@@ -7481,7 +7557,13 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
searchParams: url.searchParams,
params: actionParams,
});
- element = buildPageElement(actionRoute, actionParams, undefined, url.searchParams);
+ element = buildPageElements(
+ actionRoute,
+ actionParams,
+ cleanPathname,
+ undefined,
+ url.searchParams,
+ );
} else {
element = createElement("div", null, "Page not found");
}
@@ -7805,7 +7887,13 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
return _runWithUnifiedCtx(__revalUCtx, async () => {
_ensureFetchPatch();
setNavigationContext({ pathname: cleanPathname, searchParams: new URLSearchParams(), params });
- const __revalElement = await buildPageElement(route, params, undefined, new URLSearchParams());
+ const __revalElement = await buildPageElements(
+ route,
+ params,
+ cleanPathname,
+ undefined,
+ new URLSearchParams(),
+ );
const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern);
const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError });
const __revalRscCapture = __teeAppPageRscStreamForCapture(__revalRscStream, true);
@@ -7854,7 +7942,15 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
// If the target URL matches an intercepting route in a parallel slot,
// render the source route with the intercepting page in the slot.
const __interceptResult = await __resolveAppPageIntercept({
- buildPageElement,
+ buildPageElement(interceptRoute, interceptParams, interceptOpts, interceptSearchParams) {
+ return buildPageElements(
+ interceptRoute,
+ interceptParams,
+ cleanPathname,
+ interceptOpts,
+ interceptSearchParams,
+ );
+ },
cleanPathname,
currentRoute: route,
findIntercept,
@@ -7902,7 +7998,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
const __pageBuildResult = await __buildAppPageElement({
buildPageElement() {
- return buildPageElement(route, params, interceptOpts, url.searchParams);
+ return buildPageElements(route, params, cleanPathname, interceptOpts, url.searchParams);
},
renderErrorBoundaryPage(buildErr) {
return renderErrorBoundaryPage(route, buildErr, isRscRequest, request, params);
@@ -8127,7 +8223,7 @@ import {
renderAppPageHttpAccessFallback as __renderAppPageHttpAccessFallback,
} from "/packages/vinext/src/server/app-page-boundary-render.js";
import {
- buildAppPageRouteElement as __buildAppPageRouteElement,
+ buildAppPageElements as __buildAppPageElements,
resolveAppPageChildSegments as __resolveAppPageChildSegments,
} from "/packages/vinext/src/server/app-page-route-wiring.js";
import {
@@ -8442,6 +8538,7 @@ const routes = [
routeHandler: null,
layouts: [mod_1],
routeSegments: [],
+ templateTreePositions: [],
layoutTreePositions: [0],
templates: [],
errors: [null],
@@ -8464,6 +8561,7 @@ const routes = [
routeHandler: null,
layouts: [mod_1],
routeSegments: ["about"],
+ templateTreePositions: [],
layoutTreePositions: [0],
templates: [],
errors: [null],
@@ -8486,6 +8584,7 @@ const routes = [
routeHandler: null,
layouts: [mod_1, mod_4],
routeSegments: ["blog",":slug"],
+ templateTreePositions: [],
layoutTreePositions: [0,1],
templates: [],
errors: [null, null],
@@ -8508,6 +8607,7 @@ const routes = [
routeHandler: null,
layouts: [mod_1, mod_6],
routeSegments: ["dashboard"],
+ templateTreePositions: [1],
layoutTreePositions: [0,1],
templates: [mod_7],
errors: [null, mod_9],
@@ -8700,7 +8800,7 @@ function findIntercept(pathname) {
return null;
}
-async function buildPageElement(route, params, opts, searchParams) {
+async function buildPageElements(route, params, routePath, opts, searchParams) {
const PageComponent = route.page?.default;
if (!PageComponent) {
return createElement("div", null, "Page has no default export");
@@ -8801,13 +8901,13 @@ async function buildPageElement(route, params, opts, searchParams) {
// dynamic, and this avoids false positives from React internals.
if (hasSearchParams) markDynamicUsage();
}
- return __buildAppPageRouteElement({
+ return __buildAppPageElements({
element: createElement(PageComponent, pageProps),
- globalErrorModule: null,
makeThenableParams,
matchedParams: params,
resolvedMetadata,
resolvedViewport,
+ routePath,
rootNotFoundModule: null,
route,
slotOverrides:
@@ -9491,7 +9591,13 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
searchParams: url.searchParams,
params: actionParams,
});
- element = buildPageElement(actionRoute, actionParams, undefined, url.searchParams);
+ element = buildPageElements(
+ actionRoute,
+ actionParams,
+ cleanPathname,
+ undefined,
+ url.searchParams,
+ );
} else {
element = createElement("div", null, "Page not found");
}
@@ -9815,7 +9921,13 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
return _runWithUnifiedCtx(__revalUCtx, async () => {
_ensureFetchPatch();
setNavigationContext({ pathname: cleanPathname, searchParams: new URLSearchParams(), params });
- const __revalElement = await buildPageElement(route, params, undefined, new URLSearchParams());
+ const __revalElement = await buildPageElements(
+ route,
+ params,
+ cleanPathname,
+ undefined,
+ new URLSearchParams(),
+ );
const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern);
const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError });
const __revalRscCapture = __teeAppPageRscStreamForCapture(__revalRscStream, true);
@@ -9864,7 +9976,15 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
// If the target URL matches an intercepting route in a parallel slot,
// render the source route with the intercepting page in the slot.
const __interceptResult = await __resolveAppPageIntercept({
- buildPageElement,
+ buildPageElement(interceptRoute, interceptParams, interceptOpts, interceptSearchParams) {
+ return buildPageElements(
+ interceptRoute,
+ interceptParams,
+ cleanPathname,
+ interceptOpts,
+ interceptSearchParams,
+ );
+ },
cleanPathname,
currentRoute: route,
findIntercept,
@@ -9912,7 +10032,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
const __pageBuildResult = await __buildAppPageElement({
buildPageElement() {
- return buildPageElement(route, params, interceptOpts, url.searchParams);
+ return buildPageElements(route, params, cleanPathname, interceptOpts, url.searchParams);
},
renderErrorBoundaryPage(buildErr) {
return renderErrorBoundaryPage(route, buildErr, isRscRequest, request, params);
@@ -10137,7 +10257,7 @@ import {
renderAppPageHttpAccessFallback as __renderAppPageHttpAccessFallback,
} from "/packages/vinext/src/server/app-page-boundary-render.js";
import {
- buildAppPageRouteElement as __buildAppPageRouteElement,
+ buildAppPageElements as __buildAppPageElements,
resolveAppPageChildSegments as __resolveAppPageChildSegments,
} from "/packages/vinext/src/server/app-page-route-wiring.js";
import {
@@ -10451,6 +10571,7 @@ const routes = [
routeHandler: null,
layouts: [mod_1],
routeSegments: [],
+ templateTreePositions: [],
layoutTreePositions: [0],
templates: [],
errors: [null],
@@ -10473,6 +10594,7 @@ const routes = [
routeHandler: null,
layouts: [mod_1],
routeSegments: ["about"],
+ templateTreePositions: [],
layoutTreePositions: [0],
templates: [],
errors: [null],
@@ -10495,6 +10617,7 @@ const routes = [
routeHandler: null,
layouts: [mod_1, mod_4],
routeSegments: ["blog",":slug"],
+ templateTreePositions: [],
layoutTreePositions: [0,1],
templates: [],
errors: [null, null],
@@ -10517,6 +10640,7 @@ const routes = [
routeHandler: null,
layouts: [mod_1, mod_6],
routeSegments: ["dashboard"],
+ templateTreePositions: [1],
layoutTreePositions: [0,1],
templates: [mod_7],
errors: [null, mod_9],
@@ -10703,7 +10827,7 @@ function findIntercept(pathname) {
return null;
}
-async function buildPageElement(route, params, opts, searchParams) {
+async function buildPageElements(route, params, routePath, opts, searchParams) {
const PageComponent = route.page?.default;
if (!PageComponent) {
return createElement("div", null, "Page has no default export");
@@ -10804,13 +10928,13 @@ async function buildPageElement(route, params, opts, searchParams) {
// dynamic, and this avoids false positives from React internals.
if (hasSearchParams) markDynamicUsage();
}
- return __buildAppPageRouteElement({
+ return __buildAppPageElements({
element: createElement(PageComponent, pageProps),
- globalErrorModule: null,
makeThenableParams,
matchedParams: params,
resolvedMetadata,
resolvedViewport,
+ routePath,
rootNotFoundModule: null,
route,
slotOverrides:
@@ -11858,7 +11982,13 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
searchParams: url.searchParams,
params: actionParams,
});
- element = buildPageElement(actionRoute, actionParams, undefined, url.searchParams);
+ element = buildPageElements(
+ actionRoute,
+ actionParams,
+ cleanPathname,
+ undefined,
+ url.searchParams,
+ );
} else {
element = createElement("div", null, "Page not found");
}
@@ -12182,7 +12312,13 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
return _runWithUnifiedCtx(__revalUCtx, async () => {
_ensureFetchPatch();
setNavigationContext({ pathname: cleanPathname, searchParams: new URLSearchParams(), params });
- const __revalElement = await buildPageElement(route, params, undefined, new URLSearchParams());
+ const __revalElement = await buildPageElements(
+ route,
+ params,
+ cleanPathname,
+ undefined,
+ new URLSearchParams(),
+ );
const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern);
const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError });
const __revalRscCapture = __teeAppPageRscStreamForCapture(__revalRscStream, true);
@@ -12231,7 +12367,15 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
// If the target URL matches an intercepting route in a parallel slot,
// render the source route with the intercepting page in the slot.
const __interceptResult = await __resolveAppPageIntercept({
- buildPageElement,
+ buildPageElement(interceptRoute, interceptParams, interceptOpts, interceptSearchParams) {
+ return buildPageElements(
+ interceptRoute,
+ interceptParams,
+ cleanPathname,
+ interceptOpts,
+ interceptSearchParams,
+ );
+ },
cleanPathname,
currentRoute: route,
findIntercept,
@@ -12279,7 +12423,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
const __pageBuildResult = await __buildAppPageElement({
buildPageElement() {
- return buildPageElement(route, params, interceptOpts, url.searchParams);
+ return buildPageElements(route, params, cleanPathname, interceptOpts, url.searchParams);
},
renderErrorBoundaryPage(buildErr) {
return renderErrorBoundaryPage(route, buildErr, isRscRequest, request, params);
diff --git a/tests/app-browser-entry.test.ts b/tests/app-browser-entry.test.ts
new file mode 100644
index 000000000..32856e299
--- /dev/null
+++ b/tests/app-browser-entry.test.ts
@@ -0,0 +1,163 @@
+import React from "react";
+import { describe, expect, it, vi } from "vite-plus/test";
+import {
+ APP_ROOT_LAYOUT_KEY,
+ APP_ROUTE_KEY,
+ normalizeAppElements,
+ type AppElements,
+} from "../packages/vinext/src/server/app-elements.js";
+import { createClientNavigationRenderSnapshot } from "../packages/vinext/src/shims/navigation.js";
+import {
+ applyAppRouterStateUpdate,
+ createPendingNavigationCommit,
+ routerReducer,
+ type AppRouterState,
+} from "../packages/vinext/src/server/app-browser-state.js";
+
+function createResolvedElements(
+ routeId: string,
+ rootLayoutTreePath: string | null,
+ extraEntries: Record = {},
+) {
+ return Promise.resolve(
+ normalizeAppElements({
+ [APP_ROUTE_KEY]: routeId,
+ [APP_ROOT_LAYOUT_KEY]: rootLayoutTreePath,
+ ...extraEntries,
+ }),
+ );
+}
+
+function createState(overrides: Partial = {}): AppRouterState {
+ return {
+ elements: createResolvedElements("route:/initial", "/"),
+ navigationSnapshot: createClientNavigationRenderSnapshot("https://example.com/initial", {}),
+ renderId: 0,
+ rootLayoutTreePath: "/",
+ routeId: "route:/initial",
+ ...overrides,
+ };
+}
+
+describe("app browser entry state helpers", () => {
+ it("merges elements on navigate", async () => {
+ const previousElements = createResolvedElements("route:/initial", "/", {
+ "layout:/": React.createElement("div", null, "layout"),
+ });
+ const nextElements = createResolvedElements("route:/next", "/", {
+ "page:/next": React.createElement("main", null, "next"),
+ });
+
+ const nextState = routerReducer(
+ createState({
+ elements: previousElements,
+ }),
+ {
+ elements: nextElements,
+ navigationSnapshot: createState().navigationSnapshot,
+ renderId: 1,
+ rootLayoutTreePath: "/",
+ routeId: "route:/next",
+ type: "navigate",
+ },
+ );
+
+ expect(nextState.routeId).toBe("route:/next");
+ expect(nextState.rootLayoutTreePath).toBe("/");
+ await expect(nextState.elements).resolves.toMatchObject({
+ "layout:/": expect.anything(),
+ "page:/next": expect.anything(),
+ });
+ });
+
+ it("replaces elements on replace", async () => {
+ const nextElements = createResolvedElements("route:/next", "/", {
+ "page:/next": React.createElement("main", null, "next"),
+ });
+
+ const nextState = routerReducer(createState(), {
+ elements: nextElements,
+ navigationSnapshot: createState().navigationSnapshot,
+ renderId: 1,
+ rootLayoutTreePath: "/",
+ routeId: "route:/next",
+ type: "replace",
+ });
+
+ expect(nextState.elements).toBe(nextElements);
+ await expect(nextState.elements).resolves.toMatchObject({
+ "page:/next": expect.anything(),
+ });
+ });
+
+ it("hard navigates instead of merging when the root layout changes", async () => {
+ const assign = vi.fn<(href: string) => void>();
+
+ const result = await applyAppRouterStateUpdate({
+ commit: vi.fn(),
+ currentState: createState({
+ rootLayoutTreePath: "/(marketing)",
+ }),
+ dispatch: vi.fn(),
+ nextElements: createResolvedElements("route:/dashboard", "/(dashboard)"),
+ onHardNavigate: assign,
+ targetHref: "/dashboard",
+ transition: (callback) => callback(),
+ });
+
+ expect(result).toEqual({ type: "hard-navigate" });
+ expect(assign).toHaveBeenCalledWith("/dashboard");
+ });
+
+ it("defers commit side effects until the payload has resolved and dispatched", async () => {
+ let resolveElements: ((value: AppElements) => void) | undefined;
+ const nextElements = new Promise((resolve) => {
+ resolveElements = resolve;
+ });
+ const dispatch = vi.fn();
+ const commit = vi.fn();
+
+ const pending = applyAppRouterStateUpdate({
+ commit,
+ currentState: createState(),
+ dispatch,
+ nextElements,
+ onHardNavigate: vi.fn(),
+ targetHref: "/dashboard",
+ transition: (callback) => callback(),
+ });
+
+ expect(dispatch).not.toHaveBeenCalled();
+ expect(commit).not.toHaveBeenCalled();
+
+ if (!resolveElements) {
+ throw new Error("Expected deferred elements resolver");
+ }
+
+ resolveElements(
+ normalizeAppElements({
+ [APP_ROUTE_KEY]: "route:/dashboard",
+ [APP_ROOT_LAYOUT_KEY]: "/",
+ "page:/dashboard": React.createElement("main", null, "dashboard"),
+ }),
+ );
+
+ await pending;
+
+ expect(dispatch).toHaveBeenCalledOnce();
+ expect(commit).toHaveBeenCalledOnce();
+ });
+
+ it("builds a merge commit for refresh and server-action payloads", async () => {
+ const refreshCommit = await createPendingNavigationCommit({
+ currentState: createState(),
+ nextElements: createResolvedElements("route:/dashboard", "/"),
+ navigationSnapshot: createState().navigationSnapshot,
+ type: "navigate",
+ });
+
+ expect(refreshCommit.action.type).toBe("navigate");
+ expect(refreshCommit.routeId).toBe("route:/dashboard");
+ expect(refreshCommit.rootLayoutTreePath).toBe("/");
+ });
+});
diff --git a/tests/app-elements.test.ts b/tests/app-elements.test.ts
new file mode 100644
index 000000000..ceb0d5fe2
--- /dev/null
+++ b/tests/app-elements.test.ts
@@ -0,0 +1,68 @@
+import React from "react";
+import { describe, expect, it } from "vite-plus/test";
+import { UNMATCHED_SLOT } from "../packages/vinext/src/shims/slot.js";
+import {
+ APP_ROOT_LAYOUT_KEY,
+ APP_ROUTE_KEY,
+ APP_UNMATCHED_SLOT_WIRE_VALUE,
+ normalizeAppElements,
+ readAppElementsMetadata,
+} from "../packages/vinext/src/server/app-elements.js";
+
+describe("app elements payload helpers", () => {
+ it("normalizes the unmatched-slot wire marker to UNMATCHED_SLOT for slot entries", () => {
+ const normalized = normalizeAppElements({
+ [APP_ROOT_LAYOUT_KEY]: "/",
+ [APP_ROUTE_KEY]: "route:/dashboard",
+ "page:/dashboard": React.createElement("main", null, "dashboard"),
+ "slot:modal:/": APP_UNMATCHED_SLOT_WIRE_VALUE,
+ });
+
+ expect(normalized["slot:modal:/"]).toBe(UNMATCHED_SLOT);
+ expect(normalized["page:/dashboard"]).not.toBe(UNMATCHED_SLOT);
+ });
+
+ it("does not rewrite the unmatched-slot wire marker for non-slot entries", () => {
+ const normalized = normalizeAppElements({
+ [APP_ROOT_LAYOUT_KEY]: "/",
+ [APP_ROUTE_KEY]: "route:/dashboard",
+ "page:/dashboard": APP_UNMATCHED_SLOT_WIRE_VALUE,
+ });
+
+ expect(normalized["page:/dashboard"]).toBe(APP_UNMATCHED_SLOT_WIRE_VALUE);
+ });
+
+ it("reads route metadata from the normalized payload", () => {
+ const metadata = readAppElementsMetadata(
+ normalizeAppElements({
+ [APP_ROOT_LAYOUT_KEY]: "/(dashboard)",
+ [APP_ROUTE_KEY]: "route:/dashboard",
+ "route:/dashboard": React.createElement("div", null, "route"),
+ }),
+ );
+
+ expect(metadata.routeId).toBe("route:/dashboard");
+ expect(metadata.rootLayoutTreePath).toBe("/(dashboard)");
+ });
+
+ it("rejects payloads with a missing __route key", () => {
+ expect(() =>
+ readAppElementsMetadata(
+ normalizeAppElements({
+ [APP_ROOT_LAYOUT_KEY]: "/",
+ }),
+ ),
+ ).toThrow("[vinext] Missing __route string in App Router payload");
+ });
+
+ it("rejects payloads with an invalid __rootLayout value", () => {
+ expect(() =>
+ readAppElementsMetadata(
+ normalizeAppElements({
+ [APP_ROOT_LAYOUT_KEY]: 123,
+ [APP_ROUTE_KEY]: "route:/dashboard",
+ }),
+ ),
+ ).toThrow("[vinext] Invalid __rootLayout in App Router payload");
+ });
+});
diff --git a/tests/app-page-boundary-render.test.ts b/tests/app-page-boundary-render.test.ts
index b33288666..5b529d890 100644
--- a/tests/app-page-boundary-render.test.ts
+++ b/tests/app-page-boundary-render.test.ts
@@ -5,6 +5,7 @@ import {
renderAppPageErrorBoundary,
renderAppPageHttpAccessFallback,
} from "../packages/vinext/src/server/app-page-boundary-render.js";
+import type { AppElements } from "../packages/vinext/src/server/app-elements.js";
function createStreamFromMarkup(markup: string): ReadableStream {
return new ReadableStream({
@@ -15,10 +16,17 @@ function createStreamFromMarkup(markup: string): ReadableStream {
});
}
-function renderElementToStream(element: React.ReactNode): ReadableStream {
+function renderElementToStream(element: React.ReactNode | AppElements): ReadableStream {
+ if (element !== null && typeof element === "object" && !React.isValidElement(element)) {
+ return createStreamFromMarkup(JSON.stringify(element));
+ }
return createStreamFromMarkup(ReactDOMServer.renderToStaticMarkup(element));
}
+function renderWirePayloadToStream(payload: unknown): ReadableStream {
+ return createStreamFromMarkup(JSON.stringify(payload));
+}
+
function createCommonOptions() {
const clearRequestContext = vi.fn();
const loadSsrHandler = vi.fn(async () => ({
@@ -60,7 +68,7 @@ function createCommonOptions() {
resolveChildSegments() {
return [];
},
- rootLayouts: [],
+ rootLayouts: EMPTY_ROOT_LAYOUTS,
};
}
@@ -122,6 +130,15 @@ const globalErrorModule = {
default: GlobalErrorBoundary as React.ComponentType,
};
+type TestModule =
+ | typeof rootLayoutModule
+ | typeof leafLayoutModule
+ | typeof notFoundModule
+ | typeof routeErrorModule
+ | typeof globalErrorModule;
+
+const EMPTY_ROOT_LAYOUTS: readonly TestModule[] = [];
+
describe("app page boundary render helpers", () => {
it("returns null when no HTTP access fallback boundary exists", async () => {
const common = createCommonOptions();
@@ -175,6 +192,35 @@ describe("app page boundary render helpers", () => {
expect(html).toContain('content="noindex"');
});
+ it("renders HTTP access fallback RSC responses as flat payloads", async () => {
+ const common = createCommonOptions();
+
+ const response = await renderAppPageHttpAccessFallback({
+ ...common,
+ isRscRequest: true,
+ matchedParams: { slug: "missing" },
+ renderToReadableStream: renderWirePayloadToStream,
+ rootLayouts: [rootLayoutModule],
+ route: {
+ layoutTreePositions: [0, 1],
+ layouts: [rootLayoutModule, leafLayoutModule],
+ notFound: notFoundModule,
+ params: { slug: "missing" },
+ pattern: "/posts/[slug]",
+ routeSegments: ["posts", "[slug]"],
+ },
+ statusCode: 404,
+ });
+
+ expect(response?.status).toBe(404);
+ expect(response?.headers.get("Content-Type")).toBe("text/x-component; charset=utf-8");
+
+ const payload = JSON.parse((await response?.text()) ?? "{}") as Record;
+ expect(payload.__route).toBe("route:/posts/missing");
+ expect(payload.__rootLayout).toBe("/");
+ expect(payload["route:/posts/missing"]).toBeTruthy();
+ });
+
it("renders route error boundaries with sanitized errors inside layouts", async () => {
const common = createCommonOptions();
const sanitizeErrorForClient = vi.fn((error: Error) => new Error(`safe:${error.message}`));
@@ -202,6 +248,37 @@ describe("app page boundary render helpers", () => {
expect(html).toContain("route:safe:secret");
});
+ it("renders error boundary RSC responses as flat payloads", async () => {
+ const common = createCommonOptions();
+
+ const response = await renderAppPageErrorBoundary({
+ ...common,
+ error: new Error("secret"),
+ isRscRequest: true,
+ matchedParams: { slug: "post" },
+ renderToReadableStream: renderWirePayloadToStream,
+ route: {
+ error: routeErrorModule,
+ layoutTreePositions: [0],
+ layouts: [rootLayoutModule],
+ params: { slug: "post" },
+ pattern: "/posts/[slug]",
+ routeSegments: ["posts", "[slug]"],
+ },
+ sanitizeErrorForClient(error: Error) {
+ return new Error(`safe:${error.message}`);
+ },
+ });
+
+ expect(response?.status).toBe(200);
+ expect(response?.headers.get("Content-Type")).toBe("text/x-component; charset=utf-8");
+
+ const payload = JSON.parse((await response?.text()) ?? "{}") as Record;
+ expect(payload.__route).toBe("route:/posts/missing");
+ expect(payload.__rootLayout).toBe("/");
+ expect(payload["route:/posts/missing"]).toBeTruthy();
+ });
+
it("renders global-error boundaries without layout wrapping", async () => {
const common = createCommonOptions();
diff --git a/tests/app-page-route-wiring.test.ts b/tests/app-page-route-wiring.test.ts
index 0e3826922..f4d151ce0 100644
--- a/tests/app-page-route-wiring.test.ts
+++ b/tests/app-page-route-wiring.test.ts
@@ -3,6 +3,7 @@ import ReactDOMServer from "react-dom/server";
import { describe, expect, it } from "vite-plus/test";
import { useSelectedLayoutSegments } from "../packages/vinext/src/shims/navigation.js";
import {
+ buildAppPageElements,
buildAppPageRouteElement,
createAppPageLayoutEntries,
resolveAppPageChildSegments,
@@ -149,4 +150,56 @@ describe("app page route wiring helpers", () => {
expect(html).toContain('data-segments="(marketing)|blog|post"');
expect(html).toContain('data-segments="blog|post"');
});
+
+ it("builds a flat elements map with route, layout, template, page, and slot entries", () => {
+ const elements = buildAppPageElements({
+ 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 },
+ },
+ },
+ templateTreePositions: [1],
+ templates: [{ default: Template }],
+ },
+ routePath: "/blog/post",
+ rootNotFoundModule: null,
+ slotOverrides: {
+ sidebar: {
+ pageModule: { default: SlotPage },
+ params: { slug: "post" },
+ props: { label: "intercepted" },
+ },
+ },
+ });
+
+ expect(elements.__route).toBe("route:/blog/post");
+ expect(elements.__rootLayout).toBe("/");
+ expect(elements["layout:/"]).toBeDefined();
+ expect(elements["layout:/(marketing)"]).toBeDefined();
+ expect(elements["template:/(marketing)"]).toBeDefined();
+ expect(elements["page:/blog/post"]).toBeDefined();
+ expect(elements["slot:sidebar:/"]).toBeDefined();
+ expect(elements["route:/blog/post"]).toBeDefined();
+ });
});
diff --git a/tests/app-router.test.ts b/tests/app-router.test.ts
index 29871a2a0..dd0f4e1d5 100644
--- a/tests/app-router.test.ts
+++ b/tests/app-router.test.ts
@@ -128,6 +128,24 @@ describe("App Router integration", () => {
expect(text.length).toBeGreaterThan(0);
});
+ it("returns flat payload metadata for app route RSC responses", async () => {
+ const res = await fetch(`${baseUrl}/dashboard.rsc`, {
+ headers: { Accept: "text/x-component" },
+ });
+ const rscText = await res.text();
+ if (res.status !== 200) {
+ throw new Error(rscText);
+ }
+ expect(res.headers.get("content-type")).toContain("text/x-component");
+ expect(rscText).toContain("__route");
+ expect(rscText).toContain("__rootLayout");
+ expect(rscText).toContain("route:/dashboard");
+ expect(rscText).toContain("layout:/");
+ expect(rscText).toContain("layout:/dashboard");
+ expect(rscText).toContain("slot:team:/dashboard");
+ expect(rscText).toContain("slot:analytics:/dashboard");
+ });
+
it("wraps pages in the root layout", async () => {
const res = await fetch(`${baseUrl}/about`);
const html = await res.text();
diff --git a/tests/entry-templates.test.ts b/tests/entry-templates.test.ts
index 3e1fd6e37..702d408e6 100644
--- a/tests/entry-templates.test.ts
+++ b/tests/entry-templates.test.ts
@@ -48,6 +48,7 @@ const minimalAppRoutes: AppRoute[] = [
forbiddenPath: null,
unauthorizedPath: null,
routeSegments: [],
+ templateTreePositions: [],
layoutTreePositions: [0],
isDynamic: false,
params: [],
@@ -68,6 +69,7 @@ const minimalAppRoutes: AppRoute[] = [
forbiddenPath: null,
unauthorizedPath: null,
routeSegments: ["about"],
+ templateTreePositions: [],
layoutTreePositions: [0],
isDynamic: false,
params: [],
@@ -88,6 +90,7 @@ const minimalAppRoutes: AppRoute[] = [
forbiddenPath: null,
unauthorizedPath: null,
routeSegments: ["blog", ":slug"],
+ templateTreePositions: [],
layoutTreePositions: [0, 1],
isDynamic: true,
params: ["slug"],
@@ -108,6 +111,7 @@ const minimalAppRoutes: AppRoute[] = [
forbiddenPath: null,
unauthorizedPath: null,
routeSegments: ["dashboard"],
+ templateTreePositions: [1],
layoutTreePositions: [0, 1],
isDynamic: false,
params: [],
diff --git a/tests/slot.test.ts b/tests/slot.test.ts
index 62d52b40a..af6d13296 100644
--- a/tests/slot.test.ts
+++ b/tests/slot.test.ts
@@ -177,6 +177,20 @@ describe("slot primitives", () => {
expect(errors).toEqual([]);
});
+ it("normalizes the server unmatched-slot marker to the client sentinel", async () => {
+ const { normalizeAppElements, APP_UNMATCHED_SLOT_WIRE_VALUE } =
+ await import("../packages/vinext/src/server/app-elements.js");
+ const mod = await import("../packages/vinext/src/shims/slot.js");
+
+ const normalized = normalizeAppElements({
+ __rootLayout: "/",
+ __route: "route:/dashboard",
+ "slot:modal:/": APP_UNMATCHED_SLOT_WIRE_VALUE,
+ });
+
+ expect(normalized["slot:modal:/"]).toBe(mod.UNMATCHED_SLOT);
+ });
+
it("mergeElementsPromise shallow-merges previous and next elements", async () => {
const { mergeElementsPromise } = await import("../packages/vinext/src/shims/slot.js");
From ec008fa3adc43485c48d0a34deec9ffc76988ffd Mon Sep 17 00:00:00 2001
From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com>
Date: Thu, 2 Apr 2026 14:08:20 +1100
Subject: [PATCH 07/19] fix: address review findings in flat payload
implementation
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Fix stale closure on readBrowserRouterState by using a useRef updated
synchronously during render instead of a closure captured in
useLayoutEffect. External callers (navigate, server actions, HMR) now
always read the current router state.
- Restore GlobalErrorBoundary wrapping that was dropped when switching
from buildPageElement to buildAppPageElements. Apps with
app/global-error.tsx now get their global error boundary back.
- Add exhaustive default case to routerReducer so new action types
produce a compile error and a runtime throw instead of silent undefined.
- Remove dead code: createRouteNodeSnapshot, AppRouteNodeSnapshot,
AppRouteNodeValue were defined but never imported.
- Remove deprecated buildAppPageRouteElement and its test — no
production callers remain after the flat payload cutover.
- Short-circuit normalizeAppElements when no slot keys need rewriting
to avoid unnecessary allocation on every payload.
- Align test data in error boundary RSC payload test (matchedParams
slug: "post" -> "missing" to match requestUrl /posts/missing).
---
packages/vinext/src/entries/app-rsc-entry.ts | 1 +
.../vinext/src/server/app-browser-entry.ts | 28 +--
.../vinext/src/server/app-browser-state.ts | 15 +-
packages/vinext/src/server/app-elements.ts | 13 +-
.../src/server/app-page-route-wiring.tsx | 164 +-----------------
.../entry-templates.test.ts.snap | 6 +
tests/app-page-boundary-render.test.ts | 4 +-
tests/app-page-route-wiring.test.ts | 54 ------
8 files changed, 48 insertions(+), 237 deletions(-)
diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts
index fe18feddf..7b8227c8c 100644
--- a/packages/vinext/src/entries/app-rsc-entry.ts
+++ b/packages/vinext/src/entries/app-rsc-entry.ts
@@ -985,6 +985,7 @@ async function buildPageElements(route, params, routePath, opts, searchParams) {
}
return __buildAppPageElements({
element: createElement(PageComponent, pageProps),
+ globalErrorModule: ${globalErrorVar ? globalErrorVar : "null"},
makeThenableParams,
matchedParams: params,
resolvedMetadata,
diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts
index 49c3bb15f..c2a39c6e0 100644
--- a/packages/vinext/src/server/app-browser-entry.ts
+++ b/packages/vinext/src/server/app-browser-entry.ts
@@ -6,6 +6,7 @@ import {
use,
useLayoutEffect,
useReducer,
+ useRef,
type Dispatch,
type ReactNode,
} from "react";
@@ -98,7 +99,7 @@ let activeNavigationId = 0;
const pendingNavigationCommits = new Map void>();
const pendingNavigationPrePaintEffects = new Map void>();
let dispatchBrowserRouterAction: Dispatch | null = null;
-let readBrowserRouterState: (() => AppRouterState) | null = null;
+let browserRouterStateRef: { current: AppRouterState } | null = null;
let latestClientParams: Record = {};
const visitedResponseCache = new Map();
@@ -114,10 +115,10 @@ function getBrowserRouterDispatch(): Dispatch {
}
function getBrowserRouterState(): AppRouterState {
- if (!readBrowserRouterState) {
+ if (!browserRouterStateRef) {
throw new Error("[vinext] Browser router state is not initialized");
}
- return readBrowserRouterState();
+ return browserRouterStateRef.current;
}
function applyClientParams(params: Record): void {
@@ -325,16 +326,21 @@ function BrowserRoot({
routeId: initialMetadata.routeId,
});
- // Assign the module-level setter via useLayoutEffect instead of during render
- // to avoid side effects that React Strict Mode / concurrent features may
- // call multiple times. useLayoutEffect fires synchronously during commit,
- // before hydrateRoot returns to main(), so the router dispatch is available
- // before __VINEXT_RSC_NAVIGATE__ is assigned. dispatchTreeState is referentially
- // stable so the effect only runs on mount.
+ // Keep the latest router state in a ref so external callers (navigate(),
+ // server actions, HMR) always read the current state. The ref is updated
+ // synchronously during render -- not in an effect -- so there is no stale
+ // window between React committing a new state and the effect firing.
+ const stateRef = useRef(treeState);
+ stateRef.current = treeState;
+ browserRouterStateRef = stateRef;
+
+ // Assign the module-level dispatch via useLayoutEffect. dispatchTreeState
+ // is referentially stable so the effect only runs on mount. The effect fires
+ // synchronously during commit, before hydrateRoot returns to main(), so the
+ // dispatch is available before __VINEXT_RSC_NAVIGATE__ is assigned.
useLayoutEffect(() => {
dispatchBrowserRouterAction = dispatchTreeState;
- readBrowserRouterState = () => treeState;
- }, [dispatchTreeState, treeState]);
+ }, [dispatchTreeState]);
const committedTree = createElement(
NavigationCommitSignal,
diff --git a/packages/vinext/src/server/app-browser-state.ts b/packages/vinext/src/server/app-browser-state.ts
index 10ea50649..696ee76ed 100644
--- a/packages/vinext/src/server/app-browser-state.ts
+++ b/packages/vinext/src/server/app-browser-state.ts
@@ -1,4 +1,3 @@
-import type { ReactNode } from "react";
import { mergeElementsPromise } from "../shims/slot.js";
import { readAppElementsMetadata, type AppElements } from "./app-elements.js";
import type { ClientNavigationRenderSnapshot } from "../shims/navigation.js";
@@ -44,6 +43,10 @@ export function routerReducer(state: AppRouterState, action: AppRouterAction): A
rootLayoutTreePath: action.rootLayoutTreePath,
routeId: action.routeId,
};
+ default: {
+ const _exhaustive: never = action.type;
+ throw new Error("[vinext] Unknown router action: " + String(_exhaustive));
+ }
}
}
@@ -112,13 +115,3 @@ export async function applyAppRouterStateUpdate(options: {
return { type: "dispatched" };
}
-
-export function createRouteNodeSnapshot(
- elements: Promise,
- routeId: string,
-): { elements: Promise; routeId: string } {
- return { elements, routeId };
-}
-
-export type AppRouteNodeSnapshot = ReturnType;
-export type AppRouteNodeValue = ReactNode;
diff --git a/packages/vinext/src/server/app-elements.ts b/packages/vinext/src/server/app-elements.ts
index e04828971..f1e4a8930 100644
--- a/packages/vinext/src/server/app-elements.ts
+++ b/packages/vinext/src/server/app-elements.ts
@@ -18,8 +18,19 @@ export type AppElementsMetadata = {
};
export function normalizeAppElements(elements: AppWireElements): AppElements {
- const normalized: Record = {};
+ let needsNormalization = false;
+ for (const [key, value] of Object.entries(elements)) {
+ if (key.startsWith("slot:") && value === APP_UNMATCHED_SLOT_WIRE_VALUE) {
+ needsNormalization = true;
+ break;
+ }
+ }
+ if (!needsNormalization) {
+ return elements;
+ }
+
+ const normalized: Record = {};
for (const [key, value] of Object.entries(elements)) {
normalized[key] =
key.startsWith("slot:") && value === APP_UNMATCHED_SLOT_WIRE_VALUE ? UNMATCHED_SLOT : value;
diff --git a/packages/vinext/src/server/app-page-route-wiring.tsx b/packages/vinext/src/server/app-page-route-wiring.tsx
index a605fe877..421c8b717 100644
--- a/packages/vinext/src/server/app-page-route-wiring.tsx
+++ b/packages/vinext/src/server/app-page-route-wiring.tsx
@@ -94,7 +94,7 @@ export type BuildAppPageRouteElementOptions<
export type BuildAppPageElementsOptions<
TModule extends AppPageModule = AppPageModule,
TErrorModule extends AppPageErrorModule = AppPageErrorModule,
-> = Omit, "globalErrorModule"> & {
+> = BuildAppPageRouteElementOptions & {
routePath: string;
};
@@ -475,6 +475,11 @@ export function buildAppPageElements<
);
}
+ const globalErrorComponent = getErrorBoundaryExport(options.globalErrorModule);
+ if (globalErrorComponent) {
+ routeChildren = {routeChildren};
+ }
+
elements[routeId] = (
<>
{createAppPageRouteHead(options.resolvedMetadata, options.resolvedViewport)}
@@ -484,160 +489,3 @@ export function buildAppPageElements<
return elements;
}
-
-export function buildAppPageRouteElement<
- TModule extends AppPageModule,
- TErrorModule extends AppPageErrorModule,
->(options: BuildAppPageRouteElementOptions): ReactNode {
- /**
- * @deprecated PR 2c introduces buildAppPageElements() for the flat payload
- * cutover. Keep this helper during the transition so intermediate test runs
- * remain stable, then delete it only after all call sites have switched.
- */
- 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 = getErrorBoundaryExport(options.route.error);
- if (pageErrorComponent && options.route.error !== lastLayoutErrorModule) {
- element = {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 = getErrorBoundaryExport(layoutEntry.errorModule);
- if (layoutErrorComponent) {
- element = {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 = getErrorBoundaryExport(slot.error);
- if (slotErrorComponent) {
- slotElement = {slotElement};
- }
-
- layoutProps[slotName] = slotElement;
- }
-
- const LayoutComponent = layoutComponent;
- element = {element};
- element = (
-
- {element}
-
- );
- }
-
- const globalErrorComponent = getErrorBoundaryExport(options.globalErrorModule);
- if (globalErrorComponent) {
- element = {element};
- }
-
- return element;
-}
diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap
index fa31701cc..e370e373b 100644
--- a/tests/__snapshots__/entry-templates.test.ts.snap
+++ b/tests/__snapshots__/entry-templates.test.ts.snap
@@ -751,6 +751,7 @@ async function buildPageElements(route, params, routePath, opts, searchParams) {
}
return __buildAppPageElements({
element: createElement(PageComponent, pageProps),
+ globalErrorModule: null,
makeThenableParams,
matchedParams: params,
resolvedMetadata,
@@ -2778,6 +2779,7 @@ async function buildPageElements(route, params, routePath, opts, searchParams) {
}
return __buildAppPageElements({
element: createElement(PageComponent, pageProps),
+ globalErrorModule: null,
makeThenableParams,
matchedParams: params,
resolvedMetadata,
@@ -4809,6 +4811,7 @@ async function buildPageElements(route, params, routePath, opts, searchParams) {
}
return __buildAppPageElements({
element: createElement(PageComponent, pageProps),
+ globalErrorModule: mod_11,
makeThenableParams,
matchedParams: params,
resolvedMetadata,
@@ -6866,6 +6869,7 @@ async function buildPageElements(route, params, routePath, opts, searchParams) {
}
return __buildAppPageElements({
element: createElement(PageComponent, pageProps),
+ globalErrorModule: null,
makeThenableParams,
matchedParams: params,
resolvedMetadata,
@@ -8903,6 +8907,7 @@ async function buildPageElements(route, params, routePath, opts, searchParams) {
}
return __buildAppPageElements({
element: createElement(PageComponent, pageProps),
+ globalErrorModule: null,
makeThenableParams,
matchedParams: params,
resolvedMetadata,
@@ -10930,6 +10935,7 @@ async function buildPageElements(route, params, routePath, opts, searchParams) {
}
return __buildAppPageElements({
element: createElement(PageComponent, pageProps),
+ globalErrorModule: null,
makeThenableParams,
matchedParams: params,
resolvedMetadata,
diff --git a/tests/app-page-boundary-render.test.ts b/tests/app-page-boundary-render.test.ts
index 5b529d890..060e0acf2 100644
--- a/tests/app-page-boundary-render.test.ts
+++ b/tests/app-page-boundary-render.test.ts
@@ -255,13 +255,13 @@ describe("app page boundary render helpers", () => {
...common,
error: new Error("secret"),
isRscRequest: true,
- matchedParams: { slug: "post" },
+ matchedParams: { slug: "missing" },
renderToReadableStream: renderWirePayloadToStream,
route: {
error: routeErrorModule,
layoutTreePositions: [0],
layouts: [rootLayoutModule],
- params: { slug: "post" },
+ params: { slug: "missing" },
pattern: "/posts/[slug]",
routeSegments: ["posts", "[slug]"],
},
diff --git a/tests/app-page-route-wiring.test.ts b/tests/app-page-route-wiring.test.ts
index f4d151ce0..c4ca42c2e 100644
--- a/tests/app-page-route-wiring.test.ts
+++ b/tests/app-page-route-wiring.test.ts
@@ -1,10 +1,8 @@
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 {
buildAppPageElements,
- buildAppPageRouteElement,
createAppPageLayoutEntries,
resolveAppPageChildSegments,
} from "../packages/vinext/src/server/app-page-route-wiring.js";
@@ -99,58 +97,6 @@ describe("app page route wiring helpers", () => {
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"');
- });
-
it("builds a flat elements map with route, layout, template, page, and slot entries", () => {
const elements = buildAppPageElements({
element: createElement(PageProbe),
From 5395efc5028671e75d14822fedf215d0e5f83e51 Mon Sep 17 00:00:00 2001
From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com>
Date: Thu, 2 Apr 2026 14:52:07 +1100
Subject: [PATCH 08/19] fix: normalize flat payload after use(), not before
createFromReadableStream() returns a React thenable whose .then()
returns undefined (not a Promise). Chaining .then(normalizeAppElements)
broke SSR by assigning undefined to flightRoot.
Fix: call use() on the raw thenable, then normalize synchronously
after resolution. Also widen renderAppPageLifecycle element type to
accept flat map payloads.
---
packages/vinext/src/server/app-page-render.ts | 4 ++--
packages/vinext/src/server/app-ssr-entry.ts | 10 ++++------
2 files changed, 6 insertions(+), 8 deletions(-)
diff --git a/packages/vinext/src/server/app-page-render.ts b/packages/vinext/src/server/app-page-render.ts
index 8591cae79..ed66bf72b 100644
--- a/packages/vinext/src/server/app-page-render.ts
+++ b/packages/vinext/src/server/app-page-render.ts
@@ -84,14 +84,14 @@ export type RenderAppPageLifecycleOptions = {
) => Promise;
renderPageSpecialError: (specialError: AppPageSpecialError) => Promise;
renderToReadableStream: (
- element: ReactNode,
+ element: ReactNode | Record