diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts
index 23e1c3ecc..0e9faf7e8 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(
@@ -206,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(", ")}],
@@ -337,13 +342,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 +378,10 @@ import {
renderAppPageErrorBoundary as __renderAppPageErrorBoundary,
renderAppPageHttpAccessFallback as __renderAppPageHttpAccessFallback,
} from ${JSON.stringify(appPageBoundaryRenderPath)};
+import {
+ buildAppPageElements as __buildAppPageElements,
+ resolveAppPageChildSegments as __resolveAppPageChildSegments,
+} from ${JSON.stringify(appPageRouteWiringPath)};
import {
renderAppPageLifecycle as __renderAppPageLifecycle,
} from ${JSON.stringify(appPageRenderPath)};
@@ -542,38 +549,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 +752,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 +798,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,
@@ -907,10 +882,21 @@ 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");
+ const _noExportRouteId = "route:" + routePath;
+ let _noExportRootLayout = null;
+ if (route.layouts?.length > 0) {
+ const _tp = route.layoutTreePositions?.[0] ?? 0;
+ const _segs = route.routeSegments?.slice(0, _tp) ?? [];
+ _noExportRootLayout = _segs.length === 0 ? "/" : "/" + _segs.join("/");
+ }
+ return {
+ __route: _noExportRouteId,
+ __rootLayout: _noExportRootLayout,
+ [_noExportRouteId]: createElement("div", null, "Page has no default export"),
+ };
}
// Resolve metadata and viewport from layouts and page.
@@ -989,12 +975,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 +994,26 @@ 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 __buildAppPageElements({
+ element: createElement(PageComponent, pageProps),
+ globalErrorModule: ${globalErrorVar ? globalErrorVar : "null"},
+ makeThenableParams,
+ matchedParams: params,
+ resolvedMetadata,
+ resolvedViewport,
+ routePath,
+ 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") : ""}
@@ -1900,9 +1714,20 @@ 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");
+ const _actionRouteId = "route:" + cleanPathname;
+ element = {
+ __route: _actionRouteId,
+ __rootLayout: null,
+ [_actionRouteId]: createElement("div", null, "Page not found"),
+ };
}
const onRenderError = createRscOnErrorHandler(
@@ -2254,7 +2079,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);
@@ -2303,7 +2134,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,
@@ -2351,7 +2190,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..fe5c02003 100644
--- a/packages/vinext/src/server/app-browser-entry.ts
+++ b/packages/vinext/src/server/app-browser-entry.ts
@@ -5,10 +5,10 @@ import {
startTransition,
use,
useLayoutEffect,
- useState,
+ useReducer,
+ useRef,
type Dispatch,
type ReactNode,
- type SetStateAction,
} from "react";
import {
createFromFetch,
@@ -46,22 +46,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 +98,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 browserRouterStateRef: { current: AppRouterState } | null = null;
let latestClientParams: Record = {};
const visitedResponseCache = new Map();
@@ -97,11 +107,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 (!browserRouterStateRef) {
+ throw new Error("[vinext] Browser router state is not initialized");
}
- return setBrowserTreeState;
+ return browserRouterStateRef.current;
}
function applyClientParams(params: Record): void {
@@ -171,9 +188,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 +305,54 @@ function NavigationCommitSignal({
return children;
}
+function normalizeAppElementsPromise(payload: Promise): Promise {
+ // Wrap in Promise.resolve() because createFromReadableStream() returns a
+ // React Flight thenable whose .then() returns undefined (not a new Promise).
+ // Without the wrap, chaining .then() produces undefined → use() crashes.
+ return Promise.resolve(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: 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
- // 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(() => {
- setBrowserTreeState = setTreeState;
- }, []); // eslint-disable-line react-hooks/exhaustive-deps -- setTreeState is referentially stable
+ dispatchBrowserRouterAction = dispatchTreeState;
+ }, [dispatchTreeState]);
const committedTree = createElement(
NavigationCommitSignal,
{ renderId: treeState.renderId },
- treeState.node,
+ createElement(
+ ElementsContext.Provider,
+ { value: treeState.elements },
+ createElement(Slot, { id: treeState.routeId }),
+ ),
);
const ClientNavigationRenderContext = getClientNavigationRenderContext();
@@ -328,83 +367,100 @@ function BrowserRoot({
);
}
-function updateBrowserTree(
- node: ReactNode | Promise,
+function dispatchBrowserTree(
+ elements: AppElements,
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 });
- };
-
- // Balance the activate/commit pairing if the async payload rejects after
- // activateNavigationSnapshot() was called. Only decrement when snapshotActivated
- // is true — server action callers skip renderNavigationPayload entirely and
- // never call activateNavigationSnapshot(), so decrementing there would corrupt
- // the counter for any concurrent RSC navigation.
- const handleAsyncError = () => {
- pendingNavigationPrePaintEffects.delete(renderId);
- const resolve = pendingNavigationCommits.get(renderId);
- pendingNavigationCommits.delete(renderId);
- if (snapshotActivated) {
- commitClientNavigationState();
- }
- resolve?.();
- };
-
- if (node != null && typeof (node as PromiseLike).then === "function") {
- const thenable = node as PromiseLike;
- if (useTransitionMode) {
- void thenable.then(
- (resolved) => startTransition(() => resolvedThenSet(resolved)),
- handleAsyncError,
- );
- } else {
- void thenable.then(resolvedThenSet, handleAsyncError);
- }
- return;
- }
+ const dispatch = getBrowserRouterDispatch();
+
+ const applyAction = () =>
+ dispatch({
+ elements,
+ navigationSnapshot,
+ renderId,
+ rootLayoutTreePath,
+ routeId,
+ type: actionType,
+ });
- const syncNode = node as ReactNode;
if (useTransitionMode) {
- startTransition(() => resolvedThenSet(syncNode));
- return;
+ startTransition(applyAction);
+ } else {
+ applyAction();
}
-
- resolvedThenSet(syncNode);
}
-function renderNavigationPayload(
- payload: Promise | ReactNode,
+async function renderNavigationPayload(
+ payload: Promise,
navigationSnapshot: ClientNavigationRenderSnapshot,
+ targetHref: string,
+ navId: number,
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.
+ let snapshotActivated = false;
try {
- updateBrowserTree(payload, navigationSnapshot, renderId, useTransition, true);
+ const currentState = getBrowserRouterState();
+ const pending = await createPendingNavigationCommit({
+ currentState,
+ nextElements: payload,
+ navigationSnapshot,
+ renderId,
+ type: actionType,
+ });
+
+ // After the await, a newer navigation may have started. Bail out to
+ // avoid dispatching stale elements into the React tree. Clean up the
+ // pending commit entry so it doesn't leak.
+ if (navId !== activeNavigationId) {
+ const resolve = pendingNavigationCommits.get(renderId);
+ pendingNavigationCommits.delete(renderId);
+ resolve?.();
+ return;
+ }
+
+ if (shouldHardNavigate(currentState.rootLayoutTreePath, pending.rootLayoutTreePath)) {
+ pendingNavigationCommits.delete(renderId);
+ window.location.assign(targetHref);
+ return;
+ }
+
+ queuePrePaintNavigationEffect(renderId, prePaintEffect);
+ activateNavigationSnapshot();
+ snapshotActivated = true;
+ dispatchBrowserTree(
+ pending.action.elements,
+ navigationSnapshot,
+ renderId,
+ actionType,
+ pending.routeId,
+ pending.rootLayoutTreePath,
+ useTransition,
+ );
} catch (error) {
- // Clean up pending state and decrement counter on synchronous error.
+ // Clean up pending state on error. Only decrement the snapshot counter
+ // if activateNavigationSnapshot() was actually called — if
+ // createPendingNavigationCommit() threw, the counter was never
+ // incremented so decrementing would underflow it.
pendingNavigationPrePaintEffects.delete(renderId);
const resolve = pendingNavigationCommits.get(renderId);
pendingNavigationCommits.delete(renderId);
- commitClientNavigationState();
+ if (snapshotActivated) {
+ commitClientNavigationState();
+ }
resolve?.();
- throw error; // Re-throw to maintain error propagation
+ throw error;
}
return committed;
@@ -534,7 +590,7 @@ function registerServerActionCallback(): void {
clearClientNavigationCaches();
- const result = await createFromFetch(
+ const result = await createFromFetch(
Promise.resolve(fetchResponse),
{ temporaryReferences },
);
@@ -548,10 +604,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 +631,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 +659,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 +668,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 +708,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 +723,20 @@ 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,
+ navId,
+ createNavigationCommitEffect(href, historyUpdateMode, cachedParams),
isSameRoute,
);
} finally {
@@ -726,23 +804,20 @@ 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,
+ navId,
+ createNavigationCommitEffect(href, historyUpdateMode, navParams),
isSameRoute,
);
} finally {
@@ -753,6 +828,9 @@ async function main(): Promise {
// catch from double-decrementing navigationSnapshotActiveCount.
_snapshotPending = false;
}
+ // Don't cache the response if this navigation was superseded during
+ // renderNavigationPayload's await — the elements were never dispatched.
+ if (navId !== activeNavigationId) return;
// Store the visited response only after renderNavigationPayload succeeds.
// If we stored it before and renderNavigationPayload threw, a future
// back/forward navigation could replay a snapshot from a navigation that
@@ -801,14 +879,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 +910,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..f439b798c
--- /dev/null
+++ b/packages/vinext/src/server/app-browser-state.ts
@@ -0,0 +1,117 @@
+import { mergeElements } from "../shims/slot.js";
+import { readAppElementsMetadata, type AppElements } from "./app-elements.js";
+import type { ClientNavigationRenderSnapshot } from "../shims/navigation.js";
+
+export type AppRouterState = {
+ elements: AppElements;
+ renderId: number;
+ navigationSnapshot: ClientNavigationRenderSnapshot;
+ rootLayoutTreePath: string | null;
+ routeId: string;
+};
+
+export type AppRouterAction = {
+ elements: AppElements;
+ 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: mergeElements(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,
+ };
+ default: {
+ const _exhaustive: never = action.type;
+ throw new Error("[vinext] Unknown router action: " + String(_exhaustive));
+ }
+ }
+}
+
+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,
+ 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" };
+}
diff --git a/packages/vinext/src/server/app-elements.ts b/packages/vinext/src/server/app-elements.ts
new file mode 100644
index 000000000..f1e4a8930
--- /dev/null
+++ b/packages/vinext/src/server/app-elements.ts
@@ -0,0 +1,57 @@
+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 {
+ 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;
+ }
+
+ 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..2982acb6d 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,58 @@ 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 = createAppPageBoundaryRscPayload({
+ element: options.element,
+ layoutModules: options.layoutModules,
+ pathname,
+ route: options.route,
+ });
return renderAppPageBoundaryResponse({
async createHtmlResponse(rscStream, responseStatus) {
@@ -230,7 +283,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-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,
options: { onError: AppPageBoundaryOnError },
) => ReadableStream;
routeHasLocalBoundary: boolean;
routePattern: string;
runWithSuppressedHookWarning(probe: () => Promise): Promise;
waitUntil?: (promise: Promise) => void;
- element: ReactNode;
+ element: ReactNode | Record;
};
function buildResponseTiming(
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..1909780a6
--- /dev/null
+++ b/packages/vinext/src/server/app-page-route-wiring.tsx
@@ -0,0 +1,580 @@
+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";
+import {
+ createAppRenderDependency,
+ renderAfterAppDependencies,
+ renderWithAppDependencyBarrier,
+ type AppRenderDependency,
+} from "./app-render-dependency.js";
+
+type AppPageComponentProps = {
+ children?: ReactNode;
+ error?: Error;
+ params?: unknown;
+ reset?: () => void;
+} & Record;
+
+type AppPageComponent = ComponentType;
+type AppPageErrorComponent = ComponentType<{ error: Error; reset: () => void }>;
+
+export type AppPageModule = Record & {
+ default?: AppPageComponent | null | undefined;
+};
+
+export type AppPageErrorModule = Record & {
+ default?: AppPageErrorComponent | null | undefined;
+};
+
+export type AppPageRouteWiringSlot<
+ TModule extends AppPageModule = AppPageModule,
+ TErrorModule extends AppPageErrorModule = AppPageErrorModule,
+> = {
+ default?: TModule | null;
+ error?: TErrorModule | null;
+ layout?: TModule | null;
+ layoutIndex: number;
+ loading?: TModule | null;
+ page?: TModule | 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;
+ templateTreePositions?: readonly number[] | null;
+ templates?: readonly (TModule | null | undefined)[] | null;
+};
+
+export type AppPageSlotOverride = {
+ pageModule: TModule;
+ params?: AppPageParams;
+ props?: Readonly>;
+};
+
+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;
+ treePath: string;
+ treePosition: number;
+};
+
+export type BuildAppPageRouteElementOptions<
+ TModule extends AppPageModule = AppPageModule,
+ TErrorModule extends AppPageErrorModule = AppPageErrorModule,
+> = {
+ element: ReactNode;
+ globalErrorModule?: TErrorModule | null;
+ makeThenableParams: (params: AppPageParams) => unknown;
+ matchedParams: AppPageParams;
+ resolvedMetadata: Metadata | null;
+ resolvedViewport: Viewport;
+ rootNotFoundModule?: TModule | null;
+ route: AppPageRouteWiringRoute;
+ slotOverrides?: Readonly>> | null;
+};
+
+export type BuildAppPageElementsOptions<
+ TModule extends AppPageModule = AppPageModule,
+ TErrorModule extends AppPageErrorModule = AppPageErrorModule,
+> = BuildAppPageRouteElementOptions & {
+ routePath: string;
+};
+
+type AppPageTemplateEntry = {
+ id: string;
+ templateModule?: TModule | null | undefined;
+ treePath: string;
+ treePosition: number;
+};
+
+function getDefaultExport(
+ module: TModule | null | undefined,
+): AppPageComponent | null {
+ return module?.default ?? null;
+}
+
+function getErrorBoundaryExport(
+ module: TModule | null | undefined,
+): AppPageErrorComponent | null {
+ return module?.default ?? null;
+}
+
+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<
+ TModule extends AppPageModule,
+ TErrorModule extends AppPageErrorModule,
+>(
+ 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 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,
+ 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;
+}
+
+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 layoutEntriesByTreePosition = new Map>();
+ const templateEntriesByTreePosition = new Map>();
+ for (const layoutEntry of layoutEntries) {
+ layoutEntriesByTreePosition.set(layoutEntry.treePosition, layoutEntry);
+ }
+ for (const templateEntry of templateEntries) {
+ templateEntriesByTreePosition.set(templateEntry.treePosition, templateEntry);
+ }
+ const layoutIndicesByTreePosition = new Map();
+ for (let index = 0; index < layoutEntries.length; index++) {
+ layoutIndicesByTreePosition.set(layoutEntries[index].treePosition, index);
+ }
+ const layoutDependenciesByIndex = new Map();
+ const layoutDependenciesBefore: AppRenderDependency[][] = [];
+ const slotDependenciesByLayoutIndex: AppRenderDependency[][] = [];
+ const templateDependenciesById = new Map();
+ const templateDependenciesBeforeById = new Map();
+ const pageDependencies: AppRenderDependency[] = [];
+ const routeThenableParams = options.makeThenableParams(options.matchedParams);
+ const rootLayoutTreePath = layoutEntries[0]?.treePath ?? null;
+ const orderedTreePositions = Array.from(
+ new Set([
+ ...layoutEntries.map((entry) => entry.treePosition),
+ ...templateEntries.map((entry) => entry.treePosition),
+ ]),
+ ).sort((left, right) => left - right);
+
+ for (const treePosition of orderedTreePositions) {
+ const layoutIndex = layoutIndicesByTreePosition.get(treePosition);
+ if (layoutIndex !== undefined) {
+ const layoutEntry = layoutEntries[layoutIndex];
+ layoutDependenciesBefore[layoutIndex] = [...pageDependencies];
+ if (getDefaultExport(layoutEntry.layoutModule)) {
+ const layoutDependency = createAppRenderDependency();
+ layoutDependenciesByIndex.set(layoutIndex, layoutDependency);
+ pageDependencies.push(layoutDependency);
+ }
+ slotDependenciesByLayoutIndex[layoutIndex] = [...pageDependencies];
+ }
+
+ const templateEntry = templateEntriesByTreePosition.get(treePosition);
+ if (!templateEntry || !getDefaultExport(templateEntry.templateModule)) {
+ continue;
+ }
+
+ const templateDependency = createAppRenderDependency();
+ templateDependenciesById.set(templateEntry.id, templateDependency);
+ templateDependenciesBeforeById.set(templateEntry.id, [...pageDependencies]);
+ pageDependencies.push(templateDependency);
+ }
+
+ elements[APP_ROUTE_KEY] = routeId;
+ elements[APP_ROOT_LAYOUT_KEY] = rootLayoutTreePath;
+ elements[pageId] = renderAfterAppDependencies(options.element, pageDependencies);
+
+ for (const templateEntry of templateEntries) {
+ const templateComponent = getDefaultExport(templateEntry.templateModule);
+ if (!templateComponent) {
+ continue;
+ }
+ const TemplateComponent = templateComponent;
+ const templateDependency = templateDependenciesById.get(templateEntry.id);
+ const templateElement = templateDependency ? (
+ renderWithAppDependencyBarrier(
+
+
+ ,
+ templateDependency,
+ )
+ ) : (
+
+
+
+ );
+ elements[templateEntry.id] = renderAfterAppDependencies(
+ templateElement,
+ templateDependenciesBeforeById.get(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;
+ const layoutDependency = layoutDependenciesByIndex.get(index);
+ const layoutElement = layoutDependency ? (
+ renderWithAppDependencyBarrier(
+
+
+ ,
+ layoutDependency,
+ )
+ ) : (
+
+
+
+ );
+ elements[layoutEntry.id] = renderAfterAppDependencies(
+ layoutElement,
+ layoutDependenciesBefore[index] ?? [],
+ );
+ }
+
+ 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] = renderAfterAppDependencies(
+ slotElement,
+ targetIndex >= 0 ? (slotDependenciesByLayoutIndex[targetIndex] ?? []) : [],
+ );
+ }
+
+ 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 = orderedTreePositions.length - 1; index >= 0; index--) {
+ const treePosition = orderedTreePositions[index];
+ const templateEntry = templateEntriesByTreePosition.get(treePosition);
+ if (templateEntry) {
+ routeChildren = (
+
+ {routeChildren}
+
+ );
+ }
+
+ const layoutEntry = layoutEntriesByTreePosition.get(treePosition);
+ if (!layoutEntry) {
+ continue;
+ }
+ let layoutChildren = routeChildren;
+ 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 === layoutIndicesByTreePosition.get(treePosition);
+ })
+ .map(([slotName]) => [slotName, []]),
+ ),
+ }}
+ >
+
+ {layoutChildren}
+
+
+ );
+ }
+
+ const globalErrorComponent = getErrorBoundaryExport(options.globalErrorModule);
+ if (globalErrorComponent) {
+ routeChildren = {routeChildren};
+ }
+
+ elements[routeId] = (
+ <>
+ {createAppPageRouteHead(options.resolvedMetadata, options.resolvedViewport)}
+ {routeChildren}
+ >
+ );
+
+ return elements;
+}
diff --git a/packages/vinext/src/server/app-render-dependency.tsx b/packages/vinext/src/server/app-render-dependency.tsx
new file mode 100644
index 000000000..09182280b
--- /dev/null
+++ b/packages/vinext/src/server/app-render-dependency.tsx
@@ -0,0 +1,59 @@
+import { type ReactNode } from "react";
+
+export type AppRenderDependency = {
+ promise: Promise;
+ release: () => void;
+};
+
+export function createAppRenderDependency(): AppRenderDependency {
+ let released = false;
+ let resolve!: () => void;
+
+ const promise = new Promise((promiseResolve) => {
+ resolve = promiseResolve;
+ });
+
+ return {
+ promise,
+ release() {
+ if (released) {
+ return;
+ }
+ released = true;
+ resolve();
+ },
+ };
+}
+
+export function renderAfterAppDependencies(
+ children: ReactNode,
+ dependencies: readonly AppRenderDependency[],
+): ReactNode {
+ if (dependencies.length === 0) {
+ return children;
+ }
+
+ async function AwaitAppRenderDependencies() {
+ await Promise.all(dependencies.map((dependency) => dependency.promise));
+ return children;
+ }
+
+ return ;
+}
+
+export function renderWithAppDependencyBarrier(
+ children: ReactNode,
+ dependency: AppRenderDependency,
+): ReactNode {
+ function ReleaseAppRenderDependency() {
+ dependency.release();
+ return null;
+ }
+
+ return (
+ <>
+ {children}
+
+ >
+ );
+}
diff --git a/packages/vinext/src/server/app-ssr-entry.ts b/packages/vinext/src/server/app-ssr-entry.ts
index 32d754c47..f0a50c88d 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,12 @@ 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 AppWireElements,
+} from "./app-elements.js";
+import { ElementsContext, Slot } from "../shims/slot.js";
export type FontPreload = {
href: string;
@@ -167,13 +173,20 @@ export async function handleSsr(
const [ssrStream, embedStream] = rscStream.tee();
const rscEmbed = createRscEmbedTransform(embedStream);
- let flightRoot: Promise | null = null;
+ let flightRoot: PromiseLike | null = null;
function VinextFlightRoot(): ReactNode {
if (!flightRoot) {
- flightRoot = createFromReadableStream(ssrStream);
+ flightRoot = createFromReadableStream(ssrStream);
}
- return flightRoot as unknown as ReactNode;
+ const wireElements = use(flightRoot);
+ const elements = normalizeAppElements(wireElements);
+ const metadata = readAppElementsMetadata(elements);
+ return createReactElement(
+ ElementsContext.Provider,
+ { value: elements },
+ createReactElement(Slot, { id: metadata.routeId }),
+ );
}
const root = createReactElement(VinextFlightRoot);
diff --git a/packages/vinext/src/shims/error-boundary.tsx b/packages/vinext/src/shims/error-boundary.tsx
index 1f097ba1c..cadcbdb92 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,13 +23,26 @@ 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 {
+ 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.
@@ -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/packages/vinext/src/shims/slot.tsx b/packages/vinext/src/shims/slot.tsx
new file mode 100644
index 000000000..8c8d738bb
--- /dev/null
+++ b/packages/vinext/src/shims/slot.tsx
@@ -0,0 +1,71 @@
+"use client";
+
+import * as React from "react";
+import { UNMATCHED_SLOT, type AppElementValue, type AppElements } from "../server/app-elements.js";
+import { notFound } from "./navigation.js";
+
+const EMPTY_ELEMENTS: AppElements = {};
+
+export { UNMATCHED_SLOT };
+
+/**
+ * Holds resolved AppElements (not a Promise). React 19's use(Promise) during
+ * hydration triggers "async Client Component" for native Promises that lack
+ * React's internal .status property. Storing resolved values sidesteps this.
+ */
+export const ElementsContext = React.createContext(EMPTY_ELEMENTS);
+
+export const ChildrenContext = React.createContext(null);
+
+export const ParallelSlotsContext = React.createContext
+> | null>(null);
+
+export function mergeElements(prev: AppElements, next: AppElements): AppElements {
+ const merged: Record = { ...prev, ...next };
+ // On soft navigation, unmatched parallel slots preserve their previous subtree
+ // instead of firing notFound(). Only hard navigation (full page load) should 404.
+ // This matches Next.js behavior for parallel route persistence.
+ for (const key of Object.keys(merged)) {
+ if (key.startsWith("slot:") && merged[key] === UNMATCHED_SLOT && key in prev) {
+ merged[key] = prev[key];
+ }
+ }
+ return merged;
+}
+
+export function Slot({
+ id,
+ children,
+ parallelSlots,
+}: {
+ id: string;
+ children?: React.ReactNode;
+ parallelSlots?: Readonly>;
+}) {
+ const elements = 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/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap
index 7c8a503e2..ade238e1a 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 {
+ buildAppPageElements as __buildAppPageElements,
+ 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) {
@@ -422,6 +392,7 @@ const routes = [
routeHandler: null,
layouts: [mod_1],
routeSegments: [],
+ templateTreePositions: [],
layoutTreePositions: [0],
templates: [],
errors: [null],
@@ -444,6 +415,7 @@ const routes = [
routeHandler: null,
layouts: [mod_1],
routeSegments: ["about"],
+ templateTreePositions: [],
layoutTreePositions: [0],
templates: [],
errors: [null],
@@ -466,6 +438,7 @@ const routes = [
routeHandler: null,
layouts: [mod_1, mod_4],
routeSegments: ["blog",":slug"],
+ templateTreePositions: [],
layoutTreePositions: [0,1],
templates: [],
errors: [null, null],
@@ -488,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],
@@ -544,7 +518,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 +564,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,
@@ -674,10 +648,21 @@ 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");
+ const _noExportRouteId = "route:" + routePath;
+ let _noExportRootLayout = null;
+ if (route.layouts?.length > 0) {
+ const _tp = route.layoutTreePositions?.[0] ?? 0;
+ const _segs = route.routeSegments?.slice(0, _tp) ?? [];
+ _noExportRootLayout = _segs.length === 0 ? "/" : "/" + _segs.join("/");
+ }
+ return {
+ __route: _noExportRouteId,
+ __rootLayout: _noExportRootLayout,
+ [_noExportRouteId]: createElement("div", null, "Page has no default export"),
+ };
}
// Resolve metadata and viewport from layouts and page.
@@ -756,12 +741,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 +760,26 @@ 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 __buildAppPageElements({
+ element: createElement(PageComponent, pageProps),
+ globalErrorModule: null,
+ makeThenableParams,
+ matchedParams: params,
+ resolvedMetadata,
+ resolvedViewport,
+ routePath,
+ 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,
+ });
}
@@ -1626,9 +1451,20 @@ 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");
+ const _actionRouteId = "route:" + cleanPathname;
+ element = {
+ __route: _actionRouteId,
+ __rootLayout: null,
+ [_actionRouteId]: createElement("div", null, "Page not found"),
+ };
}
const onRenderError = createRscOnErrorHandler(
@@ -1950,7 +1786,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);
@@ -1999,7 +1841,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,
@@ -2047,7 +1897,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);
@@ -2235,13 +2085,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 +2121,10 @@ import {
renderAppPageErrorBoundary as __renderAppPageErrorBoundary,
renderAppPageHttpAccessFallback as __renderAppPageHttpAccessFallback,
} from "/packages/vinext/src/server/app-page-boundary-render.js";
+import {
+ buildAppPageElements as __buildAppPageElements,
+ 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 +2292,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) {
@@ -2616,6 +2436,7 @@ const routes = [
routeHandler: null,
layouts: [mod_1],
routeSegments: [],
+ templateTreePositions: [],
layoutTreePositions: [0],
templates: [],
errors: [null],
@@ -2638,6 +2459,7 @@ const routes = [
routeHandler: null,
layouts: [mod_1],
routeSegments: ["about"],
+ templateTreePositions: [],
layoutTreePositions: [0],
templates: [],
errors: [null],
@@ -2660,6 +2482,7 @@ const routes = [
routeHandler: null,
layouts: [mod_1, mod_4],
routeSegments: ["blog",":slug"],
+ templateTreePositions: [],
layoutTreePositions: [0,1],
templates: [],
errors: [null, null],
@@ -2682,6 +2505,7 @@ const routes = [
routeHandler: null,
layouts: [mod_1, mod_6],
routeSegments: ["dashboard"],
+ templateTreePositions: [1],
layoutTreePositions: [0,1],
templates: [mod_7],
errors: [null, mod_9],
@@ -2738,7 +2562,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 +2608,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,
@@ -2868,10 +2692,21 @@ 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");
+ const _noExportRouteId = "route:" + routePath;
+ let _noExportRootLayout = null;
+ if (route.layouts?.length > 0) {
+ const _tp = route.layoutTreePositions?.[0] ?? 0;
+ const _segs = route.routeSegments?.slice(0, _tp) ?? [];
+ _noExportRootLayout = _segs.length === 0 ? "/" : "/" + _segs.join("/");
+ }
+ return {
+ __route: _noExportRouteId,
+ __rootLayout: _noExportRootLayout,
+ [_noExportRouteId]: createElement("div", null, "Page has no default export"),
+ };
}
// Resolve metadata and viewport from layouts and page.
@@ -2950,12 +2785,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 +2804,26 @@ 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 __buildAppPageElements({
+ element: createElement(PageComponent, pageProps),
+ globalErrorModule: null,
+ makeThenableParams,
+ matchedParams: params,
+ resolvedMetadata,
+ resolvedViewport,
+ routePath,
+ 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,
+ });
}
@@ -3823,9 +3498,20 @@ 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");
+ const _actionRouteId = "route:" + cleanPathname;
+ element = {
+ __route: _actionRouteId,
+ __rootLayout: null,
+ [_actionRouteId]: createElement("div", null, "Page not found"),
+ };
}
const onRenderError = createRscOnErrorHandler(
@@ -4147,7 +3833,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);
@@ -4196,7 +3888,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,
@@ -4244,7 +3944,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);
@@ -4432,13 +4132,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 +4168,10 @@ import {
renderAppPageErrorBoundary as __renderAppPageErrorBoundary,
renderAppPageHttpAccessFallback as __renderAppPageHttpAccessFallback,
} from "/packages/vinext/src/server/app-page-boundary-render.js";
+import {
+ buildAppPageElements as __buildAppPageElements,
+ 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 +4339,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) {
@@ -4814,6 +4484,7 @@ const routes = [
routeHandler: null,
layouts: [mod_1],
routeSegments: [],
+ templateTreePositions: [],
layoutTreePositions: [0],
templates: [],
errors: [null],
@@ -4836,6 +4507,7 @@ const routes = [
routeHandler: null,
layouts: [mod_1],
routeSegments: ["about"],
+ templateTreePositions: [],
layoutTreePositions: [0],
templates: [],
errors: [null],
@@ -4858,6 +4530,7 @@ const routes = [
routeHandler: null,
layouts: [mod_1, mod_4],
routeSegments: ["blog",":slug"],
+ templateTreePositions: [],
layoutTreePositions: [0,1],
templates: [],
errors: [null, null],
@@ -4880,6 +4553,7 @@ const routes = [
routeHandler: null,
layouts: [mod_1, mod_6],
routeSegments: ["dashboard"],
+ templateTreePositions: [1],
layoutTreePositions: [0,1],
templates: [mod_7],
errors: [null, mod_9],
@@ -4936,7 +4610,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 +4656,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,
@@ -5066,10 +4740,21 @@ 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");
+ const _noExportRouteId = "route:" + routePath;
+ let _noExportRootLayout = null;
+ if (route.layouts?.length > 0) {
+ const _tp = route.layoutTreePositions?.[0] ?? 0;
+ const _segs = route.routeSegments?.slice(0, _tp) ?? [];
+ _noExportRootLayout = _segs.length === 0 ? "/" : "/" + _segs.join("/");
+ }
+ return {
+ __route: _noExportRouteId,
+ __rootLayout: _noExportRootLayout,
+ [_noExportRouteId]: createElement("div", null, "Page has no default export"),
+ };
}
// Resolve metadata and viewport from layouts and page.
@@ -5148,12 +4833,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 +4852,26 @@ 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 __buildAppPageElements({
+ element: createElement(PageComponent, pageProps),
+ globalErrorModule: mod_11,
+ makeThenableParams,
+ matchedParams: params,
+ resolvedMetadata,
+ resolvedViewport,
+ routePath,
+ 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,
+ });
}
@@ -6026,9 +5543,20 @@ 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");
+ const _actionRouteId = "route:" + cleanPathname;
+ element = {
+ __route: _actionRouteId,
+ __rootLayout: null,
+ [_actionRouteId]: createElement("div", null, "Page not found"),
+ };
}
const onRenderError = createRscOnErrorHandler(
@@ -6350,7 +5878,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);
@@ -6399,7 +5933,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,
@@ -6447,7 +5989,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);
@@ -6635,13 +6177,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 +6213,10 @@ import {
renderAppPageErrorBoundary as __renderAppPageErrorBoundary,
renderAppPageHttpAccessFallback as __renderAppPageHttpAccessFallback,
} from "/packages/vinext/src/server/app-page-boundary-render.js";
+import {
+ buildAppPageElements as __buildAppPageElements,
+ 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 +6384,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) {
@@ -7046,6 +6558,7 @@ const routes = [
routeHandler: null,
layouts: [mod_1],
routeSegments: [],
+ templateTreePositions: [],
layoutTreePositions: [0],
templates: [],
errors: [null],
@@ -7068,6 +6581,7 @@ const routes = [
routeHandler: null,
layouts: [mod_1],
routeSegments: ["about"],
+ templateTreePositions: [],
layoutTreePositions: [0],
templates: [],
errors: [null],
@@ -7090,6 +6604,7 @@ const routes = [
routeHandler: null,
layouts: [mod_1, mod_4],
routeSegments: ["blog",":slug"],
+ templateTreePositions: [],
layoutTreePositions: [0,1],
templates: [],
errors: [null, null],
@@ -7112,6 +6627,7 @@ const routes = [
routeHandler: null,
layouts: [mod_1, mod_6],
routeSegments: ["dashboard"],
+ templateTreePositions: [1],
layoutTreePositions: [0,1],
templates: [mod_7],
errors: [null, mod_9],
@@ -7168,7 +6684,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 +6730,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,
@@ -7298,10 +6814,21 @@ 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");
+ const _noExportRouteId = "route:" + routePath;
+ let _noExportRootLayout = null;
+ if (route.layouts?.length > 0) {
+ const _tp = route.layoutTreePositions?.[0] ?? 0;
+ const _segs = route.routeSegments?.slice(0, _tp) ?? [];
+ _noExportRootLayout = _segs.length === 0 ? "/" : "/" + _segs.join("/");
+ }
+ return {
+ __route: _noExportRouteId,
+ __rootLayout: _noExportRootLayout,
+ [_noExportRouteId]: createElement("div", null, "Page has no default export"),
+ };
}
// Resolve metadata and viewport from layouts and page.
@@ -7380,12 +6907,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 +6926,26 @@ 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 __buildAppPageElements({
+ element: createElement(PageComponent, pageProps),
+ globalErrorModule: null,
+ makeThenableParams,
+ matchedParams: params,
+ resolvedMetadata,
+ resolvedViewport,
+ routePath,
+ 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,
+ });
}
@@ -8253,9 +7620,20 @@ 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");
+ const _actionRouteId = "route:" + cleanPathname;
+ element = {
+ __route: _actionRouteId,
+ __rootLayout: null,
+ [_actionRouteId]: createElement("div", null, "Page not found"),
+ };
}
const onRenderError = createRscOnErrorHandler(
@@ -8577,7 +7955,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);
@@ -8626,7 +8010,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,
@@ -8674,7 +8066,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);
@@ -8862,13 +8254,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 +8290,10 @@ import {
renderAppPageErrorBoundary as __renderAppPageErrorBoundary,
renderAppPageHttpAccessFallback as __renderAppPageHttpAccessFallback,
} from "/packages/vinext/src/server/app-page-boundary-render.js";
+import {
+ buildAppPageElements as __buildAppPageElements,
+ 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 +8461,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) {
@@ -9244,6 +8606,7 @@ const routes = [
routeHandler: null,
layouts: [mod_1],
routeSegments: [],
+ templateTreePositions: [],
layoutTreePositions: [0],
templates: [],
errors: [null],
@@ -9266,6 +8629,7 @@ const routes = [
routeHandler: null,
layouts: [mod_1],
routeSegments: ["about"],
+ templateTreePositions: [],
layoutTreePositions: [0],
templates: [],
errors: [null],
@@ -9288,6 +8652,7 @@ const routes = [
routeHandler: null,
layouts: [mod_1, mod_4],
routeSegments: ["blog",":slug"],
+ templateTreePositions: [],
layoutTreePositions: [0,1],
templates: [],
errors: [null, null],
@@ -9310,6 +8675,7 @@ const routes = [
routeHandler: null,
layouts: [mod_1, mod_6],
routeSegments: ["dashboard"],
+ templateTreePositions: [1],
layoutTreePositions: [0,1],
templates: [mod_7],
errors: [null, mod_9],
@@ -9372,7 +8738,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 +8784,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,
@@ -9502,10 +8868,21 @@ 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");
+ const _noExportRouteId = "route:" + routePath;
+ let _noExportRootLayout = null;
+ if (route.layouts?.length > 0) {
+ const _tp = route.layoutTreePositions?.[0] ?? 0;
+ const _segs = route.routeSegments?.slice(0, _tp) ?? [];
+ _noExportRootLayout = _segs.length === 0 ? "/" : "/" + _segs.join("/");
+ }
+ return {
+ __route: _noExportRouteId,
+ __rootLayout: _noExportRootLayout,
+ [_noExportRouteId]: createElement("div", null, "Page has no default export"),
+ };
}
// Resolve metadata and viewport from layouts and page.
@@ -9584,12 +8961,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 +8980,26 @@ 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 __buildAppPageElements({
+ element: createElement(PageComponent, pageProps),
+ globalErrorModule: null,
+ makeThenableParams,
+ matchedParams: params,
+ resolvedMetadata,
+ resolvedViewport,
+ routePath,
+ 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,
+ });
}
@@ -10454,9 +9671,20 @@ 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");
+ const _actionRouteId = "route:" + cleanPathname;
+ element = {
+ __route: _actionRouteId,
+ __rootLayout: null,
+ [_actionRouteId]: createElement("div", null, "Page not found"),
+ };
}
const onRenderError = createRscOnErrorHandler(
@@ -10778,7 +10006,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);
@@ -10827,7 +10061,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,
@@ -10875,7 +10117,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);
@@ -11063,13 +10305,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 +10341,10 @@ import {
renderAppPageErrorBoundary as __renderAppPageErrorBoundary,
renderAppPageHttpAccessFallback as __renderAppPageHttpAccessFallback,
} from "/packages/vinext/src/server/app-page-boundary-render.js";
+import {
+ buildAppPageElements as __buildAppPageElements,
+ 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 +10512,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) {
@@ -11444,6 +10656,7 @@ const routes = [
routeHandler: null,
layouts: [mod_1],
routeSegments: [],
+ templateTreePositions: [],
layoutTreePositions: [0],
templates: [],
errors: [null],
@@ -11466,6 +10679,7 @@ const routes = [
routeHandler: null,
layouts: [mod_1],
routeSegments: ["about"],
+ templateTreePositions: [],
layoutTreePositions: [0],
templates: [],
errors: [null],
@@ -11488,6 +10702,7 @@ const routes = [
routeHandler: null,
layouts: [mod_1, mod_4],
routeSegments: ["blog",":slug"],
+ templateTreePositions: [],
layoutTreePositions: [0,1],
templates: [],
errors: [null, null],
@@ -11510,6 +10725,7 @@ const routes = [
routeHandler: null,
layouts: [mod_1, mod_6],
routeSegments: ["dashboard"],
+ templateTreePositions: [1],
layoutTreePositions: [0,1],
templates: [mod_7],
errors: [null, mod_9],
@@ -11566,7 +10782,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 +10828,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,
@@ -11696,10 +10912,21 @@ 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");
+ const _noExportRouteId = "route:" + routePath;
+ let _noExportRootLayout = null;
+ if (route.layouts?.length > 0) {
+ const _tp = route.layoutTreePositions?.[0] ?? 0;
+ const _segs = route.routeSegments?.slice(0, _tp) ?? [];
+ _noExportRootLayout = _segs.length === 0 ? "/" : "/" + _segs.join("/");
+ }
+ return {
+ __route: _noExportRouteId,
+ __rootLayout: _noExportRootLayout,
+ [_noExportRouteId]: createElement("div", null, "Page has no default export"),
+ };
}
// Resolve metadata and viewport from layouts and page.
@@ -11778,12 +11005,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 +11024,26 @@ 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 __buildAppPageElements({
+ element: createElement(PageComponent, pageProps),
+ globalErrorModule: null,
+ makeThenableParams,
+ matchedParams: params,
+ resolvedMetadata,
+ resolvedViewport,
+ routePath,
+ 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,
+ });
}
@@ -13012,9 +12079,20 @@ 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");
+ const _actionRouteId = "route:" + cleanPathname;
+ element = {
+ __route: _actionRouteId,
+ __rootLayout: null,
+ [_actionRouteId]: createElement("div", null, "Page not found"),
+ };
}
const onRenderError = createRscOnErrorHandler(
@@ -13336,7 +12414,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);
@@ -13385,7 +12469,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,
@@ -13433,7 +12525,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..813b55b11
--- /dev/null
+++ b/tests/app-browser-entry.test.ts
@@ -0,0 +1,161 @@
+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 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("/");
+ expect(nextState.elements).toMatchObject({
+ "layout:/": expect.anything(),
+ "page:/next": expect.anything(),
+ });
+ });
+
+ it("replaces elements on replace", () => {
+ 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);
+ expect(nextState.elements).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: Promise.resolve(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: Promise.resolve(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..a1c83a35f 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,26 @@ 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)) {
+ // Flat map payload — extract the route element and render it to HTML
+ // (mirrors what the real SSR entry does after deserializing the Flight stream)
+ const record = element as Record;
+ const routeId = record.__route;
+ if (typeof routeId === "string" && React.isValidElement(record[routeId])) {
+ return createStreamFromMarkup(
+ ReactDOMServer.renderToStaticMarkup(record[routeId] as React.ReactNode),
+ );
+ }
+ 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 +77,7 @@ function createCommonOptions() {
resolveChildSegments() {
return [];
},
- rootLayouts: [],
+ rootLayouts: EMPTY_ROOT_LAYOUTS,
};
}
@@ -122,6 +139,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 +201,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 +257,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: "missing" },
+ renderToReadableStream: renderWirePayloadToStream,
+ route: {
+ error: routeErrorModule,
+ layoutTreePositions: [0],
+ layouts: [rootLayoutModule],
+ params: { slug: "missing" },
+ 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
new file mode 100644
index 000000000..8ca0844d8
--- /dev/null
+++ b/tests/app-page-route-wiring.test.ts
@@ -0,0 +1,347 @@
+import { Fragment, createElement, isValidElement, type ReactNode } from "react";
+import { describe, expect, it } from "vite-plus/test";
+import { useSelectedLayoutSegments } from "../packages/vinext/src/shims/navigation.js";
+import type { AppElements } from "../packages/vinext/src/server/app-elements.js";
+import {
+ buildAppPageElements,
+ 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;
+}
+
+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(node: ReactNode): Promise {
+ const { renderToReadableStream } = await import("react-dom/server.edge");
+ const stream = await renderToReadableStream(node, {
+ onError(error: unknown) {
+ throw error instanceof Error ? error : new Error(String(error));
+ },
+ });
+
+ return readStream(stream);
+}
+
+async function renderRouteEntry(elements: AppElements, routeId: string): Promise {
+ const { ElementsContext, Slot } = await import("../packages/vinext/src/shims/slot.js");
+ return renderHtml(
+ createElement(
+ ElementsContext.Provider,
+ { value: elements },
+ createElement(Slot, { id: routeId }),
+ ),
+ );
+}
+
+async function withTimeout(promise: Promise, timeoutMs: number): Promise {
+ return new Promise((resolve, reject) => {
+ const timeoutId = setTimeout(() => {
+ reject(new Error(`Timed out after ${timeoutMs}ms`));
+ }, timeoutMs);
+
+ promise.then(
+ (value) => {
+ clearTimeout(timeoutId);
+ resolve(value);
+ },
+ (error: unknown) => {
+ clearTimeout(timeoutId);
+ reject(error);
+ },
+ );
+ });
+}
+
+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");
+}
+
+function LayoutWithoutChildren() {
+ return createElement("div", { "data-layout": "without-children" }, "Layout only");
+}
+
+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("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();
+ });
+
+ it("does not deadlock when a layout renders without children", async () => {
+ const elements = buildAppPageElements({
+ element: createElement("main", null, "Page content"),
+ makeThenableParams(params) {
+ return Promise.resolve(params);
+ },
+ matchedParams: {},
+ resolvedMetadata: null,
+ resolvedViewport: {},
+ route: {
+ error: null,
+ errors: [null],
+ layoutTreePositions: [0],
+ layouts: [{ default: LayoutWithoutChildren }],
+ loading: null,
+ notFound: null,
+ notFounds: [null],
+ routeSegments: [],
+ slots: null,
+ templateTreePositions: [],
+ templates: [],
+ },
+ routePath: "/layout-only",
+ rootNotFoundModule: null,
+ });
+
+ const body = await withTimeout(
+ renderHtml(
+ createElement(
+ Fragment,
+ null,
+ readChildren(elements["layout:/"]),
+ readChildren(elements["page:/layout-only"]),
+ ),
+ ),
+ 1_000,
+ );
+
+ expect(body).toContain("Layout only");
+ expect(body).toContain("Page content");
+ });
+
+ it("waits for template-only segments before serializing the page entry", async () => {
+ let activeLocale = "en";
+
+ async function AsyncTemplate(props: Record) {
+ await Promise.resolve();
+ activeLocale = "de";
+ return createElement("div", { "data-template": "async" }, readChildren(props.children));
+ }
+
+ function LocalePage() {
+ return createElement("main", null, `page:${activeLocale}`);
+ }
+
+ const elements = buildAppPageElements({
+ element: createElement(LocalePage),
+ makeThenableParams(params) {
+ return Promise.resolve(params);
+ },
+ matchedParams: {},
+ resolvedMetadata: null,
+ resolvedViewport: {},
+ route: {
+ error: null,
+ errors: [],
+ layoutTreePositions: [],
+ layouts: [],
+ loading: null,
+ notFound: null,
+ notFounds: [],
+ routeSegments: ["blog"],
+ slots: null,
+ templateTreePositions: [1],
+ templates: [{ default: AsyncTemplate }],
+ },
+ routePath: "/blog",
+ rootNotFoundModule: null,
+ });
+
+ const body = await renderHtml(
+ createElement(
+ Fragment,
+ null,
+ readChildren(elements["template:/blog"]),
+ readChildren(elements["page:/blog"]),
+ ),
+ );
+
+ expect(body).toContain("page:de");
+ expect(body).not.toContain("page:en");
+ });
+
+ it("renders template-only segments in the route entry even without a matching layout", async () => {
+ function BlogTemplate(props: Record) {
+ return createElement("div", { "data-template": "blog" }, readChildren(props.children));
+ }
+
+ function BlogPage() {
+ return createElement("main", null, "Blog page");
+ }
+
+ const elements = buildAppPageElements({
+ element: createElement(BlogPage),
+ makeThenableParams(params) {
+ return Promise.resolve(params);
+ },
+ matchedParams: {},
+ resolvedMetadata: null,
+ resolvedViewport: {},
+ route: {
+ error: null,
+ errors: [null],
+ layoutTreePositions: [0],
+ layouts: [{ default: RootLayout }],
+ loading: null,
+ notFound: null,
+ notFounds: [null],
+ routeSegments: ["blog"],
+ slots: null,
+ templateTreePositions: [1],
+ templates: [{ default: BlogTemplate }],
+ },
+ routePath: "/blog",
+ rootNotFoundModule: null,
+ });
+
+ const body = await renderRouteEntry(elements, "route:/blog");
+
+ expect(body).toContain('data-layout="root"');
+ expect(body).toContain('data-template="blog"');
+ expect(body).toContain("Blog page");
+ });
+});
diff --git a/tests/app-render-dependency.test.ts b/tests/app-render-dependency.test.ts
new file mode 100644
index 000000000..03f57da35
--- /dev/null
+++ b/tests/app-render-dependency.test.ts
@@ -0,0 +1,83 @@
+import { createElement } from "react";
+import { renderToReadableStream } from "react-dom/server.edge";
+import { describe, expect, it } from "vite-plus/test";
+import {
+ createAppRenderDependency,
+ renderAfterAppDependencies,
+ renderWithAppDependencyBarrier,
+} from "../packages/vinext/src/server/app-render-dependency.js";
+
+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.ReactNode): Promise {
+ const stream = await renderToReadableStream(element, {
+ onError(error: unknown) {
+ throw error instanceof Error ? error : new Error(String(error));
+ },
+ });
+ await stream.allReady;
+ return readStream(stream);
+}
+
+describe("app render dependency helpers", () => {
+ it("documents that React can render a sync sibling before an async sibling completes", async () => {
+ let activeLocale = "en";
+
+ async function LocaleLayout() {
+ await Promise.resolve();
+ activeLocale = "de";
+ return createElement("div", null, "layout");
+ }
+
+ function LocalePage() {
+ return createElement("p", null, `page:${activeLocale}`);
+ }
+
+ const body = await renderHtml(
+ createElement("div", null, createElement(LocaleLayout), createElement(LocalePage)),
+ );
+
+ expect(body).toContain("page:en");
+ });
+
+ it("waits to serialize dependent entries until the barrier entry has rendered", async () => {
+ let activeLocale = "en";
+ const layoutDependency = createAppRenderDependency();
+
+ async function LocaleLayout() {
+ await Promise.resolve();
+ activeLocale = "de";
+ return createElement("div", null, renderWithAppDependencyBarrier("layout", layoutDependency));
+ }
+
+ function LocalePage() {
+ return createElement("p", null, `page:${activeLocale}`);
+ }
+
+ const body = await renderHtml(
+ createElement(
+ "div",
+ null,
+ createElement(LocaleLayout),
+ renderAfterAppDependencies(createElement(LocalePage), [layoutDependency]),
+ ),
+ );
+
+ expect(body).toContain("page:de");
+ expect(body).not.toContain("page:en");
+ });
+});
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/e2e/app-router/instrumentation.spec.ts b/tests/e2e/app-router/instrumentation.spec.ts
index 6c9ccdd0c..68b78b528 100644
--- a/tests/e2e/app-router/instrumentation.spec.ts
+++ b/tests/e2e/app-router/instrumentation.spec.ts
@@ -62,9 +62,9 @@ test.describe("instrumentation.ts onRequestError", () => {
const data = await stateRes.json();
expect(data.errors.length).toBeGreaterThanOrEqual(1);
- const err = data.errors[data.errors.length - 1];
+ const err = data.errors.find((e: { path: string }) => e.path === "/api/error-route");
+ expect(err).toBeTruthy();
expect(err.message).toBe("Intentional route handler error");
- expect(err.path).toBe("/api/error-route");
expect(err.method).toBe("GET");
expect(err.routerKind).toBe("App Router");
expect(err.routeType).toBe("route");
diff --git a/tests/e2e/app-router/layout-persistence.spec.ts b/tests/e2e/app-router/layout-persistence.spec.ts
new file mode 100644
index 000000000..5a6c72821
--- /dev/null
+++ b/tests/e2e/app-router/layout-persistence.spec.ts
@@ -0,0 +1,228 @@
+import { test, expect } from "@playwright/test";
+
+const BASE = "http://localhost:4174";
+
+/**
+ * Wait for the RSC browser entry to hydrate.
+ */
+async function waitForHydration(page: import("@playwright/test").Page) {
+ await expect(async () => {
+ const ready = await page.evaluate(() => "__VINEXT_RSC_ROOT__" in window);
+ expect(ready).toBe(true);
+ }).toPass({ timeout: 10_000 });
+}
+
+// ---------------------------------------------------------------------------
+// 1. Layout persistence — navigate between sibling routes, prove the layout
+// DOM survives and client state in it persists.
+// ---------------------------------------------------------------------------
+
+test.describe("Layout persistence", () => {
+ test("dashboard layout counter survives sibling navigation", async ({ page }) => {
+ await page.goto(`${BASE}/dashboard`);
+ await expect(page.locator("h1")).toHaveText("Dashboard");
+ await waitForHydration(page);
+
+ // Increment the counter in the dashboard layout
+ await page.click('[data-testid="layout-increment"]');
+ await page.click('[data-testid="layout-increment"]');
+ await page.click('[data-testid="layout-increment"]');
+ await expect(page.locator('[data-testid="layout-count"]')).toHaveText("Layout count: 3");
+
+ // Navigate to settings (sibling route under same layout)
+ await page.click('[data-testid="dash-settings-link"]');
+ await expect(page.locator("h1")).toHaveText("Settings");
+
+ // Layout counter should still be 3 — the layout was NOT remounted
+ await expect(page.locator('[data-testid="layout-count"]')).toHaveText("Layout count: 3");
+
+ // Navigate back to dashboard home
+ await page.click('[data-testid="dash-home-link"]');
+ await expect(page.locator("h1")).toHaveText("Dashboard");
+
+ // Counter should still be 3
+ await expect(page.locator('[data-testid="layout-count"]')).toHaveText("Layout count: 3");
+ });
+
+ test("layout counter resets on hard navigation", async ({ page }) => {
+ await page.goto(`${BASE}/dashboard`);
+ await waitForHydration(page);
+
+ // Increment counter
+ await page.click('[data-testid="layout-increment"]');
+ await expect(page.locator('[data-testid="layout-count"]')).toHaveText("Layout count: 1");
+
+ // Hard navigation (full page load) should reset everything
+ await page.goto(`${BASE}/dashboard`);
+ await waitForHydration(page);
+
+ await expect(page.locator('[data-testid="layout-count"]')).toHaveText("Layout count: 0");
+ });
+});
+
+// ---------------------------------------------------------------------------
+// 2. Template remount — prove template state resets on segment boundary
+// change but persists on search param change.
+// ---------------------------------------------------------------------------
+
+test.describe("Template remount", () => {
+ test("root template counter resets when navigating between top-level segments", async ({
+ page,
+ }) => {
+ await page.goto(`${BASE}/`);
+ await expect(page.locator("h1")).toHaveText("Welcome to App Router");
+ await waitForHydration(page);
+
+ // Increment the template counter
+ await page.click('[data-testid="template-increment"]');
+ await page.click('[data-testid="template-increment"]');
+ await expect(page.locator('[data-testid="template-count"]')).toHaveText("Template count: 2");
+
+ // Navigate to /about — this changes the root segment from "" to "about",
+ // so the root template should remount and the counter should reset.
+ await page.click('a[href="/about"]');
+ await expect(page.locator("h1")).toHaveText("About");
+
+ await expect(page.locator('[data-testid="template-count"]')).toHaveText("Template count: 0");
+ });
+
+ test("root template counter persists within same top-level segment", async ({ page }) => {
+ await page.goto(`${BASE}/dashboard`);
+ await expect(page.locator("h1")).toHaveText("Dashboard");
+ await waitForHydration(page);
+
+ // Increment the template counter
+ await page.click('[data-testid="template-increment"]');
+ await page.click('[data-testid="template-increment"]');
+ await expect(page.locator('[data-testid="template-count"]')).toHaveText("Template count: 2");
+
+ // Navigate to /dashboard/settings — this is still under the "dashboard"
+ // top-level segment, so the root template should NOT remount.
+ await page.click('[data-testid="dash-settings-link"]');
+ await expect(page.locator("h1")).toHaveText("Settings");
+
+ await expect(page.locator('[data-testid="template-count"]')).toHaveText("Template count: 2");
+ });
+});
+
+// ---------------------------------------------------------------------------
+// 3. Error recovery — trigger an error, navigate away via client nav,
+// navigate back, prove the error is gone and normal content renders.
+// ---------------------------------------------------------------------------
+
+test.describe("Error recovery across navigation", () => {
+ test("navigating away from error and back clears the error", async ({ page }) => {
+ await page.goto(`${BASE}/error-test`);
+ await expect(page.locator('[data-testid="error-content"]')).toBeVisible();
+ await waitForHydration(page);
+
+ // Trigger error
+ await expect(async () => {
+ await page.click('[data-testid="trigger-error"]');
+ await expect(page.locator("#error-boundary")).toBeVisible({ timeout: 2_000 });
+ }).toPass({ timeout: 15_000 });
+
+ // Error boundary should be visible
+ await expect(page.locator("#error-boundary")).toBeVisible();
+
+ // Client-navigate away to home via the link in the error boundary
+ await page.click('[data-testid="error-go-home"]');
+ await expect(page.locator("h1")).toHaveText("Welcome to App Router");
+
+ // Client-navigate back to error-test via link on home page
+ await page.click('[data-testid="error-test-link"]');
+
+ // Error should be gone — fresh page renders normally
+ await expect(page.locator('[data-testid="error-content"]')).toBeVisible({ timeout: 5_000 });
+ await expect(page.locator("#error-boundary")).not.toBeVisible();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// 4. Back/forward — navigate through a sequence, go back, prove layout
+// state survived the round trip.
+// ---------------------------------------------------------------------------
+
+test.describe("Back/forward with layout state", () => {
+ test("browser back preserves layout counter across navigation history", async ({ page }) => {
+ await page.goto(`${BASE}/dashboard`);
+ await expect(page.locator("h1")).toHaveText("Dashboard");
+ await waitForHydration(page);
+
+ // Increment layout counter to 2
+ await page.click('[data-testid="layout-increment"]');
+ await page.click('[data-testid="layout-increment"]');
+ await expect(page.locator('[data-testid="layout-count"]')).toHaveText("Layout count: 2");
+
+ // Navigate: dashboard → settings
+ await page.click('[data-testid="dash-settings-link"]');
+ await expect(page.locator("h1")).toHaveText("Settings");
+
+ // Counter should still be 2 (layout persisted)
+ await expect(page.locator('[data-testid="layout-count"]')).toHaveText("Layout count: 2");
+
+ // Increment once more while on settings
+ await page.click('[data-testid="layout-increment"]');
+ await expect(page.locator('[data-testid="layout-count"]')).toHaveText("Layout count: 3");
+
+ // Go back to dashboard
+ await page.goBack();
+ await expect(page.locator("h1")).toHaveText("Dashboard");
+
+ // Counter should still be 3 — back/forward doesn't remount the layout
+ await expect(page.locator('[data-testid="layout-count"]')).toHaveText("Layout count: 3");
+
+ // Go forward to settings
+ await page.goForward();
+ await expect(page.locator("h1")).toHaveText("Settings");
+
+ // Counter should still be 3
+ await expect(page.locator('[data-testid="layout-count"]')).toHaveText("Layout count: 3");
+ });
+});
+
+// ---------------------------------------------------------------------------
+// 5. Parallel slots — soft nav keeps slot content; hard load shows default.
+// ---------------------------------------------------------------------------
+
+test.describe("Parallel slot persistence", () => {
+ test("parallel slot content persists on soft navigation to child route", async ({ page }) => {
+ // Load /dashboard — parallel slots @team and @analytics have page.tsx
+ await page.goto(`${BASE}/dashboard`);
+ await expect(page.locator("h1")).toHaveText("Dashboard");
+ await waitForHydration(page);
+
+ // Verify slot content is visible
+ await expect(page.locator('[data-testid="team-panel"]')).toBeVisible();
+ await expect(page.locator('[data-testid="analytics-panel"]')).toBeVisible();
+ await expect(page.locator('[data-testid="team-slot"]')).toBeVisible();
+ await expect(page.locator('[data-testid="analytics-slot"]')).toBeVisible();
+
+ // Soft navigate to /dashboard/settings
+ await page.click('[data-testid="dash-settings-link"]');
+ await expect(page.locator("h1")).toHaveText("Settings");
+
+ // Parallel slot content should persist from the soft nav —
+ // the slots don't have a page.tsx for /settings, so the previous
+ // slot content is retained (absent key = persisted from prior soft nav).
+ await expect(page.locator('[data-testid="team-panel"]')).toBeVisible();
+ await expect(page.locator('[data-testid="analytics-panel"]')).toBeVisible();
+ });
+
+ test("parallel slots show default.tsx on hard navigation to child route", async ({ page }) => {
+ // Hard-load /dashboard/settings directly — slots should show default.tsx
+ await page.goto(`${BASE}/dashboard/settings`);
+ await expect(page.locator("h1")).toHaveText("Settings");
+ await waitForHydration(page);
+
+ // On hard load, slots should render their default.tsx content
+ await expect(page.locator('[data-testid="team-panel"]')).toBeVisible();
+ await expect(page.locator('[data-testid="analytics-panel"]')).toBeVisible();
+ await expect(page.locator('[data-testid="team-default"]')).toBeVisible();
+ await expect(page.locator('[data-testid="analytics-default"]')).toBeVisible();
+
+ // The page-specific slot content should NOT be visible
+ await expect(page.locator('[data-testid="team-slot"]')).not.toBeVisible();
+ await expect(page.locator('[data-testid="analytics-slot"]')).not.toBeVisible();
+ });
+});
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/error-boundary.test.ts b/tests/error-boundary.test.ts
index 3639bb9fc..65fcb3fb5 100644
--- a/tests/error-boundary.test.ts
+++ b/tests/error-boundary.test.ts
@@ -18,48 +18,81 @@ 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): {
+ 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 +103,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 +118,129 @@ 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",
+ });
+ });
+
+ 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",
+ });
});
});
diff --git a/tests/fixtures/app-basic/app/components/layout-counter.tsx b/tests/fixtures/app-basic/app/components/layout-counter.tsx
new file mode 100644
index 000000000..14d9bd94a
--- /dev/null
+++ b/tests/fixtures/app-basic/app/components/layout-counter.tsx
@@ -0,0 +1,21 @@
+"use client";
+
+import { useState } from "react";
+
+/**
+ * Client counter used to verify layout persistence.
+ * If the layout remounts, this counter resets to 0.
+ * If the layout persists across navigation, the counter retains its value.
+ */
+export function LayoutCounter() {
+ const [count, setCount] = useState(0);
+
+ return (
+
+ Layout count: {count}
+
+
+ );
+}
diff --git a/tests/fixtures/app-basic/app/components/template-counter.tsx b/tests/fixtures/app-basic/app/components/template-counter.tsx
new file mode 100644
index 000000000..c5be0f61d
--- /dev/null
+++ b/tests/fixtures/app-basic/app/components/template-counter.tsx
@@ -0,0 +1,22 @@
+"use client";
+
+import { useState } from "react";
+
+/**
+ * Client counter used to verify template remount behavior.
+ * Templates remount when navigation crosses their segment boundary,
+ * so this counter should reset. But search param changes within the
+ * same segment should NOT cause a remount, so the counter persists.
+ */
+export function TemplateCounter() {
+ const [count, setCount] = useState(0);
+
+ return (
+
+ Template count: {count}
+
+
+ );
+}
diff --git a/tests/fixtures/app-basic/app/dashboard/layout.tsx b/tests/fixtures/app-basic/app/dashboard/layout.tsx
index 169c725b6..d5d53901a 100644
--- a/tests/fixtures/app-basic/app/dashboard/layout.tsx
+++ b/tests/fixtures/app-basic/app/dashboard/layout.tsx
@@ -1,3 +1,5 @@
+import Link from "next/link";
+import { LayoutCounter } from "../components/layout-counter";
import { SegmentDisplay } from "./segment-display";
export default function DashboardLayout({
@@ -13,7 +15,14 @@ export default function DashboardLayout({