diff --git a/packages/vinext/src/build/layout-classification.ts b/packages/vinext/src/build/layout-classification.ts new file mode 100644 index 000000000..a492cbb65 --- /dev/null +++ b/packages/vinext/src/build/layout-classification.ts @@ -0,0 +1,111 @@ +/** + * Layout classification — determines whether each layout in an App Router + * route tree is static or dynamic via two complementary detection layers: + * + * Layer 1: Segment config (`export const dynamic`, `export const revalidate`) + * Layer 2: Module graph traversal (checks for transitive dynamic shim imports) + * + * Layer 3 (probe-based runtime detection) is handled separately in + * `app-page-execution.ts` at request time. + */ + +import { classifyLayoutSegmentConfig } from "./report.js"; +import { createAppPageTreePath } from "../server/app-page-route-wiring.js"; + +export type ModuleGraphClassification = "static" | "needs-probe"; +export type LayoutClassificationResult = "static" | "dynamic" | "needs-probe"; + +export type ModuleInfoProvider = { + getModuleInfo(id: string): { + importedIds: string[]; + dynamicImportedIds: string[]; + } | null; +}; + +type RouteForClassification = { + layouts: string[]; + layoutTreePositions: number[]; + routeSegments: string[]; + layoutSegmentConfigs?: ReadonlyArray<{ code: string } | null>; +}; + +/** + * BFS traversal of a layout's dependency tree. If any transitive import + * resolves to a dynamic shim path (headers, cache, server), the layout + * cannot be proven static at build time and needs a runtime probe. + */ +export function classifyLayoutByModuleGraph( + layoutModuleId: string, + dynamicShimPaths: ReadonlySet, + moduleInfo: ModuleInfoProvider, +): ModuleGraphClassification { + const visited = new Set(); + const queue: string[] = [layoutModuleId]; + + while (queue.length > 0) { + const currentId = queue.shift()!; + + if (visited.has(currentId)) continue; + visited.add(currentId); + + if (dynamicShimPaths.has(currentId)) return "needs-probe"; + + const info = moduleInfo.getModuleInfo(currentId); + if (!info) continue; + + for (const importedId of info.importedIds) { + if (!visited.has(importedId)) queue.push(importedId); + } + for (const dynamicId of info.dynamicImportedIds) { + if (!visited.has(dynamicId)) queue.push(dynamicId); + } + } + + return "static"; +} + +/** + * Classifies all layouts across all routes using a two-layer strategy: + * + * 1. Segment config (Layer 1) — short-circuits to "static" or "dynamic" + * 2. Module graph (Layer 2) — BFS for dynamic shim imports → "static" or "needs-probe" + * + * Shared layouts (same file appearing in multiple routes) are classified once + * and deduplicated by layout ID. + */ +export function classifyAllRouteLayouts( + routes: readonly RouteForClassification[], + dynamicShimPaths: ReadonlySet, + moduleInfo: ModuleInfoProvider, +): Map { + const result = new Map(); + + for (const route of routes) { + for (let i = 0; i < route.layouts.length; i++) { + const treePosition = route.layoutTreePositions[i] ?? 0; + const layoutId = `layout:${createAppPageTreePath(route.routeSegments, treePosition)}`; + + if (result.has(layoutId)) continue; + + // Layer 1: segment config + const segmentConfig = route.layoutSegmentConfigs?.[i]; + if (segmentConfig) { + const configResult = classifyLayoutSegmentConfig(segmentConfig.code); + if (configResult !== null) { + result.set(layoutId, configResult); + continue; + } + } + + // Layer 2: module graph + const graphResult = classifyLayoutByModuleGraph( + route.layouts[i], + dynamicShimPaths, + moduleInfo, + ); + result.set(layoutId, graphResult); + } + } + + return result; +} diff --git a/packages/vinext/src/build/report.ts b/packages/vinext/src/build/report.ts index a29b05842..70b30343f 100644 --- a/packages/vinext/src/build/report.ts +++ b/packages/vinext/src/build/report.ts @@ -607,6 +607,37 @@ function findMatchingToken( return -1; } +// ─── Layout segment config classification ──────────────────────────────────── + +/** + * Classification result for layout segment config analysis. + * "static" means the layout is confirmed static via segment config. + * "dynamic" means the layout is confirmed dynamic via segment config. + */ +export type LayoutClassification = "static" | "dynamic"; + +/** + * Classifies a layout file by its segment config exports (`dynamic`, `revalidate`). + * + * Returns `"static"` or `"dynamic"` when the config is decisive, or `null` + * when no segment config is present (deferring to module graph analysis). + * + * Unlike page classification, positive `revalidate` values are not meaningful + * for layout skip decisions — ISR is a page-level concept. Only the extremes + * (`revalidate = 0` → dynamic, `revalidate = Infinity` → static) are decisive. + */ +export function classifyLayoutSegmentConfig(code: string): LayoutClassification | null { + const dynamicValue = extractExportConstString(code, "dynamic"); + if (dynamicValue === "force-dynamic") return "dynamic"; + if (dynamicValue === "force-static" || dynamicValue === "error") return "static"; + + const revalidateValue = extractExportConstNumber(code, "revalidate"); + if (revalidateValue === Infinity) return "static"; + if (revalidateValue === 0) return "dynamic"; + + return null; +} + // ─── Route classification ───────────────────────────────────────────────────── /** 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/global.d.ts b/packages/vinext/src/global.d.ts index 2e85310ce..1b3584b31 100644 --- a/packages/vinext/src/global.d.ts +++ b/packages/vinext/src/global.d.ts @@ -93,6 +93,7 @@ declare global { redirectDepth?: number, navigationKind?: "navigate" | "traverse" | "refresh", historyUpdateMode?: "push" | "replace", + previousNextUrlOverride?: string | null, ) => Promise) | undefined; 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..1c1635ba0 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,6 +26,8 @@ import { commitClientNavigationState, consumePrefetchResponse, createClientNavigationRenderSnapshot, + getCurrentNextUrl, + getCurrentInterceptionContext, getClientNavigationRenderContext, getPrefetchCache, getPrefetchedUrls, @@ -46,22 +48,37 @@ import { createProgressiveRscStream, getVinextBrowserGlobal, } from "./app-browser-stream.js"; +import { + createAppPayloadCacheKey, + normalizeAppElements, + readAppElementsMetadata, + resolveVisitedResponseInterceptionContext, + type AppElements, + type AppWireElements, + type LayoutFlags, +} from "./app-elements.js"; +import { + createHistoryStateWithPreviousNextUrl, + createPendingNavigationCommit, + readHistoryStatePreviousNextUrl, + resolveInterceptionContextFromPreviousNextUrl, + routerReducer, + shouldHardNavigate, + type AppRouterAction, + type AppRouterState, +} from "./app-browser-state.js"; +import { ElementsContext, Slot } from "../shims/slot.js"; type SearchParamInput = ConstructorParameters[0]; type ServerActionResult = { - root: ReactNode; + root: AppWireElements; returnValue?: { ok: boolean; data: unknown; }; }; -type BrowserTreeState = { - renderId: number; - node: ReactNode; - navigationSnapshot: ClientNavigationRenderSnapshot; -}; type NavigationKind = "navigate" | "traverse" | "refresh"; type HistoryUpdateMode = "push" | "replace"; type VisitedResponseCacheEntry = { @@ -89,7 +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 dispatchBrowserRouterAction; +} + +function getBrowserRouterState(): AppRouterState { + if (!browserRouterStateRef) { + throw new Error("[vinext] Browser router state is not initialized"); } - return setBrowserTreeState; + return browserRouterStateRef.current; } function applyClientParams(params: Record): void { @@ -111,12 +136,10 @@ function applyClientParams(params: Record): void { function stageClientParams(params: Record): void { // NB: latestClientParams diverges from ClientNavigationState.clientParams - // between staging and commit. Server action snapshots (updateBrowserTree - // calls inside registerServerActionCallback) read latestClientParams, so a + // between staging and commit. Server action snapshots read latestClientParams, so a // server action fired during this window would get the pending (not yet // committed) params. This is acceptable because the commit effect fires - // synchronously in the same React commit phase, keeping the window - // vanishingly small. + // before hooks observe the new URL state, keeping the window vanishingly small. latestClientParams = params; replaceClientParamsWithoutNotify(params); } @@ -171,14 +194,22 @@ function drainPrePaintEffects(upToRenderId: number): void { function createNavigationCommitEffect( href: string, historyUpdateMode: HistoryUpdateMode | undefined, + params: Record, + previousNextUrl: string | null, ): () => void { return () => { const targetHref = new URL(href, window.location.origin).href; + stageClientParams(params); + const preserveExistingState = historyUpdateMode === "replace"; + const historyState = createHistoryStateWithPreviousNextUrl( + preserveExistingState ? window.history.state : null, + previousNextUrl, + ); 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 +228,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 +244,101 @@ 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, }); } +type NavigationRequestState = { + interceptionContext: string | null; + previousNextUrl: string | null; +}; + +function getRequestState( + navigationKind: NavigationKind, + previousNextUrlOverride?: string | null, +): NavigationRequestState { + if (previousNextUrlOverride !== undefined) { + return { + interceptionContext: resolveInterceptionContextFromPreviousNextUrl( + previousNextUrlOverride, + __basePath, + ), + previousNextUrl: previousNextUrlOverride, + }; + } + + switch (navigationKind) { + case "navigate": + return { + interceptionContext: getCurrentInterceptionContext(), + previousNextUrl: getCurrentNextUrl(), + }; + case "traverse": { + const previousNextUrl = readHistoryStatePreviousNextUrl(window.history.state); + return { + interceptionContext: resolveInterceptionContextFromPreviousNextUrl( + previousNextUrl, + __basePath, + ), + previousNextUrl, + }; + } + case "refresh": + return { + interceptionContext: resolveInterceptionContextFromPreviousNextUrl( + getBrowserRouterState().previousNextUrl, + __basePath, + ), + previousNextUrl: getBrowserRouterState().previousNextUrl, + }; + 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 +379,57 @@ 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, + layoutFlags: initialMetadata.layoutFlags, navigationSnapshot: initialNavigationSnapshot, + previousNextUrl: null, + renderId: 0, + rootLayoutTreePath: initialMetadata.rootLayoutTreePath, + routeId: initialMetadata.routeId, }); - // Assign the module-level setter via useLayoutEffect instead of during render - // to avoid side effects that React Strict Mode / concurrent features may - // call multiple times. useLayoutEffect fires synchronously during commit, - // before hydrateRoot returns to main(), so setBrowserTreeState is available - // before __VINEXT_RSC_NAVIGATE__ is assigned. setTreeState is referentially - // stable so the effect only runs on mount. + // Keep the latest router state in a ref so external callers (navigate(), + // server actions, HMR) always read the current state. The ref is updated + // synchronously during render, not in an effect, so there is no stale + // window between React committing a new state and the effect firing. + const stateRef = useRef(treeState); + stateRef.current = treeState; + browserRouterStateRef = stateRef; + + // Assign the module-level dispatch via useLayoutEffect. dispatchTreeState + // is referentially stable so the effect only runs on mount. The effect fires + // synchronously during commit, before hydrateRoot returns to main(), so the + // dispatch is available before __VINEXT_RSC_NAVIGATE__ is assigned. useLayoutEffect(() => { - setBrowserTreeState = setTreeState; - }, []); // eslint-disable-line react-hooks/exhaustive-deps -- setTreeState is referentially stable + dispatchBrowserRouterAction = dispatchTreeState; + }, [dispatchTreeState]); const committedTree = createElement( NavigationCommitSignal, { renderId: treeState.renderId }, - treeState.node, + createElement( + ElementsContext.Provider, + { value: treeState.elements }, + createElement(Slot, { id: treeState.routeId }), + ), ); const ClientNavigationRenderContext = getClientNavigationRenderContext(); @@ -328,83 +444,115 @@ function BrowserRoot({ ); } -function updateBrowserTree( - node: ReactNode | Promise, +function dispatchBrowserTree( + elements: AppElements, navigationSnapshot: ClientNavigationRenderSnapshot, renderId: number, + actionType: "navigate" | "replace", + interceptionContext: string | null, + layoutFlags: LayoutFlags, + previousNextUrl: 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, + layoutFlags, + navigationSnapshot, + previousNextUrl, + 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, + previousNextUrl: string | null, useTransition = true, + actionType: "navigate" | "replace" = "navigate", ): Promise { const renderId = ++nextNavigationRenderId; - queuePrePaintNavigationEffect(renderId, prePaintEffect); - const committed = new Promise((resolve) => { pendingNavigationCommits.set(renderId, resolve); }); - activateNavigationSnapshot(); - - // Wrap updateBrowserTree in try-catch to ensure counter is decremented - // if a synchronous error occurs before the async promise chain is established. + let snapshotActivated = false; try { - updateBrowserTree(payload, navigationSnapshot, renderId, useTransition, true); + const currentState = getBrowserRouterState(); + const pending = await createPendingNavigationCommit({ + currentState, + nextElements: payload, + navigationSnapshot, + previousNextUrl, + 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.previousNextUrl), + ); + activateNavigationSnapshot(); + snapshotActivated = true; + dispatchBrowserTree( + pending.action.elements, + navigationSnapshot, + renderId, + actionType, + pending.interceptionContext, + pending.action.layoutFlags, + pending.previousNextUrl, + 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 +648,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,24 +684,41 @@ function registerServerActionCallback(): void { clearClientNavigationCaches(); - const result = await createFromFetch( + const result = await createFromFetch( Promise.resolve(fetchResponse), { temporaryReferences }, ); - // Note: Server actions update the tree via updateBrowserTree directly (not + // Note: Server actions update the tree by dispatching the merged payload directly (not // renderNavigationPayload) because they stay on the same URL. This means // activateNavigationSnapshot is not called, so hooks use useSyncExternalStore - // values directly. snapshotActivated is intentionally omitted (defaults false) - // so handleAsyncError skips commitClientNavigationState() — decrementing an - // unincremented counter would corrupt it for concurrent RSC navigations. + // values directly. There is no snapshot-activation bookkeeping here, so + // commitClientNavigationState() must not run for this path. // If server actions ever trigger URL changes via RSC payload (instead of hard - // redirects), this would need renderNavigationPayload() + snapshotActivated=true. + // redirects), this would need renderNavigationPayload() instead. 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, + previousNextUrl: getBrowserRouterState().previousNextUrl, + renderId: ++nextNavigationRenderId, + type: "navigate", + }); + dispatchBrowserTree( + pending.action.elements, + navigationSnapshot, + pending.action.renderId, + "navigate", + pending.interceptionContext, + pending.action.layoutFlags, + pending.previousNextUrl, + pending.routeId, + pending.rootLayoutTreePath, false, ); if (result.returnValue) { @@ -561,11 +728,28 @@ 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, + previousNextUrl: getBrowserRouterState().previousNextUrl, + renderId: ++nextNavigationRenderId, + type: "navigate", + }); + dispatchBrowserTree( + pending.action.elements, + navigationSnapshot, + pending.action.renderId, + "navigate", + pending.interceptionContext, + pending.action.layoutFlags, + pending.previousNextUrl, + pending.routeId, + pending.rootLayoutTreePath, false, ); return result; @@ -576,16 +760,21 @@ 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, ); + replaceHistoryStateWithoutNotify( + createHistoryStateWithPreviousNextUrl(window.history.state, null), + "", + window.location.href, + ); window.__VINEXT_RSC_ROOT__ = hydrateRoot( document, createElement(BrowserRoot, { - initialNode: root, + initialElements: root, initialNavigationSnapshot, }), import.meta.env.DEV ? { onCaughtError() {} } : undefined, @@ -597,6 +786,7 @@ async function main(): Promise { redirectDepth = 0, navigationKind: NavigationKind = "navigate", historyUpdateMode?: HistoryUpdateMode, + previousNextUrlOverride?: string | null, ): Promise { if (redirectDepth > 10) { console.error( @@ -613,6 +803,9 @@ async function main(): Promise { try { const url = new URL(href, window.location.origin); const rscUrl = toRscUrl(url.pathname + url.search); + const requestState = getRequestState(navigationKind, previousNextUrlOverride); + const requestInterceptionContext = requestState.interceptionContext; + const requestPreviousNextUrl = requestState.previousNextUrl; // 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 +817,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 +833,22 @@ 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, + requestPreviousNextUrl, isSameRoute, ); } finally { @@ -672,7 +862,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 +870,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 +884,11 @@ async function main(): Promise { if (finalUrl.pathname !== requestedUrl.pathname) { const destinationPath = finalUrl.pathname.replace(/\.rsc$/, "") + finalUrl.search; - replaceHistoryStateWithoutNotify(null, "", destinationPath); + replaceHistoryStateWithoutNotify( + createHistoryStateWithPreviousNextUrl(null, requestPreviousNextUrl), + "", + destinationPath, + ); const navigate = window.__VINEXT_RSC_NAVIGATE__; if (!navigate) { @@ -704,7 +899,13 @@ async function main(): Promise { // The URL has already been updated via replaceHistoryStateWithoutNotify above, // so the recursive navigation should NOT push/replace again. Pass undefined // for historyUpdateMode to make the commit effect a no-op for history updates. - return navigate(destinationPath, redirectDepth + 1, navigationKind, undefined); + return navigate( + destinationPath, + redirectDepth + 1, + navigationKind, + undefined, + requestPreviousNextUrl, + ); } let navParams: Record = {}; @@ -726,23 +927,22 @@ 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, + requestPreviousNextUrl, isSameRoute, ); } finally { @@ -753,11 +953,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 +1014,31 @@ 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.action.layoutFlags, + pending.previousNextUrl, + pending.routeId, + pending.rootLayoutTreePath, false, ); } catch (error) { @@ -818,4 +1048,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..fdf140676 --- /dev/null +++ b/packages/vinext/src/server/app-browser-state.ts @@ -0,0 +1,191 @@ +import { mergeElements } from "../shims/slot.js"; +import { stripBasePath } from "../utils/base-path.js"; +import { readAppElementsMetadata, type AppElements, type LayoutFlags } from "./app-elements.js"; +import type { ClientNavigationRenderSnapshot } from "../shims/navigation.js"; + +const VINEXT_PREVIOUS_NEXT_URL_HISTORY_STATE_KEY = "__vinext_previousNextUrl"; + +type HistoryStateRecord = { + [key: string]: unknown; +}; + +export type AppRouterState = { + elements: AppElements; + interceptionContext: string | null; + layoutFlags: LayoutFlags; + previousNextUrl: string | null; + renderId: number; + navigationSnapshot: ClientNavigationRenderSnapshot; + rootLayoutTreePath: string | null; + routeId: string; +}; + +export type AppRouterAction = { + elements: AppElements; + interceptionContext: string | null; + layoutFlags: LayoutFlags; + navigationSnapshot: ClientNavigationRenderSnapshot; + previousNextUrl: string | null; + renderId: number; + rootLayoutTreePath: string | null; + routeId: string; + type: "navigate" | "replace"; +}; + +export type PendingNavigationCommit = { + action: AppRouterAction; + interceptionContext: string | null; + previousNextUrl: string | null; + rootLayoutTreePath: string | null; + routeId: string; +}; + +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; +} + +export function createHistoryStateWithPreviousNextUrl( + state: unknown, + previousNextUrl: string | null, +): HistoryStateRecord | null { + const nextState = cloneHistoryState(state); + + if (previousNextUrl === null) { + delete nextState[VINEXT_PREVIOUS_NEXT_URL_HISTORY_STATE_KEY]; + } else { + nextState[VINEXT_PREVIOUS_NEXT_URL_HISTORY_STATE_KEY] = previousNextUrl; + } + + return Object.keys(nextState).length > 0 ? nextState : null; +} + +export function readHistoryStatePreviousNextUrl(state: unknown): string | null { + const value = cloneHistoryState(state)[VINEXT_PREVIOUS_NEXT_URL_HISTORY_STATE_KEY]; + return typeof value === "string" ? value : null; +} + +export function resolveInterceptionContextFromPreviousNextUrl( + previousNextUrl: string | null, + basePath: string = "", +): string | null { + if (previousNextUrl === null) { + return null; + } + + const parsedUrl = new URL(previousNextUrl, "http://localhost"); + return stripBasePath(parsedUrl.pathname, basePath); +} + +export function routerReducer(state: AppRouterState, action: AppRouterAction): AppRouterState { + switch (action.type) { + case "navigate": + return { + elements: mergeElements(state.elements, action.elements), + interceptionContext: action.interceptionContext, + layoutFlags: { ...state.layoutFlags, ...action.layoutFlags }, + navigationSnapshot: action.navigationSnapshot, + previousNextUrl: action.previousNextUrl, + renderId: action.renderId, + rootLayoutTreePath: action.rootLayoutTreePath, + routeId: action.routeId, + }; + case "replace": + return { + elements: action.elements, + interceptionContext: action.interceptionContext, + layoutFlags: action.layoutFlags, + navigationSnapshot: action.navigationSnapshot, + previousNextUrl: action.previousNextUrl, + 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; + previousNextUrl?: string | null; + renderId?: number; + type: "navigate" | "replace"; +}): Promise { + const elements = await options.nextElements; + const metadata = readAppElementsMetadata(elements); + const previousNextUrl = options.previousNextUrl ?? options.currentState.previousNextUrl; + + return { + action: { + elements, + interceptionContext: metadata.interceptionContext, + layoutFlags: metadata.layoutFlags, + navigationSnapshot: options.navigationSnapshot, + previousNextUrl, + renderId: options.renderId ?? options.currentState.renderId + 1, + rootLayoutTreePath: metadata.rootLayoutTreePath, + routeId: metadata.routeId, + type: options.type, + }, + interceptionContext: metadata.interceptionContext, + previousNextUrl, + 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; + previousNextUrl?: string | null; + 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, + previousNextUrl: options.previousNextUrl, + 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..5109f7016 --- /dev/null +++ b/packages/vinext/src/server/app-elements.ts @@ -0,0 +1,132 @@ +import type { ReactNode } from "react"; + +const APP_INTERCEPTION_SEPARATOR = "\0"; + +export const APP_INTERCEPTION_CONTEXT_KEY = "__interceptionContext"; +export const APP_LAYOUT_FLAGS_KEY = "__layoutFlags"; +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 LayoutFlags = Readonly>; + +export type AppElementsMetadata = { + interceptionContext: string | null; + layoutFlags: LayoutFlags; + 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; +} + +function isLayoutFlagsRecord(value: unknown): value is LayoutFlags { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + for (const v of Object.values(value)) { + if (v !== "s" && v !== "d") return false; + } + return true; +} + +function parseLayoutFlags(value: unknown): LayoutFlags { + if (isLayoutFlagsRecord(value)) return value; + return {}; +} + +/** + * Parses metadata from the wire payload. Accepts `Record` + * because the RSC payload carries heterogeneous values (React elements, + * strings, and plain objects like layout flags) under the same record type. + */ +export function readAppElementsMetadata( + elements: Readonly>, +): 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"); + } + + const layoutFlags = parseLayoutFlags(elements[APP_LAYOUT_FLAGS_KEY]); + + return { + interceptionContext: interceptionContext ?? null, + layoutFlags, + 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-execution.ts b/packages/vinext/src/server/app-page-execution.ts index 806d7214b..b55b981e0 100644 --- a/packages/vinext/src/server/app-page-execution.ts +++ b/packages/vinext/src/server/app-page-execution.ts @@ -1,3 +1,7 @@ +import type { LayoutFlags } from "./app-elements.js"; + +export type { LayoutFlags }; + export type AppPageSpecialError = | { kind: "redirect"; location: string; statusCode: number } | { kind: "http-access-fallback"; statusCode: number }; @@ -19,11 +23,27 @@ export type BuildAppPageSpecialErrorResponseOptions = { specialError: AppPageSpecialError; }; +export type ProbeAppPageLayoutsResult = { + response: Response | null; + layoutFlags: LayoutFlags; +}; + +export type LayoutClassificationOptions = { + /** Build-time classifications from segment config or module graph. */ + buildTimeClassifications?: ReadonlyMap | null; + /** Maps layout index to its layout ID (e.g. "layout:/blog"). */ + getLayoutId: (layoutIndex: number) => string; + /** Runs a function with isolated dynamic usage tracking per layout. */ + runWithIsolatedDynamicScope: (fn: () => T) => Promise<{ result: T; dynamicDetected: boolean }>; +}; + export type ProbeAppPageLayoutsOptions = { layoutCount: number; onLayoutError: (error: unknown, layoutIndex: number) => Promise; probeLayoutAt: (layoutIndex: number) => unknown; runWithSuppressedHookWarning(probe: () => Promise): Promise; + /** When provided, enables per-layout static/dynamic classification. */ + classification?: LayoutClassificationOptions | null; }; export type ProbeAppPageComponentOptions = { @@ -98,24 +118,64 @@ export async function buildAppPageSpecialErrorResponse( export async function probeAppPageLayouts( options: ProbeAppPageLayoutsOptions, -): Promise { - return options.runWithSuppressedHookWarning(async () => { +): Promise { + const layoutFlags: Record = {}; + const cls = options.classification ?? null; + + const response = await options.runWithSuppressedHookWarning(async () => { for (let layoutIndex = options.layoutCount - 1; layoutIndex >= 0; layoutIndex--) { - try { - const layoutResult = options.probeLayoutAt(layoutIndex); - if (isPromiseLike(layoutResult)) { - await layoutResult; - } - } catch (error) { - const response = await options.onLayoutError(error, layoutIndex); - if (response) { - return response; + const buildTimeResult = cls?.buildTimeClassifications?.get(layoutIndex); + + if (cls && buildTimeResult) { + // Build-time classified (Layer 1 or Layer 2): skip dynamic isolation, + // but still probe for special errors (redirects, not-found). + layoutFlags[cls.getLayoutId(layoutIndex)] = buildTimeResult === "static" ? "s" : "d"; + const errorResponse = await probeLayoutForErrors(options, layoutIndex); + if (errorResponse) return errorResponse; + continue; + } + + if (cls) { + // Layer 3: probe with isolated dynamic scope to detect per-layout + // dynamic API usage (headers(), cookies(), connection(), etc.) + try { + const { dynamicDetected } = await cls.runWithIsolatedDynamicScope(() => + options.probeLayoutAt(layoutIndex), + ); + layoutFlags[cls.getLayoutId(layoutIndex)] = dynamicDetected ? "d" : "s"; + } catch (error) { + // Probe failed — conservatively treat as dynamic. + layoutFlags[cls.getLayoutId(layoutIndex)] = "d"; + const errorResponse = await options.onLayoutError(error, layoutIndex); + if (errorResponse) return errorResponse; } + continue; } + + // No classification options — original behavior + const errorResponse = await probeLayoutForErrors(options, layoutIndex); + if (errorResponse) return errorResponse; } return null; }); + + return { response, layoutFlags }; +} + +async function probeLayoutForErrors( + options: ProbeAppPageLayoutsOptions, + layoutIndex: number, +): Promise { + try { + const layoutResult = options.probeLayoutAt(layoutIndex); + if (isPromiseLike(layoutResult)) { + await layoutResult; + } + } catch (error) { + return options.onLayoutError(error, layoutIndex); + } + return null; } export async function probeAppPageComponent( diff --git a/packages/vinext/src/server/app-page-probe.ts b/packages/vinext/src/server/app-page-probe.ts index 58c8c29e0..443bff36a 100644 --- a/packages/vinext/src/server/app-page-probe.ts +++ b/packages/vinext/src/server/app-page-probe.ts @@ -2,8 +2,15 @@ import { probeAppPageComponent, probeAppPageLayouts, type AppPageSpecialError, + type LayoutClassificationOptions, + type LayoutFlags, } from "./app-page-execution.js"; +export type ProbeAppPageBeforeRenderResult = { + response: Response | null; + layoutFlags: LayoutFlags; +}; + export type ProbeAppPageBeforeRenderOptions = { hasLoadingBoundary: boolean; layoutCount: number; @@ -16,15 +23,19 @@ export type ProbeAppPageBeforeRenderOptions = { renderPageSpecialError: (specialError: AppPageSpecialError) => Promise; resolveSpecialError: (error: unknown) => AppPageSpecialError | null; runWithSuppressedHookWarning(probe: () => Promise): Promise; + /** When provided, enables per-layout static/dynamic classification. */ + classification?: LayoutClassificationOptions | null; }; export async function probeAppPageBeforeRender( options: ProbeAppPageBeforeRenderOptions, -): Promise { +): Promise { + let layoutFlags: LayoutFlags = {}; + // Layouts render before their children in Next.js, so layout-level special // errors must be handled before probing the page component itself. if (options.layoutCount > 0) { - const layoutProbeResponse = await probeAppPageLayouts({ + const layoutProbeResult = await probeAppPageLayouts({ layoutCount: options.layoutCount, async onLayoutError(layoutError, layoutIndex) { const specialError = options.resolveSpecialError(layoutError); @@ -38,16 +49,19 @@ export async function probeAppPageBeforeRender( runWithSuppressedHookWarning(probe) { return options.runWithSuppressedHookWarning(probe); }, + classification: options.classification, }); - if (layoutProbeResponse) { - return layoutProbeResponse; + layoutFlags = layoutProbeResult.layoutFlags; + + if (layoutProbeResult.response) { + return { response: layoutProbeResult.response, layoutFlags }; } } // Server Components are functions, so we can probe the page ahead of stream // creation and only turn special throws into immediate responses. - return probeAppPageComponent({ + const pageResponse = await probeAppPageComponent({ awaitAsyncResult: !options.hasLoadingBoundary, async onError(pageError) { const specialError = options.resolveSpecialError(pageError); @@ -65,4 +79,6 @@ export async function probeAppPageBeforeRender( return options.runWithSuppressedHookWarning(probe); }, }); + + return { response: pageResponse, layoutFlags }; } diff --git a/packages/vinext/src/server/app-page-render.ts b/packages/vinext/src/server/app-page-render.ts index 8591cae79..fb63b3d51 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( @@ -116,7 +116,7 @@ function buildResponseTiming( export async function renderAppPageLifecycle( options: RenderAppPageLifecycleOptions, ): Promise { - const preRenderResponse = await probeAppPageBeforeRender({ + const preRenderResult = await probeAppPageBeforeRender({ hasLoadingBoundary: options.hasLoadingBoundary, layoutCount: options.layoutCount, probeLayoutAt(layoutIndex) { @@ -136,8 +136,8 @@ export async function renderAppPageLifecycle( return options.runWithSuppressedHookWarning(probe); }, }); - if (preRenderResponse) { - return preRenderResponse; + if (preRenderResult.response) { + return preRenderResult.response; } const compileEnd = options.isProduction ? undefined : performance.now(); 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..61e31e484 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"; @@ -273,6 +274,22 @@ export function toRscUrl(href: string): string { return normalizedPath + ".rsc" + query; } +export function getCurrentInterceptionContext(): string | null { + if (isServer) { + return null; + } + + return stripBasePath(window.location.pathname, __basePath); +} + +export function getCurrentNextUrl(): string { + if (isServer) { + return "/"; + } + + return window.location.pathname + window.location.search; +} + /** Get or create the shared in-memory RSC prefetch cache on window. */ export function getPrefetchCache(): Map { if (isServer) return new Map(); @@ -284,7 +301,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 +353,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 +366,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 +422,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 +439,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 +454,7 @@ export function prefetchRscResponse(rscUrl: string, fetchPromise: Promise= PREFETCH_CACHE_TTL) { @@ -1122,16 +1153,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..15931f4fa --- /dev/null +++ b/tests/app-browser-entry.test.ts @@ -0,0 +1,310 @@ +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, + createHistoryStateWithPreviousNextUrl, + createPendingNavigationCommit, + readHistoryStatePreviousNextUrl, + resolveInterceptionContextFromPreviousNextUrl, + 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", "/"), + layoutFlags: {}, + navigationSnapshot: createClientNavigationRenderSnapshot("https://example.com/initial", {}), + renderId: 0, + interceptionContext: null, + previousNextUrl: 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, + layoutFlags: {}, + navigationSnapshot: createState().navigationSnapshot, + previousNextUrl: null, + renderId: 1, + rootLayoutTreePath: "/", + routeId: "route:/next", + type: "navigate", + }, + ); + + expect(nextState.routeId).toBe("route:/next"); + expect(nextState.interceptionContext).toBeNull(); + expect(nextState.previousNextUrl).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, + layoutFlags: {}, + navigationSnapshot: createState().navigationSnapshot, + previousNextUrl: null, + renderId: 1, + rootLayoutTreePath: "/", + routeId: "route:/next", + type: "replace", + }); + + expect(nextState.elements).toBe(nextElements); + expect(nextState.interceptionContext).toBeNull(); + expect(nextState.previousNextUrl).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, + previousNextUrl: "/feed", + type: "navigate", + }); + + expect(pending.routeId).toBe("route:/photos/42\0/feed"); + expect(pending.interceptionContext).toBe("/feed"); + expect(pending.previousNextUrl).toBe("/feed"); + expect(pending.action.interceptionContext).toBe("/feed"); + expect(pending.action.previousNextUrl).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, + previousNextUrl: "/feed", + type: "navigate", + }); + + expect(refreshCommit.action.type).toBe("navigate"); + expect(refreshCommit.routeId).toBe("route:/dashboard"); + expect(refreshCommit.rootLayoutTreePath).toBe("/"); + expect(refreshCommit.previousNextUrl).toBe("/feed"); + }); + + it("merges layoutFlags on navigate", () => { + const nextState = routerReducer( + createState({ layoutFlags: { "layout:/": "s", "layout:/old": "d" } }), + { + elements: createResolvedElements("route:/next", "/"), + interceptionContext: null, + layoutFlags: { "layout:/": "s", "layout:/blog": "d" }, + navigationSnapshot: createState().navigationSnapshot, + previousNextUrl: null, + renderId: 1, + rootLayoutTreePath: "/", + routeId: "route:/next", + type: "navigate", + }, + ); + + // Navigate merges: old flags preserved, new flags override + expect(nextState.layoutFlags).toEqual({ + "layout:/": "s", + "layout:/old": "d", + "layout:/blog": "d", + }); + }); + + it("replaces layoutFlags on replace", () => { + const nextState = routerReducer( + createState({ layoutFlags: { "layout:/": "s", "layout:/old": "d" } }), + { + elements: createResolvedElements("route:/next", "/"), + interceptionContext: null, + layoutFlags: { "layout:/": "d" }, + navigationSnapshot: createState().navigationSnapshot, + previousNextUrl: null, + renderId: 1, + rootLayoutTreePath: "/", + routeId: "route:/next", + type: "replace", + }, + ); + + // Replace: only new flags + expect(nextState.layoutFlags).toEqual({ "layout:/": "d" }); + }); + + it("stores previousNextUrl on navigate actions", () => { + const nextState = routerReducer(createState(), { + elements: createResolvedElements("route:/photos/42\0/feed", "/", "/feed"), + interceptionContext: "/feed", + layoutFlags: {}, + navigationSnapshot: createState().navigationSnapshot, + previousNextUrl: "/feed", + renderId: 1, + rootLayoutTreePath: "/", + routeId: "route:/photos/42\0/feed", + type: "navigate", + }); + + expect(nextState.interceptionContext).toBe("/feed"); + expect(nextState.previousNextUrl).toBe("/feed"); + }); +}); + +describe("app browser entry previousNextUrl helpers", () => { + it("stores previousNextUrl alongside existing history state", () => { + expect( + createHistoryStateWithPreviousNextUrl( + { + __vinext_scrollY: 120, + }, + "/feed?tab=latest", + ), + ).toEqual({ + __vinext_previousNextUrl: "/feed?tab=latest", + __vinext_scrollY: 120, + }); + }); + + it("drops previousNextUrl when cleared", () => { + expect( + createHistoryStateWithPreviousNextUrl( + { + __vinext_previousNextUrl: "/feed", + __vinext_scrollY: 120, + }, + null, + ), + ).toEqual({ + __vinext_scrollY: 120, + }); + }); + + it("reads previousNextUrl from history state", () => { + expect( + readHistoryStatePreviousNextUrl({ + __vinext_previousNextUrl: "/feed?tab=latest", + }), + ).toBe("/feed?tab=latest"); + }); + + it("derives interception context from previousNextUrl pathname", () => { + expect(resolveInterceptionContextFromPreviousNextUrl("/feed?tab=latest")).toBe("/feed"); + }); + + it("returns null when previousNextUrl is missing", () => { + expect(readHistoryStatePreviousNextUrl({})).toBeNull(); + expect(resolveInterceptionContextFromPreviousNextUrl(null)).toBeNull(); + }); +}); diff --git a/tests/app-elements.test.ts b/tests/app-elements.test.ts new file mode 100644 index 000000000..0cf968f1b --- /dev/null +++ b/tests/app-elements.test.ts @@ -0,0 +1,141 @@ +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_LAYOUT_FLAGS_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"); + }); + + it("reads layoutFlags from payload metadata", () => { + // Layout flags are set directly on the elements object (not via + // normalizeAppElements which expects AppWireElementValue types). + const elements = { + ...normalizeAppElements({ + [APP_ROOT_LAYOUT_KEY]: "/", + [APP_ROUTE_KEY]: "route:/blog", + "page:/blog": React.createElement("div", null, "blog"), + }), + [APP_LAYOUT_FLAGS_KEY]: { "layout:/": "s", "layout:/blog": "d" }, + }; + const metadata = readAppElementsMetadata(elements); + + expect(metadata.layoutFlags).toEqual({ "layout:/": "s", "layout:/blog": "d" }); + }); + + it("defaults missing layoutFlags to empty object (backward compat)", () => { + const metadata = readAppElementsMetadata( + normalizeAppElements({ + [APP_ROOT_LAYOUT_KEY]: "/", + [APP_ROUTE_KEY]: "route:/dashboard", + "route:/dashboard": React.createElement("div", null, "route"), + }), + ); + + expect(metadata.layoutFlags).toEqual({}); + }); +}); 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-execution.test.ts b/tests/app-page-execution.test.ts index f1548f057..0305d9b9b 100644 --- a/tests/app-page-execution.test.ts +++ b/tests/app-page-execution.test.ts @@ -103,7 +103,7 @@ describe("app page execution helpers", () => { it("probes layouts from inner to outer and stops on a handled special response", async () => { const probedLayouts: number[] = []; - const response = await probeAppPageLayouts({ + const result = await probeAppPageLayouts({ layoutCount: 3, async onLayoutError(error, layoutIndex) { expect(error).toBeInstanceOf(Error); @@ -122,8 +122,8 @@ describe("app page execution helpers", () => { }); expect(probedLayouts).toEqual([2, 1]); - expect(response?.status).toBe(404); - await expect(response?.text()).resolves.toBe("layout-fallback"); + expect(result.response?.status).toBe(404); + await expect(result.response?.text()).resolves.toBe("layout-fallback"); }); it("does not await async page probes when a loading boundary is present", async () => { @@ -154,6 +154,164 @@ describe("app page execution helpers", () => { ); }); + it("tracks per-layout dynamic usage when classification options are provided", async () => { + const result = await probeAppPageLayouts({ + layoutCount: 3, + onLayoutError() { + return Promise.resolve(null); + }, + probeLayoutAt() { + return null; + }, + runWithSuppressedHookWarning(probe) { + return probe(); + }, + classification: { + buildTimeClassifications: new Map([ + [0, "static"], + [2, "dynamic"], + ]), + getLayoutId(layoutIndex) { + return ["layout:/", "layout:/blog", "layout:/blog/post"][layoutIndex]; + }, + runWithIsolatedDynamicScope(fn) { + return Promise.resolve({ result: fn(), dynamicDetected: false }); + }, + }, + }); + + expect(result.response).toBeNull(); + // Layout 0 is build-time static, layout 2 is build-time dynamic + // Layout 1 has no build-time classification, probed with no dynamic detected + expect(result.layoutFlags).toEqual({ + "layout:/": "s", + "layout:/blog": "s", + "layout:/blog/post": "d", + }); + }); + + it("detects dynamic usage per-layout through isolated scope", async () => { + let probeCallCount = 0; + const result = await probeAppPageLayouts({ + layoutCount: 2, + onLayoutError() { + return Promise.resolve(null); + }, + probeLayoutAt() { + return null; + }, + runWithSuppressedHookWarning(probe) { + return probe(); + }, + classification: { + getLayoutId(layoutIndex) { + return ["layout:/", "layout:/dashboard"][layoutIndex]; + }, + runWithIsolatedDynamicScope(fn) { + probeCallCount++; + const result = fn(); + // Simulate: second probe call (layout 0, since we iterate inner-to-outer) + // detects dynamic usage + return Promise.resolve({ + result, + dynamicDetected: probeCallCount === 2, + }); + }, + }, + }); + + expect(result.response).toBeNull(); + expect(result.layoutFlags).toEqual({ + "layout:/": "d", + "layout:/dashboard": "s", + }); + }); + + it("returns empty layoutFlags when classification options are absent (backward compat)", async () => { + const result = await probeAppPageLayouts({ + layoutCount: 2, + onLayoutError() { + return Promise.resolve(null); + }, + probeLayoutAt() { + return null; + }, + runWithSuppressedHookWarning(probe) { + return probe(); + }, + }); + + expect(result.response).toBeNull(); + expect(result.layoutFlags).toEqual({}); + }); + + it("defaults to dynamic flag when probe throws a non-special error", async () => { + const result = await probeAppPageLayouts({ + layoutCount: 2, + onLayoutError() { + // Non-special error — return null (don't short-circuit) + return Promise.resolve(null); + }, + probeLayoutAt(layoutIndex) { + if (layoutIndex === 1) throw new Error("use() outside render"); + return null; + }, + runWithSuppressedHookWarning(probe) { + return probe(); + }, + classification: { + getLayoutId(layoutIndex) { + return ["layout:/", "layout:/dashboard"][layoutIndex]; + }, + runWithIsolatedDynamicScope(fn) { + // Re-throw so the catch path in probeAppPageLayouts fires + return Promise.resolve(fn()).then((result) => ({ result, dynamicDetected: false })); + }, + }, + }); + + expect(result.response).toBeNull(); + // Layout 1 threw → conservatively flagged as dynamic + expect(result.layoutFlags["layout:/dashboard"]).toBe("d"); + // Layout 0 probed successfully + expect(result.layoutFlags["layout:/"]).toBe("s"); + }); + + it("skips probe for build-time classified layouts", async () => { + let probeCalls = 0; + const result = await probeAppPageLayouts({ + layoutCount: 2, + onLayoutError() { + return Promise.resolve(null); + }, + probeLayoutAt() { + return null; + }, + runWithSuppressedHookWarning(probe) { + return probe(); + }, + classification: { + buildTimeClassifications: new Map([ + [0, "static"], + [1, "dynamic"], + ]), + getLayoutId(layoutIndex) { + return ["layout:/", "layout:/admin"][layoutIndex]; + }, + runWithIsolatedDynamicScope(fn) { + probeCalls++; + return Promise.resolve({ result: fn(), dynamicDetected: false }); + }, + }, + }); + + expect(probeCalls).toBe(0); + expect(result.layoutFlags).toEqual({ + "layout:/": "s", + "layout:/admin": "d", + }); + }); + it("builds Link headers for preloaded app-page fonts", () => { expect( buildAppPageFontLinkHeader([ diff --git a/tests/app-page-probe.test.ts b/tests/app-page-probe.test.ts index 563938ff7..f506e06bb 100644 --- a/tests/app-page-probe.test.ts +++ b/tests/app-page-probe.test.ts @@ -11,7 +11,7 @@ describe("app page probe helpers", () => { const renderPageSpecialError = vi.fn(); const probedLayouts: number[] = []; - const response = await probeAppPageBeforeRender({ + const result = await probeAppPageBeforeRender({ hasLoadingBoundary: false, layoutCount: 3, probeLayoutAt(layoutIndex) { @@ -47,8 +47,8 @@ describe("app page probe helpers", () => { 1, ); expect(renderPageSpecialError).not.toHaveBeenCalled(); - expect(response?.status).toBe(404); - await expect(response?.text()).resolves.toBe("layout-fallback"); + expect(result.response?.status).toBe(404); + await expect(result.response?.text()).resolves.toBe("layout-fallback"); }); it("falls through to the page probe when layout failures are not special", async () => { @@ -56,7 +56,7 @@ describe("app page probe helpers", () => { const pageProbe = vi.fn(() => null); const renderLayoutSpecialError = vi.fn(); - const response = await probeAppPageBeforeRender({ + const result = await probeAppPageBeforeRender({ hasLoadingBoundary: false, layoutCount: 2, probeLayoutAt(layoutIndex) { @@ -78,7 +78,7 @@ describe("app page probe helpers", () => { }, }); - expect(response).toBeNull(); + expect(result.response).toBeNull(); expect(pageProbe).toHaveBeenCalledTimes(1); expect(renderLayoutSpecialError).not.toHaveBeenCalled(); }); @@ -89,7 +89,7 @@ describe("app page probe helpers", () => { async () => new Response("page-fallback", { status: 307 }), ); - const response = await probeAppPageBeforeRender({ + const result = await probeAppPageBeforeRender({ hasLoadingBoundary: false, layoutCount: 0, probeLayoutAt() { @@ -121,14 +121,96 @@ describe("app page probe helpers", () => { location: "/target", statusCode: 307, }); - expect(response?.status).toBe(307); - await expect(response?.text()).resolves.toBe("page-fallback"); + expect(result.response?.status).toBe(307); + await expect(result.response?.text()).resolves.toBe("page-fallback"); + }); + + it("propagates layoutFlags from layout probe result", async () => { + const pageProbe = vi.fn(() => null); + + const result = await probeAppPageBeforeRender({ + hasLoadingBoundary: false, + layoutCount: 2, + probeLayoutAt() { + return null; + }, + probePage: pageProbe, + renderLayoutSpecialError() { + throw new Error("should not render a layout special error"); + }, + renderPageSpecialError() { + throw new Error("should not render a page special error"); + }, + resolveSpecialError() { + return null; + }, + runWithSuppressedHookWarning(probe) { + return probe(); + }, + classification: { + buildTimeClassifications: new Map([ + [0, "static"], + [1, "dynamic"], + ]), + getLayoutId(layoutIndex) { + return ["layout:/", "layout:/admin"][layoutIndex]; + }, + runWithIsolatedDynamicScope(fn) { + return Promise.resolve({ result: fn(), dynamicDetected: false }); + }, + }, + }); + + expect(result.response).toBeNull(); + expect(result.layoutFlags).toEqual({ + "layout:/": "s", + "layout:/admin": "d", + }); + }); + + it("still handles special errors with classification enabled", async () => { + const layoutError = new Error("layout failed"); + + const result = await probeAppPageBeforeRender({ + hasLoadingBoundary: false, + layoutCount: 2, + probeLayoutAt(layoutIndex) { + if (layoutIndex === 1) { + throw layoutError; + } + return null; + }, + probePage() { + throw new Error("should not probe page"); + }, + renderLayoutSpecialError: vi.fn(async () => new Response("layout-fallback", { status: 404 })), + renderPageSpecialError() { + throw new Error("should not render a page special error"); + }, + resolveSpecialError(error) { + return error === layoutError ? { kind: "http-access-fallback", statusCode: 404 } : null; + }, + runWithSuppressedHookWarning(probe) { + return probe(); + }, + classification: { + getLayoutId(layoutIndex) { + return ["layout:/", "layout:/admin"][layoutIndex]; + }, + runWithIsolatedDynamicScope(fn) { + return Promise.resolve({ result: fn(), dynamicDetected: false }); + }, + }, + }); + + // Special error response should still be returned + expect(result.response?.status).toBe(404); }); it("does not await async page probes when a loading boundary is present", async () => { const renderPageSpecialError = vi.fn(); - const response = await probeAppPageBeforeRender({ + const result = await probeAppPageBeforeRender({ hasLoadingBoundary: true, layoutCount: 0, probeLayoutAt() { @@ -152,7 +234,7 @@ describe("app page probe helpers", () => { }, }); - expect(response).toBeNull(); + expect(result.response).toBeNull(); expect(renderPageSpecialError).not.toHaveBeenCalled(); }); }); 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/build-report.test.ts b/tests/build-report.test.ts index d1e63e329..f969b6172 100644 --- a/tests/build-report.test.ts +++ b/tests/build-report.test.ts @@ -16,6 +16,7 @@ import { extractGetStaticPropsRevalidate, classifyPagesRoute, classifyAppRoute, + classifyLayoutSegmentConfig, buildReportRows, formatBuildReport, printBuildReport, @@ -739,3 +740,39 @@ describe("printBuildReport respects pageExtensions", () => { expect(output).toContain("/about"); }); }); + +// ─── classifyLayoutSegmentConfig ───────────────────────────────────────────── + +describe("classifyLayoutSegmentConfig", () => { + it('returns "static" for export const dynamic = "force-static"', () => { + expect(classifyLayoutSegmentConfig('export const dynamic = "force-static";')).toBe("static"); + }); + + it('returns "static" for export const dynamic = "error" (enforces static)', () => { + expect(classifyLayoutSegmentConfig("export const dynamic = 'error';")).toBe("static"); + }); + + it('returns "dynamic" for export const dynamic = "force-dynamic"', () => { + expect(classifyLayoutSegmentConfig('export const dynamic = "force-dynamic";')).toBe("dynamic"); + }); + + it('returns "dynamic" for export const revalidate = 0', () => { + expect(classifyLayoutSegmentConfig("export const revalidate = 0;")).toBe("dynamic"); + }); + + it('returns "static" for export const revalidate = Infinity', () => { + expect(classifyLayoutSegmentConfig("export const revalidate = Infinity;")).toBe("static"); + }); + + it("returns null for no config (defers to module graph)", () => { + expect( + classifyLayoutSegmentConfig( + "export default function Layout({ children }) { return children; }", + ), + ).toBeNull(); + }); + + it("returns null for positive revalidate (ISR is a page concept)", () => { + expect(classifyLayoutSegmentConfig("export const revalidate = 60;")).toBeNull(); + }); +}); diff --git a/tests/e2e/app-router/advanced.spec.ts b/tests/e2e/app-router/advanced.spec.ts index 6b5837d38..b257d772e 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,123 @@ 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("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("hard reload after intercepted navigation renders the full page", 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="feed-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(); + await expect(page.locator('[data-testid="feed-page"]')).not.toBeVisible(); + }); + + test("back then forward restores intercepted modal view", 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 page.goBack(); + await expect(page.locator('[data-testid="feed-page"]')).toBeVisible(); + await expect(page.locator('[data-testid="photo-modal"]')).not.toBeVisible(); + + await page.goForward(); + 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(); + }); + + test("router.refresh preserves intercepted modal view", 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 page.click('[data-testid="photo-modal-refresh"]'); + + 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(); + }); + + 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({
+
{children}
{team && } diff --git a/tests/fixtures/app-basic/app/error-test/error.tsx b/tests/fixtures/app-basic/app/error-test/error.tsx index 3aac6291c..c2d01c210 100644 --- a/tests/fixtures/app-basic/app/error-test/error.tsx +++ b/tests/fixtures/app-basic/app/error-test/error.tsx @@ -1,11 +1,16 @@ "use client"; +import Link from "next/link"; + export default function ErrorPage({ error, reset }: { error: Error; reset: () => void }) { return (

Something went wrong!

{error.message}

+ + Go home +
); } diff --git a/tests/fixtures/app-basic/app/feed/@modal/(...)photos/[id]/page.tsx b/tests/fixtures/app-basic/app/feed/@modal/(...)photos/[id]/page.tsx index 5923f6c28..aee171954 100644 --- a/tests/fixtures/app-basic/app/feed/@modal/(...)photos/[id]/page.tsx +++ b/tests/fixtures/app-basic/app/feed/@modal/(...)photos/[id]/page.tsx @@ -1,3 +1,5 @@ +import { PhotoModalRefreshButton } from "./refresh-button"; + // Intercepting route: renders when navigating from /feed to /photos/[id]. // Shows a modal version of the photo instead of the full page. export default function PhotoModal({ params }: { params: { id: string } }) { @@ -5,6 +7,7 @@ export default function PhotoModal({ params }: { params: { id: string } }) {

Photo Modal

Viewing photo {params.id} in modal

+
); } diff --git a/tests/fixtures/app-basic/app/feed/@modal/(...)photos/[id]/refresh-button.tsx b/tests/fixtures/app-basic/app/feed/@modal/(...)photos/[id]/refresh-button.tsx new file mode 100644 index 000000000..e974df5b4 --- /dev/null +++ b/tests/fixtures/app-basic/app/feed/@modal/(...)photos/[id]/refresh-button.tsx @@ -0,0 +1,13 @@ +"use client"; + +import { useRouter } from "next/navigation"; + +export function PhotoModalRefreshButton() { + const router = useRouter(); + + return ( + + ); +} diff --git a/tests/fixtures/app-basic/app/feed/page.tsx b/tests/fixtures/app-basic/app/feed/page.tsx index bb8bd0f48..d551f1f9c 100644 --- a/tests/fixtures/app-basic/app/feed/page.tsx +++ b/tests/fixtures/app-basic/app/feed/page.tsx @@ -1,16 +1,28 @@ +import Link from "next/link"; + export default function FeedPage() { return (

Photo Feed

diff --git a/tests/fixtures/app-basic/app/gallery/page.tsx b/tests/fixtures/app-basic/app/gallery/page.tsx new file mode 100644 index 000000000..a81bd7838 --- /dev/null +++ b/tests/fixtures/app-basic/app/gallery/page.tsx @@ -0,0 +1,16 @@ +import Link from "next/link"; + +export default function GalleryPage() { + return ( +
+

Photo Gallery

+
    +
  • + + Photo 42 + +
  • +
+
+ ); +} diff --git a/tests/fixtures/app-basic/app/page.tsx b/tests/fixtures/app-basic/app/page.tsx index a67457c09..2881acbb7 100644 --- a/tests/fixtures/app-basic/app/page.tsx +++ b/tests/fixtures/app-basic/app/page.tsx @@ -21,6 +21,9 @@ export default function HomePage() { Nav Flash List + + Error Test + ); diff --git a/tests/fixtures/app-basic/app/template.tsx b/tests/fixtures/app-basic/app/template.tsx index 4139d6756..7c642dc0d 100644 --- a/tests/fixtures/app-basic/app/template.tsx +++ b/tests/fixtures/app-basic/app/template.tsx @@ -1,3 +1,5 @@ +import { TemplateCounter } from "./components/template-counter"; + /** * Root template — wraps all pages but re-mounts on navigation. * Unlike layout.tsx, template.tsx creates a new instance for each route. @@ -6,6 +8,7 @@ export default function RootTemplate({ children }: { children: React.ReactNode } return (
Template Active
+ {children}
); diff --git a/tests/layout-classification.test.ts b/tests/layout-classification.test.ts new file mode 100644 index 000000000..c7a1b2785 --- /dev/null +++ b/tests/layout-classification.test.ts @@ -0,0 +1,232 @@ +/** + * Layout classification tests — module graph traversal and combined + * (segment config + module graph) classification for static/dynamic + * layout detection. + */ +import { describe, expect, it } from "vite-plus/test"; +import { + classifyLayoutByModuleGraph, + classifyAllRouteLayouts, + type ModuleInfoProvider, +} from "../packages/vinext/src/build/layout-classification.js"; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** + * Builds a fake module graph for testing. Each key is a module ID, + * and the value lists its static and dynamic imports. + */ +function createFakeModuleGraph( + graph: Record, +): ModuleInfoProvider { + return { + getModuleInfo(id: string) { + const entry = graph[id]; + if (!entry) return null; + return { + importedIds: entry.importedIds ?? [], + dynamicImportedIds: entry.dynamicImportedIds ?? [], + }; + }, + }; +} + +const DYNAMIC_SHIMS = new Set(["/shims/headers", "/shims/cache", "/shims/server"]); + +// ─── classifyLayoutByModuleGraph ───────────────────────────────────────────── + +describe("classifyLayoutByModuleGraph", () => { + it('returns "static" when layout has no transitive dynamic shim imports', () => { + const graph = createFakeModuleGraph({ + "/app/layout.tsx": { importedIds: ["/components/nav.tsx"] }, + "/components/nav.tsx": { importedIds: [] }, + }); + + expect(classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph)).toBe("static"); + }); + + it('returns "needs-probe" when headers shim is transitively imported', () => { + const graph = createFakeModuleGraph({ + "/app/layout.tsx": { importedIds: ["/components/auth.tsx"] }, + "/components/auth.tsx": { importedIds: ["/shims/headers"] }, + "/shims/headers": { importedIds: [] }, + }); + + expect(classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph)).toBe( + "needs-probe", + ); + }); + + it('returns "needs-probe" when cache shim (noStore) is transitively imported', () => { + const graph = createFakeModuleGraph({ + "/app/layout.tsx": { importedIds: ["/shims/cache"] }, + "/shims/cache": { importedIds: [] }, + }); + + expect(classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph)).toBe( + "needs-probe", + ); + }); + + it('returns "needs-probe" when server shim (connection) is transitively imported', () => { + const graph = createFakeModuleGraph({ + "/app/layout.tsx": { importedIds: ["/lib/data.ts"] }, + "/lib/data.ts": { importedIds: ["/shims/server"] }, + "/shims/server": { importedIds: [] }, + }); + + expect(classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph)).toBe( + "needs-probe", + ); + }); + + it("handles circular imports without infinite loop", () => { + const graph = createFakeModuleGraph({ + "/app/layout.tsx": { importedIds: ["/a.ts"] }, + "/a.ts": { importedIds: ["/b.ts"] }, + "/b.ts": { importedIds: ["/a.ts"] }, + }); + + expect(classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph)).toBe("static"); + }); + + it("detects dynamic shim through deep transitive chains", () => { + const graph = createFakeModuleGraph({ + "/app/layout.tsx": { importedIds: ["/a.ts"] }, + "/a.ts": { importedIds: ["/b.ts"] }, + "/b.ts": { importedIds: ["/c.ts"] }, + "/c.ts": { importedIds: ["/shims/headers"] }, + "/shims/headers": { importedIds: [] }, + }); + + expect(classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph)).toBe( + "needs-probe", + ); + }); + + it("follows dynamicImportedIds (dynamic import())", () => { + const graph = createFakeModuleGraph({ + "/app/layout.tsx": { + importedIds: [], + dynamicImportedIds: ["/lazy.ts"], + }, + "/lazy.ts": { importedIds: ["/shims/headers"] }, + "/shims/headers": { importedIds: [] }, + }); + + expect(classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph)).toBe( + "needs-probe", + ); + }); + + it('returns "static" when module info is null (unknown module)', () => { + const graph = createFakeModuleGraph({}); + + expect(classifyLayoutByModuleGraph("/unknown/layout.tsx", DYNAMIC_SHIMS, graph)).toBe("static"); + }); +}); + +// ─── classifyAllRouteLayouts ───────────────────────────────────────────────── + +describe("classifyAllRouteLayouts", () => { + it("segment config takes priority over module graph", () => { + // Layout imports headers shim, but segment config says force-static + const graph = createFakeModuleGraph({ + "/app/layout.tsx": { importedIds: ["/shims/headers"] }, + "/shims/headers": { importedIds: [] }, + }); + + const routes = [ + { + layouts: ["/app/layout.tsx"], + layoutTreePositions: [0], + routeSegments: ["blog"], + layoutSegmentConfigs: [{ code: 'export const dynamic = "force-static";' }], + }, + ]; + + const result = classifyAllRouteLayouts(routes, DYNAMIC_SHIMS, graph); + expect(result.get("layout:/")).toBe("static"); + }); + + it("deduplicates shared layout files across routes", () => { + const graph = createFakeModuleGraph({ + "/app/layout.tsx": { importedIds: [] }, + "/app/blog/layout.tsx": { importedIds: ["/shims/headers"] }, + "/shims/headers": { importedIds: [] }, + }); + + const routes = [ + { + layouts: ["/app/layout.tsx", "/app/blog/layout.tsx"], + layoutTreePositions: [0, 1], + routeSegments: ["blog"], + }, + { + layouts: ["/app/layout.tsx"], + layoutTreePositions: [0], + routeSegments: ["about"], + }, + ]; + + const result = classifyAllRouteLayouts(routes, DYNAMIC_SHIMS, graph); + // Root layout appears in both routes but should only be classified once + expect(result.get("layout:/")).toBe("static"); + expect(result.get("layout:/blog")).toBe("needs-probe"); + expect(result.size).toBe(2); + }); + + it("returns dynamic for force-dynamic segment config", () => { + const graph = createFakeModuleGraph({ + "/app/layout.tsx": { importedIds: [] }, + }); + + const routes = [ + { + layouts: ["/app/layout.tsx"], + layoutTreePositions: [0], + routeSegments: [], + layoutSegmentConfigs: [{ code: 'export const dynamic = "force-dynamic";' }], + }, + ]; + + const result = classifyAllRouteLayouts(routes, DYNAMIC_SHIMS, graph); + expect(result.get("layout:/")).toBe("dynamic"); + }); + + it("falls through to module graph when segment config returns null", () => { + const graph = createFakeModuleGraph({ + "/app/layout.tsx": { importedIds: [] }, + }); + + const routes = [ + { + layouts: ["/app/layout.tsx"], + layoutTreePositions: [0], + routeSegments: [], + layoutSegmentConfigs: [{ code: "export default function Layout() {}" }], + }, + ]; + + const result = classifyAllRouteLayouts(routes, DYNAMIC_SHIMS, graph); + expect(result.get("layout:/")).toBe("static"); + }); + + it("classifies layouts without segment configs using module graph only", () => { + const graph = createFakeModuleGraph({ + "/app/layout.tsx": { importedIds: ["/shims/cache"] }, + "/shims/cache": { importedIds: [] }, + }); + + const routes = [ + { + layouts: ["/app/layout.tsx"], + layoutTreePositions: [0], + routeSegments: [], + }, + ]; + + const result = classifyAllRouteLayouts(routes, DYNAMIC_SHIMS, graph); + expect(result.get("layout:/")).toBe("needs-probe"); + }); +}); diff --git a/tests/prefetch-cache.test.ts b/tests/prefetch-cache.test.ts index b7fd73b1e..319fe10f0 100644 --- a/tests/prefetch-cache.test.ts +++ b/tests/prefetch-cache.test.ts @@ -10,6 +10,7 @@ * vi.resetModules() + dynamic import(). */ import { describe, it, expect, beforeEach, afterEach, vi } from "vite-plus/test"; +import { createAppPayloadCacheKey } from "../packages/vinext/src/server/app-elements.js"; type Navigation = typeof import("../packages/vinext/src/shims/navigation.js"); let storePrefetchResponse: Navigation["storePrefetchResponse"]; @@ -68,6 +69,18 @@ function fillCache(count: number, timestamp: number, keyPrefix = "/page-"): void } describe("prefetch cache eviction", () => { + it("allows separate interception-context entries for the same RSC URL", () => { + const feedKey = createAppPayloadCacheKey("/photos/42.rsc", "/feed"); + const galleryKey = createAppPayloadCacheKey("/photos/42.rsc", "/gallery"); + + storePrefetchResponse(feedKey, new Response("feed")); + storePrefetchResponse(galleryKey, new Response("gallery")); + + expect(feedKey).not.toBe(galleryKey); + expect(getPrefetchCache().has(feedKey)).toBe(true); + expect(getPrefetchCache().has(galleryKey)).toBe(true); + }); + it("preserves X-Vinext-Params when replaying cached RSC responses", async () => { const response = new Response("flight", { headers: { diff --git a/tests/slot.test.ts b/tests/slot.test.ts new file mode 100644 index 000000000..e3ced8088 --- /dev/null +++ b/tests/slot.test.ts @@ -0,0 +1,261 @@ +import React from "react"; +import { renderToReadableStream } from "react-dom/server.edge"; +import { describe, expect, it, vi } from "vite-plus/test"; +import { UNMATCHED_SLOT } from "../packages/vinext/src/server/app-elements.js"; + +vi.mock("next/navigation", () => ({ + usePathname: () => "/", +})); + +function createContextProvider( + context: React.Context, + value: TValue, + child: React.ReactNode, +): React.ReactElement { + return React.createElement(context.Provider, { value }, child); +} + +async function readStream(stream: ReadableStream): Promise { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let text = ""; + + for (;;) { + const { done, value } = await reader.read(); + if (done) { + break; + } + text += decoder.decode(value, { stream: true }); + } + + return text + decoder.decode(); +} + +async function renderHtml(element: React.ReactElement): Promise { + const stream = await renderToReadableStream(element); + await stream.allReady; + return readStream(stream); +} + +describe("slot primitives", () => { + it("exports the client primitives", async () => { + const mod = await import("../packages/vinext/src/shims/slot.js"); + + expect(typeof mod.Slot).toBe("function"); + expect(typeof mod.Children).toBe("function"); + expect(typeof mod.ParallelSlot).toBe("function"); + expect(typeof mod.mergeElements).toBe("function"); + expect(mod.ElementsContext).toBeDefined(); + expect(mod.ChildrenContext).toBeDefined(); + expect(mod.ParallelSlotsContext).toBeDefined(); + expect(mod.UNMATCHED_SLOT).toBe(Symbol.for("vinext.unmatchedSlot")); + }); + + it("Children renders null outside a Slot provider", async () => { + const { Children } = await import("../packages/vinext/src/shims/slot.js"); + + const html = await renderHtml(React.createElement(Children)); + expect(html).toBe(""); + }); + + it("ParallelSlot renders null outside a Slot provider", async () => { + const { ParallelSlot } = await import("../packages/vinext/src/shims/slot.js"); + + const html = await renderHtml(React.createElement(ParallelSlot, { name: "modal" })); + expect(html).toBe(""); + }); + + it("Slot renders the matched element and provides children and parallel slots", async () => { + const mod = await import("../packages/vinext/src/shims/slot.js"); + + function LayoutShell(): React.ReactElement { + return React.createElement( + "div", + null, + React.createElement("main", null, React.createElement(mod.Children)), + React.createElement( + "aside", + null, + React.createElement(mod.ParallelSlot, { name: "modal" }), + ), + ); + } + + const slotElement = createContextProvider( + mod.ElementsContext, + { "layout:/": React.createElement(LayoutShell) }, + React.createElement( + mod.Slot, + { + id: "layout:/", + parallelSlots: { + modal: React.createElement("em", null, "modal content"), + }, + }, + React.createElement("span", null, "child content"), + ), + ); + + const html = await renderHtml(slotElement); + expect(html).toContain("child content"); + expect(html).toContain("modal content"); + }); + + it("Slot returns null when the entry is absent", async () => { + const mod = await import("../packages/vinext/src/shims/slot.js"); + + const html = await renderHtml( + createContextProvider( + mod.ElementsContext, + {}, + React.createElement(mod.Slot, { id: "slot:modal:/" }), + ), + ); + + expect(html).toBe(""); + }); + + it("Slot throws the notFound signal for an unmatched slot sentinel", async () => { + const mod = await import("../packages/vinext/src/shims/slot.js"); + const renderPromise = renderHtml( + createContextProvider( + mod.ElementsContext, + { "slot:modal:/": mod.UNMATCHED_SLOT }, + React.createElement(mod.Slot, { id: "slot:modal:/" }), + ), + ); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + + try { + await expect(renderPromise).rejects.toMatchObject({ digest: "NEXT_HTTP_ERROR_FALLBACK;404" }); + } finally { + consoleError.mockRestore(); + } + }); + + it("Slot renders a present null entry without triggering notFound", async () => { + const mod = await import("../packages/vinext/src/shims/slot.js"); + const errors: Error[] = []; + + const stream = await renderToReadableStream( + createContextProvider( + mod.ElementsContext, + { "slot:modal:/": null }, + React.createElement(mod.Slot, { id: "slot:modal:/" }), + ), + { + onError(error: unknown) { + if (error instanceof Error) { + errors.push(error); + } + }, + }, + ); + + await stream.allReady; + const html = await readStream(stream); + + expect(html).toBe(""); + expect(errors).toEqual([]); + }); + + it("normalizes the server unmatched-slot marker to the client sentinel", async () => { + const { normalizeAppElements, APP_UNMATCHED_SLOT_WIRE_VALUE } = + await import("../packages/vinext/src/server/app-elements.js"); + const mod = await import("../packages/vinext/src/shims/slot.js"); + + const normalized = normalizeAppElements({ + __rootLayout: "/", + __route: "route:/dashboard", + "slot:modal:/": APP_UNMATCHED_SLOT_WIRE_VALUE, + }); + + expect(normalized["slot:modal:/"]).toBe(mod.UNMATCHED_SLOT); + }); + + it("mergeElements shallow-merges previous and next elements", async () => { + const { mergeElements } = await import("../packages/vinext/src/shims/slot.js"); + + const merged = mergeElements( + { + "layout:/": React.createElement("div", null, "layout"), + "slot:modal:/": React.createElement("div", null, "previous slot"), + }, + { + "page:/blog/hello": React.createElement("div", null, "page"), + "slot:modal:/": React.createElement("div", null, "next slot"), + }, + ); + + expect(Object.keys(merged)).toEqual(["layout:/", "slot:modal:/", "page:/blog/hello"]); + expect(merged["layout:/"]).toBeDefined(); + expect(merged["page:/blog/hello"]).toBeDefined(); + expect(merged["slot:modal:/"]).not.toBeNull(); + }); + + it("mergeElements preserves previous slot content when next marks it unmatched", async () => { + const { mergeElements } = await import("../packages/vinext/src/shims/slot.js"); + + const previousSlotContent = React.createElement("div", null, "previous modal"); + const merged = mergeElements( + { + "layout:/": React.createElement("div", null, "layout"), + "slot:modal:/": previousSlotContent, + "page:/dashboard": React.createElement("div", null, "dashboard"), + }, + { + "page:/blog": React.createElement("div", null, "blog page"), + "slot:modal:/": UNMATCHED_SLOT, + }, + ); + + // The slot should keep its previous content, not become UNMATCHED_SLOT. + // This matches Next.js soft navigation behavior: unmatched parallel slots + // preserve their previous subtree instead of showing 404. + expect(merged["slot:modal:/"]).toBe(previousSlotContent); + expect(merged["page:/blog"]).toBeDefined(); + expect(merged["layout:/"]).toBeDefined(); + }); + + it("mergeElements allows UNMATCHED_SLOT for slots absent from previous state", async () => { + const { mergeElements } = await import("../packages/vinext/src/shims/slot.js"); + + const merged = mergeElements( + { + "layout:/": React.createElement("div", null, "layout"), + "page:/": React.createElement("div", null, "home"), + }, + { + "page:/blog": React.createElement("div", null, "blog"), + "slot:modal:/": UNMATCHED_SLOT, + }, + ); + + // No previous value to preserve — the sentinel passes through. + expect(merged["slot:modal:/"]).toBe(UNMATCHED_SLOT); + }); + + it("Slot renders element from resolved context", async () => { + const mod = await import("../packages/vinext/src/shims/slot.js"); + + const stream = await renderToReadableStream( + createContextProvider( + mod.ElementsContext, + { "layout:/": React.createElement("div", null, "resolved slot") }, + React.createElement(mod.Slot, { id: "layout:/" }), + ), + ); + + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let html = ""; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + html += decoder.decode(value, { stream: true }); + } + html += decoder.decode(); + + expect(html).toContain("resolved slot"); + }); +});