diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts
index 23e1c3ecc..bbd1c691d 100644
--- a/packages/vinext/src/entries/app-rsc-entry.ts
+++ b/packages/vinext/src/entries/app-rsc-entry.ts
@@ -55,6 +55,11 @@ const appPageBoundaryRenderPath = resolveEntryPath(
"../server/app-page-boundary-render.js",
import.meta.url,
);
+const appElementsPath = resolveEntryPath("../server/app-elements.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 +211,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 +343,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 +379,14 @@ import {
renderAppPageErrorBoundary as __renderAppPageErrorBoundary,
renderAppPageHttpAccessFallback as __renderAppPageHttpAccessFallback,
} from ${JSON.stringify(appPageBoundaryRenderPath)};
+import {
+ APP_INTERCEPTION_CONTEXT_KEY as __APP_INTERCEPTION_CONTEXT_KEY,
+ createAppPayloadRouteId as __createAppPayloadRouteId,
+} from ${JSON.stringify(appElementsPath)};
+import {
+ buildAppPageElements as __buildAppPageElements,
+ resolveAppPageChildSegments as __resolveAppPageChildSegments,
+} from ${JSON.stringify(appPageRouteWiringPath)};
import {
renderAppPageLifecycle as __renderAppPageLifecycle,
} from ${JSON.stringify(appPageRenderPath)};
@@ -542,38 +554,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 +757,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 +803,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 +887,23 @@ 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 _interceptionContext = opts?.interceptionContext ?? null;
+ const _noExportRouteId = __createAppPayloadRouteId(routePath, _interceptionContext);
+ 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 {
+ [__APP_INTERCEPTION_CONTEXT_KEY]: _interceptionContext,
+ __route: _noExportRouteId,
+ __rootLayout: _noExportRootLayout,
+ [_noExportRouteId]: createElement("div", null, "Page has no default export"),
+ };
}
// Resolve metadata and viewport from layouts and page.
@@ -989,12 +982,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 +1001,27 @@ 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,
+ interceptionContext: opts?.interceptionContext ?? null,
+ 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") : ""}
@@ -1526,6 +1348,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
}
const isRscRequest = pathname.endsWith(".rsc") || request.headers.get("accept")?.includes("text/x-component");
+ const interceptionContextHeader = request.headers.get("X-Vinext-Interception-Context");
let cleanPathname = pathname.replace(/\\.rsc$/, "");
// Middleware response headers and custom rewrite status are stored in
@@ -1900,9 +1723,21 @@ 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 = __createAppPayloadRouteId(cleanPathname, null);
+ element = {
+ [__APP_INTERCEPTION_CONTEXT_KEY]: null,
+ __route: _actionRouteId,
+ __rootLayout: null,
+ [_actionRouteId]: createElement("div", null, "Page not found"),
+ };
}
const onRenderError = createRscOnErrorHandler(
@@ -2254,7 +2089,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 +2144,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,
@@ -2338,6 +2187,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
setNavigationContext,
toInterceptOpts(intercept) {
return {
+ interceptionContext: interceptionContextHeader,
interceptSlot: intercept.slotName,
interceptPage: intercept.page,
interceptParams: intercept.matchedParams,
@@ -2351,7 +2201,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..b63966d6b 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,
@@ -26,10 +26,12 @@ import {
commitClientNavigationState,
consumePrefetchResponse,
createClientNavigationRenderSnapshot,
+ getCurrentInterceptionContext,
getClientNavigationRenderContext,
getPrefetchCache,
getPrefetchedUrls,
pushHistoryStateWithoutNotify,
+ readHistoryStateInterceptionContext,
replaceClientParamsWithoutNotify,
replaceHistoryStateWithoutNotify,
restoreRscResponse,
@@ -37,6 +39,7 @@ import {
snapshotRscResponse,
setNavigationContext,
toRscUrl,
+ VINEXT_INTERCEPTION_CONTEXT_HISTORY_STATE_KEY,
type CachedRscResponse,
type ClientNavigationRenderSnapshot,
} from "../shims/navigation.js";
@@ -46,22 +49,33 @@ import {
createProgressiveRscStream,
getVinextBrowserGlobal,
} from "./app-browser-stream.js";
+import {
+ createAppPayloadCacheKey,
+ normalizeAppElements,
+ readAppElementsMetadata,
+ resolveVisitedResponseInterceptionContext,
+ 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 = {
@@ -73,6 +87,9 @@ type VisitedResponseCacheEntry = {
const MAX_VISITED_RESPONSE_CACHE_SIZE = 50;
const VISITED_RESPONSE_CACHE_TTL = 5 * 60_000;
const MAX_TRAVERSAL_CACHE_TTL = 30 * 60_000;
+type HistoryStateRecord = {
+ [key: string]: unknown;
+};
// These are plain module-level variables, unlike ClientNavigationState in
// navigation.ts which uses Symbol.for to survive multiple Vite module instances.
@@ -89,7 +106,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 +115,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 setBrowserTreeState;
+ return dispatchBrowserRouterAction;
+}
+
+function getBrowserRouterState(): AppRouterState {
+ if (!browserRouterStateRef) {
+ throw new Error("[vinext] Browser router state is not initialized");
+ }
+ return browserRouterStateRef.current;
}
function applyClientParams(params: Record): void {
@@ -171,14 +196,22 @@ function drainPrePaintEffects(upToRenderId: number): void {
function createNavigationCommitEffect(
href: string,
historyUpdateMode: HistoryUpdateMode | undefined,
+ params: Record,
+ interceptionContext: string | null,
): () => void {
return () => {
const targetHref = new URL(href, window.location.origin).href;
+ stageClientParams(params);
+ const preserveExistingState = historyUpdateMode === "replace";
+ const historyState = createHistoryStateWithInterceptionContext(
+ preserveExistingState ? window.history.state : null,
+ interceptionContext,
+ );
if (historyUpdateMode === "replace" && window.location.href !== targetHref) {
- replaceHistoryStateWithoutNotify(null, "", href);
+ replaceHistoryStateWithoutNotify(historyState, "", href);
} else if (historyUpdateMode === "push" && window.location.href !== targetHref) {
- pushHistoryStateWithoutNotify(null, "", href);
+ pushHistoryStateWithoutNotify(historyState, "", href);
}
commitClientNavigationState();
@@ -197,9 +230,11 @@ function evictVisitedResponseCacheIfNeeded(): void {
function getVisitedResponse(
rscUrl: string,
+ interceptionContext: string | null,
navigationKind: NavigationKind,
): VisitedResponseCacheEntry | null {
- const cached = visitedResponseCache.get(rscUrl);
+ const cacheKey = createAppPayloadCacheKey(rscUrl, interceptionContext);
+ const cached = visitedResponseCache.get(cacheKey);
if (!cached) {
return null;
}
@@ -211,41 +246,93 @@ function getVisitedResponse(
if (navigationKind === "traverse") {
const createdAt = cached.expiresAt - VISITED_RESPONSE_CACHE_TTL;
if (Date.now() - createdAt >= MAX_TRAVERSAL_CACHE_TTL) {
- visitedResponseCache.delete(rscUrl);
+ visitedResponseCache.delete(cacheKey);
return null;
}
// LRU: promote to most-recently-used (delete + re-insert moves to end of Map)
- visitedResponseCache.delete(rscUrl);
- visitedResponseCache.set(rscUrl, cached);
+ visitedResponseCache.delete(cacheKey);
+ visitedResponseCache.set(cacheKey, cached);
return cached;
}
if (cached.expiresAt > Date.now()) {
// LRU: promote to most-recently-used
- visitedResponseCache.delete(rscUrl);
- visitedResponseCache.set(rscUrl, cached);
+ visitedResponseCache.delete(cacheKey);
+ visitedResponseCache.set(cacheKey, cached);
return cached;
}
- visitedResponseCache.delete(rscUrl);
+ visitedResponseCache.delete(cacheKey);
return null;
}
function storeVisitedResponseSnapshot(
rscUrl: string,
+ interceptionContext: string | null,
snapshot: CachedRscResponse,
params: Record,
): void {
- visitedResponseCache.delete(rscUrl);
+ const cacheKey = createAppPayloadCacheKey(rscUrl, interceptionContext);
+ visitedResponseCache.delete(cacheKey);
evictVisitedResponseCacheIfNeeded();
const now = Date.now();
- visitedResponseCache.set(rscUrl, {
+ visitedResponseCache.set(cacheKey, {
params,
expiresAt: now + VISITED_RESPONSE_CACHE_TTL,
response: snapshot,
});
}
+function cloneHistoryState(state: unknown): HistoryStateRecord {
+ if (!state || typeof state !== "object") {
+ return {};
+ }
+
+ const nextState: HistoryStateRecord = {};
+ for (const [key, value] of Object.entries(state)) {
+ nextState[key] = value;
+ }
+ return nextState;
+}
+
+function createHistoryStateWithInterceptionContext(
+ state: unknown,
+ interceptionContext: string | null,
+): HistoryStateRecord | null {
+ const nextState = cloneHistoryState(state);
+
+ if (interceptionContext === null) {
+ delete nextState[VINEXT_INTERCEPTION_CONTEXT_HISTORY_STATE_KEY];
+ } else {
+ nextState[VINEXT_INTERCEPTION_CONTEXT_HISTORY_STATE_KEY] = interceptionContext;
+ }
+
+ return Object.keys(nextState).length > 0 ? nextState : null;
+}
+
+function getRequestInterceptionContext(navigationKind: NavigationKind): string | null {
+ switch (navigationKind) {
+ case "navigate":
+ return getCurrentInterceptionContext();
+ case "traverse":
+ return readHistoryStateInterceptionContext(window.history.state);
+ case "refresh":
+ return null;
+ default: {
+ const _exhaustive: never = navigationKind;
+ throw new Error("[vinext] Unknown navigation kind: " + String(_exhaustive));
+ }
+ }
+}
+
+function createRscRequestHeaders(interceptionContext: string | null): Headers {
+ const headers = new Headers({ Accept: "text/x-component" });
+ if (interceptionContext !== null) {
+ headers.set("X-Vinext-Interception-Context", interceptionContext);
+ }
+ return headers;
+}
+
/**
* Resolve all pending navigation commits with renderId <= the committed renderId.
* Note: Map iteration handles concurrent deletion safely — entries are visited in
@@ -286,34 +373,70 @@ 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,
+ interceptionContext: initialMetadata.interceptionContext,
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(() => {
+ dispatchBrowserRouterAction = dispatchTreeState;
+ }, [dispatchTreeState]);
+
useLayoutEffect(() => {
- setBrowserTreeState = setTreeState;
- }, []); // eslint-disable-line react-hooks/exhaustive-deps -- setTreeState is referentially stable
+ if (treeState.renderId !== 0) {
+ return;
+ }
+
+ replaceHistoryStateWithoutNotify(
+ createHistoryStateWithInterceptionContext(
+ window.history.state,
+ treeState.interceptionContext,
+ ),
+ "",
+ window.location.href,
+ );
+ }, [treeState.interceptionContext, treeState.renderId]);
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 +451,112 @@ function BrowserRoot({
);
}
-function updateBrowserTree(
- node: ReactNode | Promise,
+function dispatchBrowserTree(
+ elements: AppElements,
navigationSnapshot: ClientNavigationRenderSnapshot,
renderId: number,
+ actionType: "navigate" | "replace",
+ interceptionContext: string | null,
+ 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,
+ interceptionContext,
+ 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,
- prePaintEffect: (() => void) | null = null,
+ targetHref: string,
+ navId: number,
+ historyUpdateMode: HistoryUpdateMode | undefined,
+ params: Record,
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,
+ createNavigationCommitEffect(
+ targetHref,
+ historyUpdateMode,
+ params,
+ pending.interceptionContext,
+ ),
+ );
+ activateNavigationSnapshot();
+ snapshotActivated = true;
+ dispatchBrowserTree(
+ pending.action.elements,
+ navigationSnapshot,
+ renderId,
+ actionType,
+ pending.interceptionContext,
+ 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;
@@ -500,6 +652,8 @@ function registerServerActionCallback(): void {
const temporaryReferences = createTemporaryReferenceSet();
const body = await encodeReply(args, { temporaryReferences });
+ // Intentionally omit interception context for server action re-renders in
+ // this PR. Durable intercepted refresh/action parity belongs to PR 5.
const fetchResponse = await fetch(toRscUrl(window.location.pathname + window.location.search), {
method: "POST",
headers: { "x-rsc-action": id },
@@ -534,7 +688,7 @@ function registerServerActionCallback(): void {
clearClientNavigationCaches();
- const result = await createFromFetch(
+ const result = await createFromFetch(
Promise.resolve(fetchResponse),
{ temporaryReferences },
);
@@ -548,10 +702,25 @@ 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.interceptionContext,
+ pending.routeId,
+ pending.rootLayoutTreePath,
false,
);
if (result.returnValue) {
@@ -561,11 +730,25 @@ 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.interceptionContext,
+ pending.routeId,
+ pending.rootLayoutTreePath,
false,
);
return result;
@@ -576,7 +759,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 +768,7 @@ async function main(): Promise {
window.__VINEXT_RSC_ROOT__ = hydrateRoot(
document,
createElement(BrowserRoot, {
- initialNode: root,
+ initialElements: root,
initialNavigationSnapshot,
}),
import.meta.env.DEV ? { onCaughtError() {} } : undefined,
@@ -613,6 +796,7 @@ async function main(): Promise {
try {
const url = new URL(href, window.location.origin);
const rscUrl = toRscUrl(url.pathname + url.search);
+ const requestInterceptionContext = getRequestInterceptionContext(navigationKind);
// Use startTransition for same-route navigations (searchParam changes)
// so React keeps the old UI visible during the transition. For cross-route
// navigations (different pathname), use synchronous updates — React's
@@ -624,9 +808,7 @@ async function main(): Promise {
const isSameRoute =
stripBasePath(url.pathname, __basePath) ===
stripBasePath(window.location.pathname, __basePath);
- const cachedRoute = getVisitedResponse(rscUrl, navigationKind);
- const navigationCommitEffect = createNavigationCommitEffect(href, historyUpdateMode);
-
+ const cachedRoute = getVisitedResponse(rscUrl, requestInterceptionContext, navigationKind);
if (cachedRoute) {
// Check stale-navigation before and after createFromFetch. The pre-check
// avoids wasted parse work; the post-check catches supersessions that
@@ -642,23 +824,21 @@ 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,
+ historyUpdateMode,
+ cachedParams,
isSameRoute,
);
} finally {
@@ -672,7 +852,7 @@ async function main(): Promise {
let navResponse: Response | undefined;
let navResponseUrl: string | null = null;
if (navigationKind !== "refresh") {
- const prefetchedResponse = consumePrefetchResponse(rscUrl);
+ const prefetchedResponse = consumePrefetchResponse(rscUrl, requestInterceptionContext);
if (prefetchedResponse) {
navResponse = restoreRscResponse(prefetchedResponse, false);
navResponseUrl = prefetchedResponse.url;
@@ -680,8 +860,9 @@ async function main(): Promise {
}
if (!navResponse) {
+ const requestHeaders = createRscRequestHeaders(requestInterceptionContext);
navResponse = await fetch(rscUrl, {
- headers: { Accept: "text/x-component" },
+ headers: requestHeaders,
credentials: "include",
});
}
@@ -693,7 +874,11 @@ async function main(): Promise {
if (finalUrl.pathname !== requestedUrl.pathname) {
const destinationPath = finalUrl.pathname.replace(/\.rsc$/, "") + finalUrl.search;
- replaceHistoryStateWithoutNotify(null, "", destinationPath);
+ replaceHistoryStateWithoutNotify(
+ createHistoryStateWithInterceptionContext(null, requestInterceptionContext),
+ "",
+ destinationPath,
+ );
const navigate = window.__VINEXT_RSC_NAVIGATE__;
if (!navigate) {
@@ -726,23 +911,21 @@ 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,
+ historyUpdateMode,
+ navParams,
isSameRoute,
);
} finally {
@@ -753,11 +936,24 @@ 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
// never actually rendered successfully.
- storeVisitedResponseSnapshot(rscUrl, responseSnapshot, navParams);
+ const resolvedElements = await rscPayload;
+ const metadata = readAppElementsMetadata(resolvedElements);
+ storeVisitedResponseSnapshot(
+ rscUrl,
+ resolveVisitedResponseInterceptionContext(
+ requestInterceptionContext,
+ metadata.interceptionContext,
+ ),
+ responseSnapshot,
+ navParams,
+ );
return;
} catch (error) {
// Only decrement counter if snapshot was activated but not yet committed.
@@ -801,14 +997,29 @@ 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.interceptionContext,
+ pending.routeId,
+ pending.rootLayoutTreePath,
false,
);
} catch (error) {
@@ -818,4 +1029,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..90ac10d9b
--- /dev/null
+++ b/packages/vinext/src/server/app-browser-state.ts
@@ -0,0 +1,124 @@
+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;
+ interceptionContext: string | null;
+ renderId: number;
+ navigationSnapshot: ClientNavigationRenderSnapshot;
+ rootLayoutTreePath: string | null;
+ routeId: string;
+};
+
+export type AppRouterAction = {
+ elements: AppElements;
+ interceptionContext: string | null;
+ navigationSnapshot: ClientNavigationRenderSnapshot;
+ renderId: number;
+ rootLayoutTreePath: string | null;
+ routeId: string;
+ type: "navigate" | "replace";
+};
+
+export type PendingNavigationCommit = {
+ action: AppRouterAction;
+ interceptionContext: string | null;
+ 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),
+ interceptionContext: action.interceptionContext,
+ navigationSnapshot: action.navigationSnapshot,
+ renderId: action.renderId,
+ rootLayoutTreePath: action.rootLayoutTreePath,
+ routeId: action.routeId,
+ };
+ case "replace":
+ return {
+ elements: action.elements,
+ interceptionContext: action.interceptionContext,
+ 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,
+ interceptionContext: metadata.interceptionContext,
+ navigationSnapshot: options.navigationSnapshot,
+ renderId: options.renderId ?? options.currentState.renderId + 1,
+ rootLayoutTreePath: metadata.rootLayoutTreePath,
+ routeId: metadata.routeId,
+ type: options.type,
+ },
+ interceptionContext: metadata.interceptionContext,
+ 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..33af07165
--- /dev/null
+++ b/packages/vinext/src/server/app-elements.ts
@@ -0,0 +1,105 @@
+import type { ReactNode } from "react";
+
+const APP_INTERCEPTION_SEPARATOR = "\0";
+
+export const APP_INTERCEPTION_CONTEXT_KEY = "__interceptionContext";
+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 = {
+ interceptionContext: string | null;
+ routeId: string;
+ rootLayoutTreePath: string | null;
+};
+
+function appendInterceptionContext(identity: string, interceptionContext: string | null): string {
+ return interceptionContext === null
+ ? identity
+ : `${identity}${APP_INTERCEPTION_SEPARATOR}${interceptionContext}`;
+}
+
+export function createAppPayloadRouteId(
+ routePath: string,
+ interceptionContext: string | null,
+): string {
+ return appendInterceptionContext(`route:${routePath}`, interceptionContext);
+}
+
+export function createAppPayloadPageId(
+ routePath: string,
+ interceptionContext: string | null,
+): string {
+ return appendInterceptionContext(`page:${routePath}`, interceptionContext);
+}
+
+export function createAppPayloadCacheKey(
+ rscUrl: string,
+ interceptionContext: string | null,
+): string {
+ return appendInterceptionContext(rscUrl, interceptionContext);
+}
+
+export function resolveVisitedResponseInterceptionContext(
+ requestInterceptionContext: string | null,
+ payloadInterceptionContext: string | null,
+): string | null {
+ return payloadInterceptionContext ?? requestInterceptionContext;
+}
+
+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 interceptionContext = elements[APP_INTERCEPTION_CONTEXT_KEY];
+ if (
+ interceptionContext !== undefined &&
+ interceptionContext !== null &&
+ typeof interceptionContext !== "string"
+ ) {
+ throw new Error("[vinext] Invalid __interceptionContext 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 {
+ interceptionContext: interceptionContext ?? null,
+ 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..b0cabf4c6 100644
--- a/packages/vinext/src/server/app-page-boundary-render.ts
+++ b/packages/vinext/src/server/app-page-boundary-render.ts
@@ -24,6 +24,14 @@ import {
renderAppPageHtmlResponse,
type AppPageSsrHandler,
} from "./app-page-stream.js";
+import {
+ APP_INTERCEPTION_CONTEXT_KEY,
+ APP_ROOT_LAYOUT_KEY,
+ APP_ROUTE_KEY,
+ createAppPayloadRouteId,
+ 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 +44,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 +77,7 @@ type AppPageBoundaryRenderCommonOptions Promise;
makeThenableParams: (params: AppPageParams) => unknown;
renderToReadableStream: (
- element: ReactNode,
+ element: ReactNode | AppElements,
options: { onError: AppPageBoundaryOnError },
) => ReadableStream;
requestUrl: string;
@@ -200,14 +215,59 @@ 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 = createAppPayloadRouteId(options.pathname, null);
+
+ return {
+ [APP_INTERCEPTION_CONTEXT_KEY]: null,
+ [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 +290,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..084aff21c
--- /dev/null
+++ b/packages/vinext/src/server/app-page-route-wiring.tsx
@@ -0,0 +1,586 @@
+import { Suspense, type ComponentType, type ReactNode } from "react";
+import {
+ APP_INTERCEPTION_CONTEXT_KEY,
+ APP_ROOT_LAYOUT_KEY,
+ APP_ROUTE_KEY,
+ APP_UNMATCHED_SLOT_WIRE_VALUE,
+ createAppPayloadPageId,
+ createAppPayloadRouteId,
+ 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 & {
+ interceptionContext?: string | null;
+ 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 interceptionContext = options.interceptionContext ?? null;
+ const routeId = createAppPayloadRouteId(options.routePath, interceptionContext);
+ const pageId = createAppPayloadPageId(options.routePath, interceptionContext);
+ 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_INTERCEPTION_CONTEXT_KEY] = interceptionContext;
+ 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/link.tsx b/packages/vinext/src/shims/link.tsx
index db950de98..e77020275 100644
--- a/packages/vinext/src/shims/link.tsx
+++ b/packages/vinext/src/shims/link.tsx
@@ -21,11 +21,13 @@ import React, {
// Import shared RSC prefetch utilities from navigation shim (relative path
// so this resolves both via the Vite plugin and in direct vitest imports)
import {
+ getCurrentInterceptionContext,
toRscUrl,
getPrefetchedUrls,
navigateClientSide,
prefetchRscResponse,
} from "./navigation.js";
+import { createAppPayloadCacheKey } from "../server/app-elements.js";
import { isDangerousScheme } from "./url-safety.js";
import {
resolveRelativeHref,
@@ -124,26 +126,33 @@ function prefetchUrl(href: string): void {
const fullHref = toBrowserNavigationHref(prefetchHref, window.location.href, __basePath);
- // Don't prefetch the same URL twice (keyed by rscUrl so the browser
- // entry can clear the key when a cache entry is consumed)
+ // Distinguish the same visible URL when it is prefetched from different
+ // interception sources such as /feed vs /gallery.
const rscUrl = toRscUrl(fullHref);
+ const interceptionContext = getCurrentInterceptionContext();
+ const cacheKey = createAppPayloadCacheKey(rscUrl, interceptionContext);
const prefetched = getPrefetchedUrls();
- if (prefetched.has(rscUrl)) return;
- prefetched.add(rscUrl);
+ if (prefetched.has(cacheKey)) return;
+ prefetched.add(cacheKey);
const schedule = window.requestIdleCallback ?? ((fn: () => void) => setTimeout(fn, 100));
schedule(() => {
if (typeof window.__VINEXT_RSC_NAVIGATE__ === "function") {
+ const headers = new Headers({ Accept: "text/x-component" });
+ if (interceptionContext !== null) {
+ headers.set("X-Vinext-Interception-Context", interceptionContext);
+ }
prefetchRscResponse(
rscUrl,
fetch(rscUrl, {
- headers: { Accept: "text/x-component" },
+ headers,
credentials: "include",
priority: "low" as const,
// @ts-expect-error — purpose is a valid fetch option in some browsers
purpose: "prefetch",
}),
+ interceptionContext,
);
} else if ((window.__NEXT_DATA__ as VinextNextData | undefined)?.__vinext?.pageModuleUrl) {
// Pages Router: inject a prefetch link for the target page module
diff --git a/packages/vinext/src/shims/navigation.ts b/packages/vinext/src/shims/navigation.ts
index 40356a978..ebfe7392f 100644
--- a/packages/vinext/src/shims/navigation.ts
+++ b/packages/vinext/src/shims/navigation.ts
@@ -12,6 +12,7 @@
// bindings are just `undefined` on the namespace object and we can guard at runtime.
import * as React from "react";
import { notifyAppRouterTransitionStart } from "../client/instrumentation-client-state.js";
+import { createAppPayloadCacheKey } from "../server/app-elements.js";
import { toBrowserNavigationHref, toSameOriginAppPath } from "./url-utils.js";
import { stripBasePath } from "../utils/base-path.js";
import { ReadonlyURLSearchParams } from "./readonly-url-search-params.js";
@@ -243,6 +244,8 @@ export const MAX_PREFETCH_CACHE_SIZE = 50;
/** TTL for prefetch cache entries in ms (matches Next.js static prefetch TTL). */
export const PREFETCH_CACHE_TTL = 30_000;
+export const VINEXT_INTERCEPTION_CONTEXT_HISTORY_STATE_KEY = "__vinext_interceptionContext";
+
/** A buffered RSC response stored as an ArrayBuffer for replay. */
export type CachedRscResponse = {
buffer: ArrayBuffer;
@@ -273,6 +276,26 @@ export function toRscUrl(href: string): string {
return normalizedPath + ".rsc" + query;
}
+export function readHistoryStateInterceptionContext(state: unknown): string | null {
+ if (!state || typeof state !== "object") {
+ return null;
+ }
+
+ const value = Reflect.get(state, VINEXT_INTERCEPTION_CONTEXT_HISTORY_STATE_KEY);
+ return typeof value === "string" ? value : null;
+}
+
+export function getCurrentInterceptionContext(): string | null {
+ if (isServer) {
+ return null;
+ }
+
+ return (
+ readHistoryStateInterceptionContext(window.history.state) ??
+ stripBasePath(window.location.pathname, __basePath)
+ );
+}
+
/** Get or create the shared in-memory RSC prefetch cache on window. */
export function getPrefetchCache(): Map {
if (isServer) return new Map();
@@ -284,7 +307,7 @@ export function getPrefetchCache(): Map {
/**
* Get or create the shared set of already-prefetched RSC URLs on window.
- * Keyed by rscUrl so that the browser entry can clear entries when consumed.
+ * Keyed by interception-aware cache key so distinct source routes do not alias.
*/
export function getPrefetchedUrls(): Set {
if (isServer) return new Set();
@@ -336,7 +359,12 @@ function evictPrefetchCacheIfNeeded(): void {
* NB: Caller is responsible for managing getPrefetchedUrls() — this
* function only stores the response in the prefetch cache.
*/
-export function storePrefetchResponse(rscUrl: string, response: Response): void {
+export function storePrefetchResponse(
+ rscUrl: string,
+ response: Response,
+ interceptionContext: string | null = null,
+): void {
+ const cacheKey = createAppPayloadCacheKey(rscUrl, interceptionContext);
evictPrefetchCacheIfNeeded();
const entry: PrefetchCacheEntry = { timestamp: Date.now() };
entry.pending = snapshotRscResponse(response)
@@ -344,12 +372,12 @@ export function storePrefetchResponse(rscUrl: string, response: Response): void
entry.snapshot = snapshot;
})
.catch(() => {
- getPrefetchCache().delete(rscUrl);
+ getPrefetchCache().delete(cacheKey);
})
.finally(() => {
entry.pending = undefined;
});
- getPrefetchCache().set(rscUrl, entry);
+ getPrefetchCache().set(cacheKey, entry);
}
/**
@@ -400,7 +428,12 @@ export function restoreRscResponse(cached: CachedRscResponse, copy = true): Resp
* Enforces a maximum cache size to prevent unbounded memory growth on
* link-heavy pages.
*/
-export function prefetchRscResponse(rscUrl: string, fetchPromise: Promise): void {
+export function prefetchRscResponse(
+ rscUrl: string,
+ fetchPromise: Promise,
+ interceptionContext: string | null = null,
+): void {
+ const cacheKey = createAppPayloadCacheKey(rscUrl, interceptionContext);
const cache = getPrefetchCache();
const prefetched = getPrefetchedUrls();
const now = Date.now();
@@ -412,13 +445,13 @@ export function prefetchRscResponse(rscUrl: string, fetchPromise: Promise {
- prefetched.delete(rscUrl);
- cache.delete(rscUrl);
+ prefetched.delete(cacheKey);
+ cache.delete(cacheKey);
})
.finally(() => {
entry.pending = undefined;
@@ -427,7 +460,7 @@ export function prefetchRscResponse(rscUrl: string, fetchPromise: Promise= PREFETCH_CACHE_TTL) {
@@ -1122,16 +1159,23 @@ const _appRouter = {
// prefetchRscResponse only manages the cache Map, not the URL set.
const fullHref = toBrowserNavigationHref(href, window.location.href, __basePath);
const rscUrl = toRscUrl(fullHref);
+ const interceptionContext = getCurrentInterceptionContext();
+ const cacheKey = createAppPayloadCacheKey(rscUrl, interceptionContext);
const prefetched = getPrefetchedUrls();
- if (prefetched.has(rscUrl)) return;
- prefetched.add(rscUrl);
+ if (prefetched.has(cacheKey)) return;
+ prefetched.add(cacheKey);
+ const headers = new Headers({ Accept: "text/x-component" });
+ if (interceptionContext !== null) {
+ headers.set("X-Vinext-Interception-Context", interceptionContext);
+ }
prefetchRscResponse(
rscUrl,
fetch(rscUrl, {
- headers: { Accept: "text/x-component" },
+ headers,
credentials: "include",
priority: "low" as RequestInit["priority"],
}),
+ interceptionContext,
);
},
};
diff --git a/packages/vinext/src/shims/next-shims.d.ts b/packages/vinext/src/shims/next-shims.d.ts
index 1a1d6e4ac..3f72d386b 100644
--- a/packages/vinext/src/shims/next-shims.d.ts
+++ b/packages/vinext/src/shims/next-shims.d.ts
@@ -121,7 +121,11 @@ declare module "next/navigation" {
export function toRscUrl(href: string): string;
export function getPrefetchCache(): Map;
export function getPrefetchedUrls(): Set;
- export function storePrefetchResponse(rscUrl: string, response: Response): void;
+ export function storePrefetchResponse(
+ rscUrl: string,
+ response: Response,
+ interceptionContext?: string | null,
+ ): void;
}
declare module "next/image" {
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..2bf25494e 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,14 @@ import {
renderAppPageErrorBoundary as __renderAppPageErrorBoundary,
renderAppPageHttpAccessFallback as __renderAppPageHttpAccessFallback,
} from "/packages/vinext/src/server/app-page-boundary-render.js";
+import {
+ APP_INTERCEPTION_CONTEXT_KEY as __APP_INTERCEPTION_CONTEXT_KEY,
+ createAppPayloadRouteId as __createAppPayloadRouteId,
+} from "/packages/vinext/src/server/app-elements.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 +252,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 +396,7 @@ const routes = [
routeHandler: null,
layouts: [mod_1],
routeSegments: [],
+ templateTreePositions: [],
layoutTreePositions: [0],
templates: [],
errors: [null],
@@ -444,6 +419,7 @@ const routes = [
routeHandler: null,
layouts: [mod_1],
routeSegments: ["about"],
+ templateTreePositions: [],
layoutTreePositions: [0],
templates: [],
errors: [null],
@@ -466,6 +442,7 @@ const routes = [
routeHandler: null,
layouts: [mod_1, mod_4],
routeSegments: ["blog",":slug"],
+ templateTreePositions: [],
layoutTreePositions: [0,1],
templates: [],
errors: [null, null],
@@ -488,6 +465,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 +522,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 +568,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 +652,23 @@ 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 _interceptionContext = opts?.interceptionContext ?? null;
+ const _noExportRouteId = __createAppPayloadRouteId(routePath, _interceptionContext);
+ 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 {
+ [__APP_INTERCEPTION_CONTEXT_KEY]: _interceptionContext,
+ __route: _noExportRouteId,
+ __rootLayout: _noExportRootLayout,
+ [_noExportRouteId]: createElement("div", null, "Page has no default export"),
+ };
}
// Resolve metadata and viewport from layouts and page.
@@ -756,12 +747,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 +766,27 @@ 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,
+ interceptionContext: opts?.interceptionContext ?? null,
+ 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,
+ });
}
@@ -1391,6 +1223,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
}
const isRscRequest = pathname.endsWith(".rsc") || request.headers.get("accept")?.includes("text/x-component");
+ const interceptionContextHeader = request.headers.get("X-Vinext-Interception-Context");
let cleanPathname = pathname.replace(/\\.rsc$/, "");
// Middleware response headers and custom rewrite status are stored in
@@ -1626,9 +1459,21 @@ 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 = __createAppPayloadRouteId(cleanPathname, null);
+ element = {
+ [__APP_INTERCEPTION_CONTEXT_KEY]: null,
+ __route: _actionRouteId,
+ __rootLayout: null,
+ [_actionRouteId]: createElement("div", null, "Page not found"),
+ };
}
const onRenderError = createRscOnErrorHandler(
@@ -1950,7 +1795,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 +1850,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,
@@ -2034,6 +1893,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
setNavigationContext,
toInterceptOpts(intercept) {
return {
+ interceptionContext: interceptionContextHeader,
interceptSlot: intercept.slotName,
interceptPage: intercept.page,
interceptParams: intercept.matchedParams,
@@ -2047,7 +1907,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 +2095,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 +2131,14 @@ import {
renderAppPageErrorBoundary as __renderAppPageErrorBoundary,
renderAppPageHttpAccessFallback as __renderAppPageHttpAccessFallback,
} from "/packages/vinext/src/server/app-page-boundary-render.js";
+import {
+ APP_INTERCEPTION_CONTEXT_KEY as __APP_INTERCEPTION_CONTEXT_KEY,
+ createAppPayloadRouteId as __createAppPayloadRouteId,
+} from "/packages/vinext/src/server/app-elements.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 +2306,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 +2450,7 @@ const routes = [
routeHandler: null,
layouts: [mod_1],
routeSegments: [],
+ templateTreePositions: [],
layoutTreePositions: [0],
templates: [],
errors: [null],
@@ -2638,6 +2473,7 @@ const routes = [
routeHandler: null,
layouts: [mod_1],
routeSegments: ["about"],
+ templateTreePositions: [],
layoutTreePositions: [0],
templates: [],
errors: [null],
@@ -2660,6 +2496,7 @@ const routes = [
routeHandler: null,
layouts: [mod_1, mod_4],
routeSegments: ["blog",":slug"],
+ templateTreePositions: [],
layoutTreePositions: [0,1],
templates: [],
errors: [null, null],
@@ -2682,6 +2519,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 +2576,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 +2622,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 +2706,23 @@ 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 _interceptionContext = opts?.interceptionContext ?? null;
+ const _noExportRouteId = __createAppPayloadRouteId(routePath, _interceptionContext);
+ 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 {
+ [__APP_INTERCEPTION_CONTEXT_KEY]: _interceptionContext,
+ __route: _noExportRouteId,
+ __rootLayout: _noExportRootLayout,
+ [_noExportRouteId]: createElement("div", null, "Page has no default export"),
+ };
}
// Resolve metadata and viewport from layouts and page.
@@ -2950,12 +2801,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 +2820,27 @@ 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,
+ interceptionContext: opts?.interceptionContext ?? null,
+ 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,
+ });
}
@@ -3588,6 +3280,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
}
const isRscRequest = pathname.endsWith(".rsc") || request.headers.get("accept")?.includes("text/x-component");
+ const interceptionContextHeader = request.headers.get("X-Vinext-Interception-Context");
let cleanPathname = pathname.replace(/\\.rsc$/, "");
// Middleware response headers and custom rewrite status are stored in
@@ -3823,9 +3516,21 @@ 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 = __createAppPayloadRouteId(cleanPathname, null);
+ element = {
+ [__APP_INTERCEPTION_CONTEXT_KEY]: null,
+ __route: _actionRouteId,
+ __rootLayout: null,
+ [_actionRouteId]: createElement("div", null, "Page not found"),
+ };
}
const onRenderError = createRscOnErrorHandler(
@@ -4147,7 +3852,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 +3907,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,
@@ -4231,6 +3950,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
setNavigationContext,
toInterceptOpts(intercept) {
return {
+ interceptionContext: interceptionContextHeader,
interceptSlot: intercept.slotName,
interceptPage: intercept.page,
interceptParams: intercept.matchedParams,
@@ -4244,7 +3964,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 +4152,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 +4188,14 @@ import {
renderAppPageErrorBoundary as __renderAppPageErrorBoundary,
renderAppPageHttpAccessFallback as __renderAppPageHttpAccessFallback,
} from "/packages/vinext/src/server/app-page-boundary-render.js";
+import {
+ APP_INTERCEPTION_CONTEXT_KEY as __APP_INTERCEPTION_CONTEXT_KEY,
+ createAppPayloadRouteId as __createAppPayloadRouteId,
+} from "/packages/vinext/src/server/app-elements.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 +4363,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 +4508,7 @@ const routes = [
routeHandler: null,
layouts: [mod_1],
routeSegments: [],
+ templateTreePositions: [],
layoutTreePositions: [0],
templates: [],
errors: [null],
@@ -4836,6 +4531,7 @@ const routes = [
routeHandler: null,
layouts: [mod_1],
routeSegments: ["about"],
+ templateTreePositions: [],
layoutTreePositions: [0],
templates: [],
errors: [null],
@@ -4858,6 +4554,7 @@ const routes = [
routeHandler: null,
layouts: [mod_1, mod_4],
routeSegments: ["blog",":slug"],
+ templateTreePositions: [],
layoutTreePositions: [0,1],
templates: [],
errors: [null, null],
@@ -4880,6 +4577,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 +4634,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 +4680,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 +4764,23 @@ 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 _interceptionContext = opts?.interceptionContext ?? null;
+ const _noExportRouteId = __createAppPayloadRouteId(routePath, _interceptionContext);
+ 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 {
+ [__APP_INTERCEPTION_CONTEXT_KEY]: _interceptionContext,
+ __route: _noExportRouteId,
+ __rootLayout: _noExportRootLayout,
+ [_noExportRouteId]: createElement("div", null, "Page has no default export"),
+ };
}
// Resolve metadata and viewport from layouts and page.
@@ -5148,12 +4859,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 +4878,27 @@ 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,
+ interceptionContext: opts?.interceptionContext ?? null,
+ 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,
+ });
}
@@ -5791,6 +5335,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
}
const isRscRequest = pathname.endsWith(".rsc") || request.headers.get("accept")?.includes("text/x-component");
+ const interceptionContextHeader = request.headers.get("X-Vinext-Interception-Context");
let cleanPathname = pathname.replace(/\\.rsc$/, "");
// Middleware response headers and custom rewrite status are stored in
@@ -6026,9 +5571,21 @@ 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 = __createAppPayloadRouteId(cleanPathname, null);
+ element = {
+ [__APP_INTERCEPTION_CONTEXT_KEY]: null,
+ __route: _actionRouteId,
+ __rootLayout: null,
+ [_actionRouteId]: createElement("div", null, "Page not found"),
+ };
}
const onRenderError = createRscOnErrorHandler(
@@ -6350,7 +5907,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 +5962,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,
@@ -6434,6 +6005,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
setNavigationContext,
toInterceptOpts(intercept) {
return {
+ interceptionContext: interceptionContextHeader,
interceptSlot: intercept.slotName,
interceptPage: intercept.page,
interceptParams: intercept.matchedParams,
@@ -6447,7 +6019,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 +6207,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 +6243,14 @@ import {
renderAppPageErrorBoundary as __renderAppPageErrorBoundary,
renderAppPageHttpAccessFallback as __renderAppPageHttpAccessFallback,
} from "/packages/vinext/src/server/app-page-boundary-render.js";
+import {
+ APP_INTERCEPTION_CONTEXT_KEY as __APP_INTERCEPTION_CONTEXT_KEY,
+ createAppPayloadRouteId as __createAppPayloadRouteId,
+} from "/packages/vinext/src/server/app-elements.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 +6418,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 +6592,7 @@ const routes = [
routeHandler: null,
layouts: [mod_1],
routeSegments: [],
+ templateTreePositions: [],
layoutTreePositions: [0],
templates: [],
errors: [null],
@@ -7068,6 +6615,7 @@ const routes = [
routeHandler: null,
layouts: [mod_1],
routeSegments: ["about"],
+ templateTreePositions: [],
layoutTreePositions: [0],
templates: [],
errors: [null],
@@ -7090,6 +6638,7 @@ const routes = [
routeHandler: null,
layouts: [mod_1, mod_4],
routeSegments: ["blog",":slug"],
+ templateTreePositions: [],
layoutTreePositions: [0,1],
templates: [],
errors: [null, null],
@@ -7112,6 +6661,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 +6718,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 +6764,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 +6848,23 @@ 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 _interceptionContext = opts?.interceptionContext ?? null;
+ const _noExportRouteId = __createAppPayloadRouteId(routePath, _interceptionContext);
+ 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 {
+ [__APP_INTERCEPTION_CONTEXT_KEY]: _interceptionContext,
+ __route: _noExportRouteId,
+ __rootLayout: _noExportRootLayout,
+ [_noExportRouteId]: createElement("div", null, "Page has no default export"),
+ };
}
// Resolve metadata and viewport from layouts and page.
@@ -7380,12 +6943,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 +6962,27 @@ 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,
+ interceptionContext: opts?.interceptionContext ?? null,
+ 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,
+ });
}
@@ -8018,6 +7422,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
}
const isRscRequest = pathname.endsWith(".rsc") || request.headers.get("accept")?.includes("text/x-component");
+ const interceptionContextHeader = request.headers.get("X-Vinext-Interception-Context");
let cleanPathname = pathname.replace(/\\.rsc$/, "");
// Middleware response headers and custom rewrite status are stored in
@@ -8253,9 +7658,21 @@ 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 = __createAppPayloadRouteId(cleanPathname, null);
+ element = {
+ [__APP_INTERCEPTION_CONTEXT_KEY]: null,
+ __route: _actionRouteId,
+ __rootLayout: null,
+ [_actionRouteId]: createElement("div", null, "Page not found"),
+ };
}
const onRenderError = createRscOnErrorHandler(
@@ -8577,7 +7994,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 +8049,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,
@@ -8661,6 +8092,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
setNavigationContext,
toInterceptOpts(intercept) {
return {
+ interceptionContext: interceptionContextHeader,
interceptSlot: intercept.slotName,
interceptPage: intercept.page,
interceptParams: intercept.matchedParams,
@@ -8674,7 +8106,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 +8294,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 +8330,14 @@ import {
renderAppPageErrorBoundary as __renderAppPageErrorBoundary,
renderAppPageHttpAccessFallback as __renderAppPageHttpAccessFallback,
} from "/packages/vinext/src/server/app-page-boundary-render.js";
+import {
+ APP_INTERCEPTION_CONTEXT_KEY as __APP_INTERCEPTION_CONTEXT_KEY,
+ createAppPayloadRouteId as __createAppPayloadRouteId,
+} from "/packages/vinext/src/server/app-elements.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 +8505,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 +8650,7 @@ const routes = [
routeHandler: null,
layouts: [mod_1],
routeSegments: [],
+ templateTreePositions: [],
layoutTreePositions: [0],
templates: [],
errors: [null],
@@ -9266,6 +8673,7 @@ const routes = [
routeHandler: null,
layouts: [mod_1],
routeSegments: ["about"],
+ templateTreePositions: [],
layoutTreePositions: [0],
templates: [],
errors: [null],
@@ -9288,6 +8696,7 @@ const routes = [
routeHandler: null,
layouts: [mod_1, mod_4],
routeSegments: ["blog",":slug"],
+ templateTreePositions: [],
layoutTreePositions: [0,1],
templates: [],
errors: [null, null],
@@ -9310,6 +8719,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 +8782,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 +8828,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 +8912,23 @@ 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 _interceptionContext = opts?.interceptionContext ?? null;
+ const _noExportRouteId = __createAppPayloadRouteId(routePath, _interceptionContext);
+ 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 {
+ [__APP_INTERCEPTION_CONTEXT_KEY]: _interceptionContext,
+ __route: _noExportRouteId,
+ __rootLayout: _noExportRootLayout,
+ [_noExportRouteId]: createElement("div", null, "Page has no default export"),
+ };
}
// Resolve metadata and viewport from layouts and page.
@@ -9584,12 +9007,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 +9026,27 @@ 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,
+ interceptionContext: opts?.interceptionContext ?? null,
+ 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,
+ });
}
@@ -10219,6 +9483,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
}
const isRscRequest = pathname.endsWith(".rsc") || request.headers.get("accept")?.includes("text/x-component");
+ const interceptionContextHeader = request.headers.get("X-Vinext-Interception-Context");
let cleanPathname = pathname.replace(/\\.rsc$/, "");
// Middleware response headers and custom rewrite status are stored in
@@ -10454,9 +9719,21 @@ 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 = __createAppPayloadRouteId(cleanPathname, null);
+ element = {
+ [__APP_INTERCEPTION_CONTEXT_KEY]: null,
+ __route: _actionRouteId,
+ __rootLayout: null,
+ [_actionRouteId]: createElement("div", null, "Page not found"),
+ };
}
const onRenderError = createRscOnErrorHandler(
@@ -10778,7 +10055,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 +10110,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,
@@ -10862,6 +10153,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
setNavigationContext,
toInterceptOpts(intercept) {
return {
+ interceptionContext: interceptionContextHeader,
interceptSlot: intercept.slotName,
interceptPage: intercept.page,
interceptParams: intercept.matchedParams,
@@ -10875,7 +10167,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 +10355,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 +10391,14 @@ import {
renderAppPageErrorBoundary as __renderAppPageErrorBoundary,
renderAppPageHttpAccessFallback as __renderAppPageHttpAccessFallback,
} from "/packages/vinext/src/server/app-page-boundary-render.js";
+import {
+ APP_INTERCEPTION_CONTEXT_KEY as __APP_INTERCEPTION_CONTEXT_KEY,
+ createAppPayloadRouteId as __createAppPayloadRouteId,
+} from "/packages/vinext/src/server/app-elements.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 +10566,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 +10710,7 @@ const routes = [
routeHandler: null,
layouts: [mod_1],
routeSegments: [],
+ templateTreePositions: [],
layoutTreePositions: [0],
templates: [],
errors: [null],
@@ -11466,6 +10733,7 @@ const routes = [
routeHandler: null,
layouts: [mod_1],
routeSegments: ["about"],
+ templateTreePositions: [],
layoutTreePositions: [0],
templates: [],
errors: [null],
@@ -11488,6 +10756,7 @@ const routes = [
routeHandler: null,
layouts: [mod_1, mod_4],
routeSegments: ["blog",":slug"],
+ templateTreePositions: [],
layoutTreePositions: [0,1],
templates: [],
errors: [null, null],
@@ -11510,6 +10779,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 +10836,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 +10882,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 +10966,23 @@ 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 _interceptionContext = opts?.interceptionContext ?? null;
+ const _noExportRouteId = __createAppPayloadRouteId(routePath, _interceptionContext);
+ 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 {
+ [__APP_INTERCEPTION_CONTEXT_KEY]: _interceptionContext,
+ __route: _noExportRouteId,
+ __rootLayout: _noExportRootLayout,
+ [_noExportRouteId]: createElement("div", null, "Page has no default export"),
+ };
}
// Resolve metadata and viewport from layouts and page.
@@ -11778,12 +11061,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 +11080,27 @@ 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,
+ interceptionContext: opts?.interceptionContext ?? null,
+ 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,
+ });
}
@@ -12642,6 +11766,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
}
const isRscRequest = pathname.endsWith(".rsc") || request.headers.get("accept")?.includes("text/x-component");
+ const interceptionContextHeader = request.headers.get("X-Vinext-Interception-Context");
let cleanPathname = pathname.replace(/\\.rsc$/, "");
// Middleware response headers and custom rewrite status are stored in
@@ -13012,9 +12137,21 @@ 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 = __createAppPayloadRouteId(cleanPathname, null);
+ element = {
+ [__APP_INTERCEPTION_CONTEXT_KEY]: null,
+ __route: _actionRouteId,
+ __rootLayout: null,
+ [_actionRouteId]: createElement("div", null, "Page not found"),
+ };
}
const onRenderError = createRscOnErrorHandler(
@@ -13336,7 +12473,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 +12528,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,
@@ -13420,6 +12571,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
setNavigationContext,
toInterceptOpts(intercept) {
return {
+ interceptionContext: interceptionContextHeader,
interceptSlot: intercept.slotName,
interceptPage: intercept.page,
interceptParams: intercept.matchedParams,
@@ -13433,7 +12585,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..4fd635d20
--- /dev/null
+++ b/tests/app-browser-entry.test.ts
@@ -0,0 +1,186 @@
+import React from "react";
+import { describe, expect, it, vi } from "vite-plus/test";
+import {
+ APP_INTERCEPTION_CONTEXT_KEY,
+ 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,
+ interceptionContext: string | null = null,
+ extraEntries: Record = {},
+) {
+ return normalizeAppElements({
+ [APP_INTERCEPTION_CONTEXT_KEY]: interceptionContext,
+ [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,
+ interceptionContext: null,
+ rootLayoutTreePath: "/",
+ routeId: "route:/initial",
+ ...overrides,
+ };
+}
+
+describe("app browser entry state helpers", () => {
+ it("merges elements on navigate", async () => {
+ const previousElements = createResolvedElements("route:/initial", "/", null, {
+ "layout:/": React.createElement("div", null, "layout"),
+ });
+ const nextElements = createResolvedElements("route:/next", "/", null, {
+ "page:/next": React.createElement("main", null, "next"),
+ });
+
+ const nextState = routerReducer(
+ createState({
+ elements: previousElements,
+ }),
+ {
+ elements: nextElements,
+ interceptionContext: null,
+ navigationSnapshot: createState().navigationSnapshot,
+ renderId: 1,
+ rootLayoutTreePath: "/",
+ routeId: "route:/next",
+ type: "navigate",
+ },
+ );
+
+ expect(nextState.routeId).toBe("route:/next");
+ expect(nextState.interceptionContext).toBeNull();
+ 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", "/", null, {
+ "page:/next": React.createElement("main", null, "next"),
+ });
+
+ const nextState = routerReducer(createState(), {
+ elements: nextElements,
+ interceptionContext: null,
+ navigationSnapshot: createState().navigationSnapshot,
+ renderId: 1,
+ rootLayoutTreePath: "/",
+ routeId: "route:/next",
+ type: "replace",
+ });
+
+ expect(nextState.elements).toBe(nextElements);
+ expect(nextState.interceptionContext).toBeNull();
+ expect(nextState.elements).toMatchObject({
+ "page:/next": expect.anything(),
+ });
+ });
+
+ it("carries interception context through pending navigation commits", async () => {
+ const pending = await createPendingNavigationCommit({
+ currentState: createState(),
+ nextElements: Promise.resolve(
+ createResolvedElements("route:/photos/42\0/feed", "/", "/feed", {
+ "page:/photos/42": React.createElement("main", null, "photo"),
+ }),
+ ),
+ navigationSnapshot: createState().navigationSnapshot,
+ type: "navigate",
+ });
+
+ expect(pending.routeId).toBe("route:/photos/42\0/feed");
+ expect(pending.interceptionContext).toBe("/feed");
+ expect(pending.action.interceptionContext).toBe("/feed");
+ });
+
+ 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..a96fef104
--- /dev/null
+++ b/tests/app-elements.test.ts
@@ -0,0 +1,112 @@
+import React from "react";
+import { describe, expect, it } from "vite-plus/test";
+import { UNMATCHED_SLOT } from "../packages/vinext/src/shims/slot.js";
+import {
+ APP_INTERCEPTION_CONTEXT_KEY,
+ APP_ROOT_LAYOUT_KEY,
+ APP_ROUTE_KEY,
+ APP_UNMATCHED_SLOT_WIRE_VALUE,
+ createAppPayloadCacheKey,
+ createAppPayloadRouteId,
+ normalizeAppElements,
+ readAppElementsMetadata,
+ resolveVisitedResponseInterceptionContext,
+} 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_INTERCEPTION_CONTEXT_KEY]: "/feed",
+ [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.interceptionContext).toBe("/feed");
+ expect(metadata.rootLayoutTreePath).toBe("/(dashboard)");
+ });
+
+ it("defaults missing interception context metadata to null", () => {
+ const metadata = readAppElementsMetadata(
+ normalizeAppElements({
+ [APP_ROOT_LAYOUT_KEY]: "/",
+ [APP_ROUTE_KEY]: "route:/dashboard",
+ "route:/dashboard": React.createElement("div", null, "route"),
+ }),
+ );
+
+ expect(metadata.interceptionContext).toBeNull();
+ });
+
+ it("encodes intercepted route ids and cache keys with a NUL separator", () => {
+ expect(createAppPayloadRouteId("/photos/42", null)).toBe("route:/photos/42");
+ expect(createAppPayloadRouteId("/photos/42", "/feed")).toBe("route:/photos/42\0/feed");
+ expect(createAppPayloadCacheKey("/photos/42.rsc", null)).toBe("/photos/42.rsc");
+ expect(createAppPayloadCacheKey("/photos/42.rsc", "/feed")).toBe("/photos/42.rsc\0/feed");
+ });
+
+ it("preserves the request cache context when a direct-route payload omits it", () => {
+ expect(resolveVisitedResponseInterceptionContext("/feed", null)).toBe("/feed");
+ expect(resolveVisitedResponseInterceptionContext("/feed", "/feed")).toBe("/feed");
+ expect(resolveVisitedResponseInterceptionContext("/feed", "/gallery")).toBe("/gallery");
+ expect(resolveVisitedResponseInterceptionContext(null, null)).toBeNull();
+ });
+
+ 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");
+ });
+
+ it("rejects payloads with an invalid __interceptionContext value", () => {
+ expect(() =>
+ readAppElementsMetadata(
+ normalizeAppElements({
+ [APP_INTERCEPTION_CONTEXT_KEY]: 123,
+ [APP_ROOT_LAYOUT_KEY]: "/",
+ [APP_ROUTE_KEY]: "route:/dashboard",
+ }),
+ ),
+ ).toThrow("[vinext] Invalid __interceptionContext 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..2862b2417 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();
@@ -427,7 +445,10 @@ describe("App Router integration", () => {
it("renders intercepted photo modal on RSC navigation from feed", async () => {
// RSC request simulates client-side navigation
const res = await fetch(`${baseUrl}/photos/42.rsc`, {
- headers: { Accept: "text/x-component" },
+ headers: {
+ Accept: "text/x-component",
+ "X-Vinext-Interception-Context": "/feed",
+ },
});
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toContain("text/x-component");
@@ -439,6 +460,13 @@ describe("App Router integration", () => {
// It should also contain the feed page content (the source route)
expect(rscPayload).toContain("Photo Feed");
expect(rscPayload).toContain("feed-page");
+ expect(rscPayload).toContain("__interceptionContext");
+ expect(rscPayload).toContain("/feed");
+ const nul = String.fromCharCode(0);
+ expect(
+ rscPayload.includes("route:/photos/42\\u0000/feed") ||
+ rscPayload.includes(`route:/photos/42${nul}/feed`),
+ ).toBe(true);
});
it("returns Method Not Allowed for unsupported HTTP methods on route handlers", async () => {
diff --git a/tests/e2e/app-router/advanced.spec.ts b/tests/e2e/app-router/advanced.spec.ts
index 6b5837d38..88b781b4f 100644
--- a/tests/e2e/app-router/advanced.spec.ts
+++ b/tests/e2e/app-router/advanced.spec.ts
@@ -2,6 +2,12 @@ import { test, expect } from "@playwright/test";
const BASE = "http://localhost:4174";
+async function waitForAppRouterHydration(page: import("@playwright/test").Page) {
+ await page.waitForFunction(() => typeof window.__VINEXT_RSC_NAVIGATE__ === "function", null, {
+ timeout: 10_000,
+ });
+}
+
test.describe("Parallel Routes", () => {
test("dashboard renders all parallel slot content", async ({ page }) => {
await page.goto(`${BASE}/dashboard`);
@@ -55,30 +61,92 @@ test.describe("Intercepting Routes", () => {
await expect(page.locator('[data-testid="photo-modal"]')).not.toBeVisible();
});
- // TODO: This test is temporarily skipped due to a timing issue with embedded
- // RSC hydration. The intercepting route feature still works - this is a test
- // infrastructure issue that needs investigation. See issue #61 comments.
- test.skip("RSC client navigation intercepts to show modal", async ({ page }) => {
- // Start on the feed page
+ test("direct payload cache does not override intercepted navigation", async ({ page }) => {
+ await page.goto(`${BASE}/photos/42`);
+ await expect(page.locator('[data-testid="photo-page"]')).toBeVisible();
+
await page.goto(`${BASE}/feed`);
+ await waitForAppRouterHydration(page);
- // Wait for hydration
- await page.waitForFunction(
- () => typeof (window as any).__VINEXT_RSC_NAVIGATE__ === "function",
- null,
- { timeout: 10000 },
- );
+ await page.click("#feed-photo-42-link");
- // Click a photo link — this should be intercepted and show a modal
- await page.click('a[href="/photos/1"]');
+ await expect(page.locator('[data-testid="photo-modal"]')).toBeVisible();
+ await expect(page.locator('[data-testid="feed-page"]')).toBeVisible();
+ await expect(page.locator('[data-testid="photo-page"]')).not.toBeVisible();
+ });
- // Wait for RSC navigation to complete
- await page.waitForTimeout(1000);
+ test("intercepted payload cache is reused for repeated source-page navigations", async ({
+ page,
+ }) => {
+ await page.goto(`${BASE}/feed`);
+ await waitForAppRouterHydration(page);
- // The modal version of the photo should appear
- await expect(page.locator('[data-testid="photo-modal"]')).toBeVisible({
- timeout: 5000,
- });
+ await page.click("#feed-photo-42-link");
+ await expect(page.locator('[data-testid="photo-modal"]')).toBeVisible();
+
+ await page.goto(`${BASE}/about`);
+ await page.goto(`${BASE}/feed`);
+ await waitForAppRouterHydration(page);
+
+ await page.click("#feed-photo-42-link");
+ await expect(page.locator('[data-testid="photo-modal"]')).toBeVisible();
+ await expect(page.locator('[data-testid="photo-page"]')).not.toBeVisible();
+ });
+
+ test("chained intercepted navigations keep the original source context", async ({ page }) => {
+ await page.goto(`${BASE}/feed`);
+ await waitForAppRouterHydration(page);
+
+ await page.click("#feed-photo-42-link");
+ await expect(page.locator('[data-testid="photo-modal"]')).toBeVisible();
+ await expect(page.locator('[data-testid="photo-modal"]')).toContainText("Viewing photo 42");
+
+ await page.click("#modal-photo-43-link");
+
+ await expect(page.locator('[data-testid="photo-modal"]')).toBeVisible();
+ await expect(page.locator('[data-testid="photo-modal"]')).toContainText("Viewing photo 43");
+ await expect(page.locator('[data-testid="feed-page"]')).toBeVisible();
+ await expect(page.locator('[data-testid="photo-page"]')).not.toBeVisible();
+ });
+
+ test("refresh on direct photo load preserves the full-page render", async ({ page }) => {
+ await page.goto(`${BASE}/photos/42`);
+ await expect(page.locator('[data-testid="photo-page"]')).toBeVisible();
+
+ await page.reload();
+ await waitForAppRouterHydration(page);
+
+ await expect(page.locator('[data-testid="photo-page"]')).toBeVisible();
+ await expect(page.locator('[data-testid="photo-modal"]')).not.toBeVisible();
+ });
+
+ test("prefetches keep separate cache entries for feed and gallery interception contexts", async ({
+ page,
+ }) => {
+ await page.goto(`${BASE}/feed`);
+ await waitForAppRouterHydration(page);
+ await expect
+ .poll(async () =>
+ page.evaluate(() =>
+ Array.from(window.__VINEXT_RSC_PREFETCH_CACHE__?.keys() ?? []).filter((key) =>
+ key.includes("/photos/42.rsc"),
+ ),
+ ),
+ )
+ .toEqual(["/photos/42.rsc\u0000/feed"]);
+
+ await page.click("#gallery-link");
+ await page.waitForURL(`${BASE}/gallery`);
+ await waitForAppRouterHydration(page);
+ await expect
+ .poll(async () =>
+ page.evaluate(() =>
+ Array.from(window.__VINEXT_RSC_PREFETCH_CACHE__?.keys() ?? [])
+ .filter((key) => key.includes("/photos/42.rsc"))
+ .sort(),
+ ),
+ )
+ .toEqual(["/photos/42.rsc\u0000/feed", "/photos/42.rsc\u0000/gallery"]);
});
});
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({