From 7f4f8ebb019ab08597e0eba0a978bf98f5e7745d Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Thu, 2 Apr 2026 12:33:43 +1100 Subject: [PATCH 01/32] Extract app page route wiring helpers --- packages/vinext/src/entries/app-rsc-entry.ts | 267 +-- .../src/server/app-page-route-wiring.tsx | 317 ++++ packages/vinext/src/shims/error-boundary.tsx | 35 +- .../entry-templates.test.ts.snap | 1514 ++--------------- tests/app-page-route-wiring.test.ts | 152 ++ tests/error-boundary.test.ts | 136 +- 6 files changed, 815 insertions(+), 1606 deletions(-) create mode 100644 packages/vinext/src/server/app-page-route-wiring.tsx create mode 100644 tests/app-page-route-wiring.test.ts diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index 23e1c3ecc..52b21e0ba 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -55,6 +55,10 @@ const appPageBoundaryRenderPath = resolveEntryPath( "../server/app-page-boundary-render.js", import.meta.url, ); +const appPageRouteWiringPath = resolveEntryPath( + "../server/app-page-route-wiring.js", + import.meta.url, +); const appPageRenderPath = resolveEntryPath("../server/app-page-render.js", import.meta.url); const appPageRequestPath = resolveEntryPath("../server/app-page-request.js", import.meta.url); const appRouteHandlerResponsePath = resolveEntryPath( @@ -337,13 +341,11 @@ function renderToReadableStream(model, options) { } })); } -import { createElement, Suspense, Fragment } from "react"; +import { createElement } from "react"; import { setNavigationContext as _setNavigationContextOrig, getNavigationContext as _getNavigationContext } from "next/navigation"; import { setHeadersContext, headersContextFromRequest, getDraftModeCookieHeader, getAndClearPendingCookies, consumeDynamicUsage, markDynamicUsage, applyMiddlewareRequestHeaders, getHeadersContext, setHeadersAccessPhase } from "next/headers"; import { NextRequest, NextFetchEvent } from "next/server"; -import { ErrorBoundary, NotFoundBoundary } from "vinext/error-boundary"; -import { LayoutSegmentProvider } from "vinext/layout-segment-context"; -import { MetadataHead, mergeMetadata, resolveModuleMetadata, ViewportHead, mergeViewport, resolveModuleViewport } from "vinext/metadata"; +import { mergeMetadata, resolveModuleMetadata, mergeViewport, resolveModuleViewport } from "vinext/metadata"; ${middlewarePath ? `import * as middlewareModule from ${JSON.stringify(middlewarePath.replace(/\\/g, "/"))};` : ""} ${instrumentationPath ? `import * as _instrumentation from ${JSON.stringify(instrumentationPath.replace(/\\/g, "/"))};` : ""} ${effectiveMetaRoutes.length > 0 ? `import { sitemapToXml, robotsToText, manifestToJson } from ${JSON.stringify(metadataRoutesPath)};` : ""} @@ -375,6 +377,10 @@ import { renderAppPageErrorBoundary as __renderAppPageErrorBoundary, renderAppPageHttpAccessFallback as __renderAppPageHttpAccessFallback, } from ${JSON.stringify(appPageBoundaryRenderPath)}; +import { + buildAppPageRouteElement as __buildAppPageRouteElement, + resolveAppPageChildSegments as __resolveAppPageChildSegments, +} from ${JSON.stringify(appPageRouteWiringPath)}; import { renderAppPageLifecycle as __renderAppPageLifecycle, } from ${JSON.stringify(appPageRenderPath)}; @@ -542,38 +548,6 @@ function makeThenableParams(obj) { return Object.assign(Promise.resolve(plain), plain); } -// Resolve route tree segments to actual values using matched params. -// Dynamic segments like [id] are replaced with param values, catch-all -// segments like [...slug] are joined with "/", and route groups are kept as-is. -function __resolveChildSegments(routeSegments, treePosition, params) { - var raw = routeSegments.slice(treePosition); - var result = []; - for (var j = 0; j < raw.length; j++) { - var seg = raw[j]; - // Optional catch-all: [[...param]] - if (seg.indexOf("[[...") === 0 && seg.charAt(seg.length - 1) === "]" && seg.charAt(seg.length - 2) === "]") { - var pn = seg.slice(5, -2); - var v = params[pn]; - // Skip empty optional catch-all (e.g., visiting /blog on [[...slug]] route) - if (Array.isArray(v) && v.length === 0) continue; - if (v == null) continue; - result.push(Array.isArray(v) ? v.join("/") : v); - // Catch-all: [...param] - } else if (seg.indexOf("[...") === 0 && seg.charAt(seg.length - 1) === "]") { - var pn2 = seg.slice(4, -1); - var v2 = params[pn2]; - result.push(Array.isArray(v2) ? v2.join("/") : (v2 || seg)); - // Dynamic: [param] - } else if (seg.charAt(0) === "[" && seg.charAt(seg.length - 1) === "]" && seg.indexOf(".") === -1) { - var pn3 = seg.slice(1, -1); - result.push(params[pn3] || seg); - } else { - result.push(seg); - } - } - return result; -} - // djb2 hash — matches Next.js's stringHash for digest generation. // Produces a stable numeric string from error message + stack. function __errorDigest(str) { @@ -777,7 +751,7 @@ async function renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, req makeThenableParams, matchedParams: opts?.matchedParams ?? route?.params ?? {}, requestUrl: request.url, - resolveChildSegments: __resolveChildSegments, + resolveChildSegments: __resolveAppPageChildSegments, rootForbiddenModule: rootForbiddenModule, rootLayouts: rootLayouts, rootNotFoundModule: rootNotFoundModule, @@ -823,7 +797,7 @@ async function renderErrorBoundaryPage(route, error, isRscRequest, request, matc makeThenableParams, matchedParams: matchedParams ?? route?.params ?? {}, requestUrl: request.url, - resolveChildSegments: __resolveChildSegments, + resolveChildSegments: __resolveAppPageChildSegments, rootLayouts: rootLayouts, route, renderToReadableStream, @@ -989,12 +963,10 @@ async function buildPageElement(route, params, opts, searchParams) { const resolvedMetadata = metadataList.length > 0 ? mergeMetadata(metadataList) : null; const resolvedViewport = mergeViewport(viewportList); - // Build nested layout tree from outermost to innermost. - // Next.js 16 passes params/searchParams as Promises (async pattern) - // but pre-16 code accesses them as plain objects (params.id). - // makeThenableParams() normalises null-prototype + preserves both patterns. - const asyncParams = makeThenableParams(params); - const pageProps = { params: asyncParams }; + // Build the route tree from the leaf page, then delegate the boundary/layout/ + // template/segment wiring to a typed runtime helper so the generated entry + // stays thin and the wiring logic can be unit tested directly. + const pageProps = { params: makeThenableParams(params) }; if (searchParams) { // Always provide searchParams prop when the URL object is available, even // when the query string is empty -- pages that do "await searchParams" need @@ -1010,196 +982,25 @@ async function buildPageElement(route, params, opts, searchParams) { // dynamic, and this avoids false positives from React internals. if (hasSearchParams) markDynamicUsage(); } - let element = createElement(PageComponent, pageProps); - - // Wrap page with empty segment provider so useSelectedLayoutSegments() - // returns [] when called from inside a page component (leaf node). - element = createElement(LayoutSegmentProvider, { segmentMap: { children: [] } }, element); - - // Add metadata + viewport head tags (React 19 hoists title/meta/link to ) - // Next.js always injects charset and default viewport even when no metadata/viewport - // is exported. We replicate that by always emitting these essential head elements. - { - const headElements = []; - // Always emit — Next.js includes this on every page - headElements.push(createElement("meta", { charSet: "utf-8" })); - if (resolvedMetadata) headElements.push(createElement(MetadataHead, { metadata: resolvedMetadata })); - headElements.push(createElement(ViewportHead, { viewport: resolvedViewport })); - element = createElement(Fragment, null, ...headElements, element); - } - - // Wrap with loading.tsx Suspense if present - if (route.loading?.default) { - element = createElement( - Suspense, - { fallback: createElement(route.loading.default) }, - element, - ); - } - - // Wrap with the leaf's error.tsx ErrorBoundary if it's not already covered - // by a per-layout error boundary (i.e., the leaf has error.tsx but no layout). - // Per-layout error boundaries are interleaved with layouts below. - { - const lastLayoutError = route.errors ? route.errors[route.errors.length - 1] : null; - if (route.error?.default && route.error !== lastLayoutError) { - element = createElement(ErrorBoundary, { - fallback: route.error.default, - children: element, - }); - } - } - - // Wrap with NotFoundBoundary so client-side notFound() renders not-found.tsx - // instead of crashing the React tree. Must be above ErrorBoundary since - // ErrorBoundary re-throws notFound errors. - // Pre-render the not-found component as a React element since it may be a - // server component (not a client reference) and can't be passed as a function prop. - { - const NotFoundComponent = route.notFound?.default ?? ${rootNotFoundVar ? `${rootNotFoundVar}?.default` : "null"}; - if (NotFoundComponent) { - element = createElement(NotFoundBoundary, { - fallback: createElement(NotFoundComponent), - children: element, - }); - } - } - - // Wrap with templates (innermost first, then outer) - // Templates are like layouts but re-mount on navigation (client-side concern). - // On the server, they just wrap the content like layouts do. - if (route.templates) { - for (let i = route.templates.length - 1; i >= 0; i--) { - const TemplateComponent = route.templates[i]?.default; - if (TemplateComponent) { - element = createElement(TemplateComponent, { children: element, params }); - } - } - } - - // Wrap with layouts (innermost first, then outer). - // At each layout level, first wrap with that level's error boundary (if any) - // so the boundary is inside the layout and catches errors from children. - // This matches Next.js behavior: Layout > ErrorBoundary > children. - // Parallel slots are passed as named props to the innermost layout - // (the layout at the same directory level as the page/slots) - for (let i = route.layouts.length - 1; i >= 0; i--) { - // Wrap with per-layout error boundary before wrapping with layout. - // This places the ErrorBoundary inside the layout, catching errors - // from child segments (matching Next.js per-segment error handling). - if (route.errors && route.errors[i]?.default) { - element = createElement(ErrorBoundary, { - fallback: route.errors[i].default, - children: element, - }); - } - - const LayoutComponent = route.layouts[i]?.default; - if (LayoutComponent) { - // Per-layout NotFoundBoundary: wraps this layout's children so that - // notFound() thrown from a child layout is caught here. - // Matches Next.js behavior where each segment has its own boundary. - // The boundary at level N catches errors from Layout[N+1] and below, - // but NOT from Layout[N] itself (which propagates to level N-1). - { - const LayoutNotFound = route.notFounds?.[i]?.default; - if (LayoutNotFound) { - element = createElement(NotFoundBoundary, { - fallback: createElement(LayoutNotFound), - children: element, - }); - } - } - - const layoutProps = { children: element, params: makeThenableParams(params) }; - - // Add parallel slot elements to the layout that defines them. - // Each slot has a layoutIndex indicating which layout it belongs to. - if (route.slots) { - for (const [slotName, slotMod] of Object.entries(route.slots)) { - // Attach slot to the layout at its layoutIndex, or to the innermost layout if -1 - const targetIdx = slotMod.layoutIndex >= 0 ? slotMod.layoutIndex : route.layouts.length - 1; - if (i !== targetIdx) continue; - // Check if this slot has an intercepting route that should activate - let SlotPage = null; - let slotParams = params; - - if (opts && opts.interceptSlot === slotName && opts.interceptPage) { - // Use the intercepting route's page component - SlotPage = opts.interceptPage.default; - slotParams = opts.interceptParams || params; - } else { - SlotPage = slotMod.page?.default || slotMod.default?.default; - } - - if (SlotPage) { - let slotElement = createElement(SlotPage, { params: makeThenableParams(slotParams) }); - // Wrap with slot-specific layout if present. - // In Next.js, @slot/layout.tsx wraps the slot's page content - // before it is passed as a prop to the parent layout. - const SlotLayout = slotMod.layout?.default; - if (SlotLayout) { - slotElement = createElement(SlotLayout, { - children: slotElement, - params: makeThenableParams(slotParams), - }); - } - // Wrap with slot-specific loading if present - if (slotMod.loading?.default) { - slotElement = createElement(Suspense, - { fallback: createElement(slotMod.loading.default) }, - slotElement, - ); - } - // Wrap with slot-specific error boundary if present - if (slotMod.error?.default) { - slotElement = createElement(ErrorBoundary, { - fallback: slotMod.error.default, - children: slotElement, - }); - } - layoutProps[slotName] = slotElement; + return __buildAppPageRouteElement({ + element: createElement(PageComponent, pageProps), + globalErrorModule: ${globalErrorVar ? globalErrorVar : "null"}, + makeThenableParams, + matchedParams: params, + resolvedMetadata, + resolvedViewport, + rootNotFoundModule: ${rootNotFoundVar ? rootNotFoundVar : "null"}, + route, + slotOverrides: + opts && opts.interceptSlot && opts.interceptPage + ? { + [opts.interceptSlot]: { + pageModule: opts.interceptPage, + params: opts.interceptParams || params, + }, } - } - } - - element = createElement(LayoutComponent, layoutProps); - - // Wrap the layout with LayoutSegmentProvider so useSelectedLayoutSegments() - // called INSIDE this layout gets the correct child segments. We resolve the - // route tree segments using actual param values and pass them through context. - // We wrap the layout (not just children) because hooks are called from - // components rendered inside the layout's own JSX. - const treePos = route.layoutTreePositions ? route.layoutTreePositions[i] : 0; - const childSegs = __resolveChildSegments(route.routeSegments || [], treePos, params); - element = createElement(LayoutSegmentProvider, { segmentMap: { children: childSegs } }, element); - } - } - - // Wrap with global error boundary if app/global-error.tsx exists. - // This must be present in both HTML and RSC paths so the component tree - // structure matches — otherwise React reconciliation on client-side navigation - // would see a mismatched tree and destroy/recreate the DOM. - // - // For RSC requests (client-side nav), this provides error recovery on the client. - // For HTML requests (initial page load), the ErrorBoundary catches during SSR - // but produces double / (root layout + global-error). The request - // handler detects this via the rscOnError flag and re-renders without layouts. - ${ - globalErrorVar - ? ` - const GlobalErrorComponent = ${globalErrorVar}.default; - if (GlobalErrorComponent) { - element = createElement(ErrorBoundary, { - fallback: GlobalErrorComponent, - children: element, - }); - } - ` - : "" - } - - return element; + : null, + }); } ${middlewarePath ? generateMiddlewareMatcherCode("modern") : ""} diff --git a/packages/vinext/src/server/app-page-route-wiring.tsx b/packages/vinext/src/server/app-page-route-wiring.tsx new file mode 100644 index 000000000..936826612 --- /dev/null +++ b/packages/vinext/src/server/app-page-route-wiring.tsx @@ -0,0 +1,317 @@ +import { Suspense, type ComponentType, type ReactNode } from "react"; +import { ErrorBoundary, NotFoundBoundary } from "../shims/error-boundary.js"; +import { LayoutSegmentProvider } from "../shims/layout-segment-context.js"; +import { MetadataHead, ViewportHead, type Metadata, type Viewport } from "../shims/metadata.js"; +import type { AppPageParams } from "./app-page-boundary.js"; + +type AppPageComponentProps = { + children?: ReactNode; + error?: Error; + params?: unknown; + reset?: () => void; +} & Record; + +type AppPageComponent = ComponentType; +type ErrorBoundaryFallbackComponent = ComponentType<{ error: Error; reset: () => void }>; + +export type AppPageModule = Record & { + default?: AppPageComponent | null | undefined; +}; + +export type AppPageRouteWiringSlot = { + default?: TModule | null; + error?: TModule | null; + layout?: TModule | null; + layoutIndex: number; + loading?: TModule | null; + page?: TModule | null; +}; + +export type AppPageRouteWiringRoute = { + error?: TModule | null; + errors?: readonly (TModule | null | undefined)[] | null; + layoutTreePositions?: readonly number[] | null; + layouts: readonly (TModule | null | undefined)[]; + loading?: TModule | null; + notFound?: TModule | null; + notFounds?: readonly (TModule | null | undefined)[] | null; + routeSegments?: readonly string[]; + slots?: Readonly>> | null; + templates?: readonly (TModule | null | undefined)[] | null; +}; + +export type AppPageSlotOverride = { + pageModule: TModule; + params?: AppPageParams; + props?: Readonly>; +}; + +export type AppPageLayoutEntry = { + errorModule?: TModule | null | undefined; + id: string; + layoutModule?: TModule | null | undefined; + notFoundModule?: TModule | null | undefined; + treePath: string; + treePosition: number; +}; + +export type BuildAppPageRouteElementOptions = { + element: ReactNode; + globalErrorModule?: TModule | null; + makeThenableParams: (params: AppPageParams) => unknown; + matchedParams: AppPageParams; + resolvedMetadata: Metadata | null; + resolvedViewport: Viewport; + rootNotFoundModule?: TModule | null; + route: AppPageRouteWiringRoute; + slotOverrides?: Readonly>> | null; +}; + +function getDefaultExport( + module: TModule | null | undefined, +): AppPageComponent | null { + return module?.default ?? null; +} + +function wrapWithErrorBoundary(fallback: AppPageComponent, children: ReactNode): ReactNode { + const FallbackBoundary: ErrorBoundaryFallbackComponent = ({ error, reset }) => { + const FallbackComponent = fallback; + return ; + }; + + return {children}; +} + +export function createAppPageTreePath( + routeSegments: readonly string[] | null | undefined, + treePosition: number, +): string { + const treePathSegments = routeSegments?.slice(0, treePosition) ?? []; + if (treePathSegments.length === 0) { + return "/"; + } + return `/${treePathSegments.join("/")}`; +} + +export function createAppPageLayoutEntries( + route: Pick< + AppPageRouteWiringRoute, + "errors" | "layoutTreePositions" | "layouts" | "notFounds" | "routeSegments" + >, +): AppPageLayoutEntry[] { + return route.layouts.map((layoutModule, index) => { + const treePosition = route.layoutTreePositions?.[index] ?? 0; + const treePath = createAppPageTreePath(route.routeSegments, treePosition); + return { + errorModule: route.errors?.[index] ?? null, + id: `layout:${treePath}`, + layoutModule, + notFoundModule: route.notFounds?.[index] ?? null, + treePath, + treePosition, + }; + }); +} + +export function resolveAppPageChildSegments( + routeSegments: readonly string[], + treePosition: number, + params: AppPageParams, +): string[] { + const rawSegments = routeSegments.slice(treePosition); + const resolvedSegments: string[] = []; + + for (const segment of rawSegments) { + if ( + segment.startsWith("[[...") && + segment.endsWith("]]") && + segment.length > "[[...x]]".length - 1 + ) { + const paramName = segment.slice(5, -2); + const paramValue = params[paramName]; + if (Array.isArray(paramValue) && paramValue.length === 0) { + continue; + } + if (paramValue === undefined) { + continue; + } + resolvedSegments.push(Array.isArray(paramValue) ? paramValue.join("/") : paramValue); + continue; + } + + if (segment.startsWith("[...") && segment.endsWith("]")) { + const paramName = segment.slice(4, -1); + const paramValue = params[paramName]; + if (Array.isArray(paramValue)) { + resolvedSegments.push(paramValue.join("/")); + continue; + } + resolvedSegments.push(paramValue ?? segment); + continue; + } + + if (segment.startsWith("[") && segment.endsWith("]") && !segment.includes(".")) { + const paramName = segment.slice(1, -1); + const paramValue = params[paramName]; + resolvedSegments.push( + Array.isArray(paramValue) ? paramValue.join("/") : (paramValue ?? segment), + ); + continue; + } + + resolvedSegments.push(segment); + } + + return resolvedSegments; +} + +export function buildAppPageRouteElement( + options: BuildAppPageRouteElementOptions, +): ReactNode { + let element: ReactNode = ( + {options.element} + ); + + element = ( + <> + + {options.resolvedMetadata ? : null} + + {element} + + ); + + const loadingComponent = getDefaultExport(options.route.loading); + if (loadingComponent) { + const LoadingComponent = loadingComponent; + element = }>{element}; + } + + const lastLayoutErrorModule = + options.route.errors && options.route.errors.length > 0 + ? options.route.errors[options.route.errors.length - 1] + : null; + const pageErrorComponent = getDefaultExport(options.route.error); + if (pageErrorComponent && options.route.error !== lastLayoutErrorModule) { + element = wrapWithErrorBoundary(pageErrorComponent, element); + } + + const notFoundComponent = + getDefaultExport(options.route.notFound) ?? getDefaultExport(options.rootNotFoundModule); + if (notFoundComponent) { + const NotFoundComponent = notFoundComponent; + element = }>{element}; + } + + const templates = options.route.templates ?? []; + for (let index = templates.length - 1; index >= 0; index--) { + const templateComponent = getDefaultExport(templates[index]); + if (!templateComponent) { + continue; + } + const TemplateComponent = templateComponent; + element = {element}; + } + + const routeSlots = options.route.slots ?? {}; + const layoutEntries = createAppPageLayoutEntries(options.route); + const routeThenableParams = options.makeThenableParams(options.matchedParams); + + for (let index = layoutEntries.length - 1; index >= 0; index--) { + const layoutEntry = layoutEntries[index]; + const layoutErrorComponent = getDefaultExport(layoutEntry.errorModule); + if (layoutErrorComponent) { + element = wrapWithErrorBoundary(layoutErrorComponent, element); + } + + const layoutComponent = getDefaultExport(layoutEntry.layoutModule); + if (!layoutComponent) { + continue; + } + + const layoutNotFoundComponent = getDefaultExport(layoutEntry.notFoundModule); + if (layoutNotFoundComponent) { + const LayoutNotFoundComponent = layoutNotFoundComponent; + element = ( + }>{element} + ); + } + + const layoutProps: Record = { + params: routeThenableParams, + }; + + for (const [slotName, slot] of Object.entries(routeSlots)) { + const targetIndex = slot.layoutIndex >= 0 ? slot.layoutIndex : layoutEntries.length - 1; + if (index !== targetIndex) { + continue; + } + + const slotOverride = options.slotOverrides?.[slotName]; + const slotParams = slotOverride?.params ?? options.matchedParams; + const slotComponent = + getDefaultExport(slotOverride?.pageModule) ?? + getDefaultExport(slot.page) ?? + getDefaultExport(slot.default); + if (!slotComponent) { + continue; + } + + const slotProps: Record = { + params: options.makeThenableParams(slotParams), + }; + if (slotOverride?.props) { + Object.assign(slotProps, slotOverride.props); + } + + const SlotComponent = slotComponent; + let slotElement: ReactNode = ; + + const slotLayoutComponent = getDefaultExport(slot.layout); + if (slotLayoutComponent) { + const SlotLayoutComponent = slotLayoutComponent; + slotElement = ( + + {slotElement} + + ); + } + + const slotLoadingComponent = getDefaultExport(slot.loading); + if (slotLoadingComponent) { + const SlotLoadingComponent = slotLoadingComponent; + slotElement = }>{slotElement}; + } + + const slotErrorComponent = getDefaultExport(slot.error); + if (slotErrorComponent) { + slotElement = wrapWithErrorBoundary(slotErrorComponent, slotElement); + } + + layoutProps[slotName] = slotElement; + } + + const LayoutComponent = layoutComponent; + element = {element}; + element = ( + + {element} + + ); + } + + const globalErrorComponent = getDefaultExport(options.globalErrorModule); + if (globalErrorComponent) { + element = wrapWithErrorBoundary(globalErrorComponent, element); + } + + return element; +} diff --git a/packages/vinext/src/shims/error-boundary.tsx b/packages/vinext/src/shims/error-boundary.tsx index 1f097ba1c..b7eb76fd5 100644 --- a/packages/vinext/src/shims/error-boundary.tsx +++ b/packages/vinext/src/shims/error-boundary.tsx @@ -9,8 +9,13 @@ export type ErrorBoundaryProps = { children: React.ReactNode; }; +type ErrorBoundaryInnerProps = { + pathname: string; +} & ErrorBoundaryProps; + export type ErrorBoundaryState = { error: Error | null; + previousPathname: string; }; /** @@ -18,10 +23,23 @@ export type ErrorBoundaryState = { * This must be a client component since error boundaries use * componentDidCatch / getDerivedStateFromError. */ -export class ErrorBoundary extends React.Component { - constructor(props: ErrorBoundaryProps) { +export class ErrorBoundaryInner extends React.Component< + ErrorBoundaryInnerProps, + ErrorBoundaryState +> { + constructor(props: ErrorBoundaryInnerProps) { super(props); - this.state = { error: null }; + this.state = { error: null, previousPathname: props.pathname }; + } + + static getDerivedStateFromProps( + props: ErrorBoundaryInnerProps, + state: ErrorBoundaryState, + ): ErrorBoundaryState | null { + if (props.pathname !== state.previousPathname && state.error) { + return { error: null, previousPathname: props.pathname }; + } + return { error: state.error, previousPathname: props.pathname }; } static getDerivedStateFromError(error: Error): ErrorBoundaryState { @@ -38,7 +56,7 @@ export class ErrorBoundary extends React.Component { @@ -54,6 +72,15 @@ export class ErrorBoundary extends React.Component + {children} + + ); +} + // --------------------------------------------------------------------------- // NotFoundBoundary — catches notFound() on the client and renders not-found.tsx // --------------------------------------------------------------------------- diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index 7c8a503e2..96989ffff 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -41,13 +41,11 @@ function renderToReadableStream(model, options) { } })); } -import { createElement, Suspense, Fragment } from "react"; +import { createElement } from "react"; import { setNavigationContext as _setNavigationContextOrig, getNavigationContext as _getNavigationContext } from "next/navigation"; import { setHeadersContext, headersContextFromRequest, getDraftModeCookieHeader, getAndClearPendingCookies, consumeDynamicUsage, markDynamicUsage, applyMiddlewareRequestHeaders, getHeadersContext, setHeadersAccessPhase } from "next/headers"; import { NextRequest, NextFetchEvent } from "next/server"; -import { ErrorBoundary, NotFoundBoundary } from "vinext/error-boundary"; -import { LayoutSegmentProvider } from "vinext/layout-segment-context"; -import { MetadataHead, mergeMetadata, resolveModuleMetadata, ViewportHead, mergeViewport, resolveModuleViewport } from "vinext/metadata"; +import { mergeMetadata, resolveModuleMetadata, mergeViewport, resolveModuleViewport } from "vinext/metadata"; @@ -79,6 +77,10 @@ import { renderAppPageErrorBoundary as __renderAppPageErrorBoundary, renderAppPageHttpAccessFallback as __renderAppPageHttpAccessFallback, } from "/packages/vinext/src/server/app-page-boundary-render.js"; +import { + buildAppPageRouteElement as __buildAppPageRouteElement, + resolveAppPageChildSegments as __resolveAppPageChildSegments, +} from "/packages/vinext/src/server/app-page-route-wiring.js"; import { renderAppPageLifecycle as __renderAppPageLifecycle, } from "/packages/vinext/src/server/app-page-render.js"; @@ -246,38 +248,6 @@ function makeThenableParams(obj) { return Object.assign(Promise.resolve(plain), plain); } -// Resolve route tree segments to actual values using matched params. -// Dynamic segments like [id] are replaced with param values, catch-all -// segments like [...slug] are joined with "/", and route groups are kept as-is. -function __resolveChildSegments(routeSegments, treePosition, params) { - var raw = routeSegments.slice(treePosition); - var result = []; - for (var j = 0; j < raw.length; j++) { - var seg = raw[j]; - // Optional catch-all: [[...param]] - if (seg.indexOf("[[...") === 0 && seg.charAt(seg.length - 1) === "]" && seg.charAt(seg.length - 2) === "]") { - var pn = seg.slice(5, -2); - var v = params[pn]; - // Skip empty optional catch-all (e.g., visiting /blog on [[...slug]] route) - if (Array.isArray(v) && v.length === 0) continue; - if (v == null) continue; - result.push(Array.isArray(v) ? v.join("/") : v); - // Catch-all: [...param] - } else if (seg.indexOf("[...") === 0 && seg.charAt(seg.length - 1) === "]") { - var pn2 = seg.slice(4, -1); - var v2 = params[pn2]; - result.push(Array.isArray(v2) ? v2.join("/") : (v2 || seg)); - // Dynamic: [param] - } else if (seg.charAt(0) === "[" && seg.charAt(seg.length - 1) === "]" && seg.indexOf(".") === -1) { - var pn3 = seg.slice(1, -1); - result.push(params[pn3] || seg); - } else { - result.push(seg); - } - } - return result; -} - // djb2 hash — matches Next.js's stringHash for digest generation. // Produces a stable numeric string from error message + stack. function __errorDigest(str) { @@ -544,7 +514,7 @@ async function renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, req makeThenableParams, matchedParams: opts?.matchedParams ?? route?.params ?? {}, requestUrl: request.url, - resolveChildSegments: __resolveChildSegments, + resolveChildSegments: __resolveAppPageChildSegments, rootForbiddenModule: rootForbiddenModule, rootLayouts: rootLayouts, rootNotFoundModule: rootNotFoundModule, @@ -590,7 +560,7 @@ async function renderErrorBoundaryPage(route, error, isRscRequest, request, matc makeThenableParams, matchedParams: matchedParams ?? route?.params ?? {}, requestUrl: request.url, - resolveChildSegments: __resolveChildSegments, + resolveChildSegments: __resolveAppPageChildSegments, rootLayouts: rootLayouts, route, renderToReadableStream, @@ -756,12 +726,10 @@ async function buildPageElement(route, params, opts, searchParams) { const resolvedMetadata = metadataList.length > 0 ? mergeMetadata(metadataList) : null; const resolvedViewport = mergeViewport(viewportList); - // Build nested layout tree from outermost to innermost. - // Next.js 16 passes params/searchParams as Promises (async pattern) - // but pre-16 code accesses them as plain objects (params.id). - // makeThenableParams() normalises null-prototype + preserves both patterns. - const asyncParams = makeThenableParams(params); - const pageProps = { params: asyncParams }; + // Build the route tree from the leaf page, then delegate the boundary/layout/ + // template/segment wiring to a typed runtime helper so the generated entry + // stays thin and the wiring logic can be unit tested directly. + const pageProps = { params: makeThenableParams(params) }; if (searchParams) { // Always provide searchParams prop when the URL object is available, even // when the query string is empty -- pages that do "await searchParams" need @@ -777,184 +745,25 @@ async function buildPageElement(route, params, opts, searchParams) { // dynamic, and this avoids false positives from React internals. if (hasSearchParams) markDynamicUsage(); } - let element = createElement(PageComponent, pageProps); - - // Wrap page with empty segment provider so useSelectedLayoutSegments() - // returns [] when called from inside a page component (leaf node). - element = createElement(LayoutSegmentProvider, { segmentMap: { children: [] } }, element); - - // Add metadata + viewport head tags (React 19 hoists title/meta/link to ) - // Next.js always injects charset and default viewport even when no metadata/viewport - // is exported. We replicate that by always emitting these essential head elements. - { - const headElements = []; - // Always emit — Next.js includes this on every page - headElements.push(createElement("meta", { charSet: "utf-8" })); - if (resolvedMetadata) headElements.push(createElement(MetadataHead, { metadata: resolvedMetadata })); - headElements.push(createElement(ViewportHead, { viewport: resolvedViewport })); - element = createElement(Fragment, null, ...headElements, element); - } - - // Wrap with loading.tsx Suspense if present - if (route.loading?.default) { - element = createElement( - Suspense, - { fallback: createElement(route.loading.default) }, - element, - ); - } - - // Wrap with the leaf's error.tsx ErrorBoundary if it's not already covered - // by a per-layout error boundary (i.e., the leaf has error.tsx but no layout). - // Per-layout error boundaries are interleaved with layouts below. - { - const lastLayoutError = route.errors ? route.errors[route.errors.length - 1] : null; - if (route.error?.default && route.error !== lastLayoutError) { - element = createElement(ErrorBoundary, { - fallback: route.error.default, - children: element, - }); - } - } - - // Wrap with NotFoundBoundary so client-side notFound() renders not-found.tsx - // instead of crashing the React tree. Must be above ErrorBoundary since - // ErrorBoundary re-throws notFound errors. - // Pre-render the not-found component as a React element since it may be a - // server component (not a client reference) and can't be passed as a function prop. - { - const NotFoundComponent = route.notFound?.default ?? null; - if (NotFoundComponent) { - element = createElement(NotFoundBoundary, { - fallback: createElement(NotFoundComponent), - children: element, - }); - } - } - - // Wrap with templates (innermost first, then outer) - // Templates are like layouts but re-mount on navigation (client-side concern). - // On the server, they just wrap the content like layouts do. - if (route.templates) { - for (let i = route.templates.length - 1; i >= 0; i--) { - const TemplateComponent = route.templates[i]?.default; - if (TemplateComponent) { - element = createElement(TemplateComponent, { children: element, params }); - } - } - } - - // Wrap with layouts (innermost first, then outer). - // At each layout level, first wrap with that level's error boundary (if any) - // so the boundary is inside the layout and catches errors from children. - // This matches Next.js behavior: Layout > ErrorBoundary > children. - // Parallel slots are passed as named props to the innermost layout - // (the layout at the same directory level as the page/slots) - for (let i = route.layouts.length - 1; i >= 0; i--) { - // Wrap with per-layout error boundary before wrapping with layout. - // This places the ErrorBoundary inside the layout, catching errors - // from child segments (matching Next.js per-segment error handling). - if (route.errors && route.errors[i]?.default) { - element = createElement(ErrorBoundary, { - fallback: route.errors[i].default, - children: element, - }); - } - - const LayoutComponent = route.layouts[i]?.default; - if (LayoutComponent) { - // Per-layout NotFoundBoundary: wraps this layout's children so that - // notFound() thrown from a child layout is caught here. - // Matches Next.js behavior where each segment has its own boundary. - // The boundary at level N catches errors from Layout[N+1] and below, - // but NOT from Layout[N] itself (which propagates to level N-1). - { - const LayoutNotFound = route.notFounds?.[i]?.default; - if (LayoutNotFound) { - element = createElement(NotFoundBoundary, { - fallback: createElement(LayoutNotFound), - children: element, - }); - } - } - - const layoutProps = { children: element, params: makeThenableParams(params) }; - - // Add parallel slot elements to the layout that defines them. - // Each slot has a layoutIndex indicating which layout it belongs to. - if (route.slots) { - for (const [slotName, slotMod] of Object.entries(route.slots)) { - // Attach slot to the layout at its layoutIndex, or to the innermost layout if -1 - const targetIdx = slotMod.layoutIndex >= 0 ? slotMod.layoutIndex : route.layouts.length - 1; - if (i !== targetIdx) continue; - // Check if this slot has an intercepting route that should activate - let SlotPage = null; - let slotParams = params; - - if (opts && opts.interceptSlot === slotName && opts.interceptPage) { - // Use the intercepting route's page component - SlotPage = opts.interceptPage.default; - slotParams = opts.interceptParams || params; - } else { - SlotPage = slotMod.page?.default || slotMod.default?.default; - } - - if (SlotPage) { - let slotElement = createElement(SlotPage, { params: makeThenableParams(slotParams) }); - // Wrap with slot-specific layout if present. - // In Next.js, @slot/layout.tsx wraps the slot's page content - // before it is passed as a prop to the parent layout. - const SlotLayout = slotMod.layout?.default; - if (SlotLayout) { - slotElement = createElement(SlotLayout, { - children: slotElement, - params: makeThenableParams(slotParams), - }); - } - // Wrap with slot-specific loading if present - if (slotMod.loading?.default) { - slotElement = createElement(Suspense, - { fallback: createElement(slotMod.loading.default) }, - slotElement, - ); - } - // Wrap with slot-specific error boundary if present - if (slotMod.error?.default) { - slotElement = createElement(ErrorBoundary, { - fallback: slotMod.error.default, - children: slotElement, - }); - } - layoutProps[slotName] = slotElement; + return __buildAppPageRouteElement({ + element: createElement(PageComponent, pageProps), + globalErrorModule: null, + makeThenableParams, + matchedParams: params, + resolvedMetadata, + resolvedViewport, + rootNotFoundModule: null, + route, + slotOverrides: + opts && opts.interceptSlot && opts.interceptPage + ? { + [opts.interceptSlot]: { + pageModule: opts.interceptPage, + params: opts.interceptParams || params, + }, } - } - } - - element = createElement(LayoutComponent, layoutProps); - - // Wrap the layout with LayoutSegmentProvider so useSelectedLayoutSegments() - // called INSIDE this layout gets the correct child segments. We resolve the - // route tree segments using actual param values and pass them through context. - // We wrap the layout (not just children) because hooks are called from - // components rendered inside the layout's own JSX. - const treePos = route.layoutTreePositions ? route.layoutTreePositions[i] : 0; - const childSegs = __resolveChildSegments(route.routeSegments || [], treePos, params); - element = createElement(LayoutSegmentProvider, { segmentMap: { children: childSegs } }, element); - } - } - - // Wrap with global error boundary if app/global-error.tsx exists. - // This must be present in both HTML and RSC paths so the component tree - // structure matches — otherwise React reconciliation on client-side navigation - // would see a mismatched tree and destroy/recreate the DOM. - // - // For RSC requests (client-side nav), this provides error recovery on the client. - // For HTML requests (initial page load), the ErrorBoundary catches during SSR - // but produces double / (root layout + global-error). The request - // handler detects this via the rscOnError flag and re-renders without layouts. - - - return element; + : null, + }); } @@ -2235,13 +2044,11 @@ function renderToReadableStream(model, options) { } })); } -import { createElement, Suspense, Fragment } from "react"; +import { createElement } from "react"; import { setNavigationContext as _setNavigationContextOrig, getNavigationContext as _getNavigationContext } from "next/navigation"; import { setHeadersContext, headersContextFromRequest, getDraftModeCookieHeader, getAndClearPendingCookies, consumeDynamicUsage, markDynamicUsage, applyMiddlewareRequestHeaders, getHeadersContext, setHeadersAccessPhase } from "next/headers"; import { NextRequest, NextFetchEvent } from "next/server"; -import { ErrorBoundary, NotFoundBoundary } from "vinext/error-boundary"; -import { LayoutSegmentProvider } from "vinext/layout-segment-context"; -import { MetadataHead, mergeMetadata, resolveModuleMetadata, ViewportHead, mergeViewport, resolveModuleViewport } from "vinext/metadata"; +import { mergeMetadata, resolveModuleMetadata, mergeViewport, resolveModuleViewport } from "vinext/metadata"; @@ -2273,6 +2080,10 @@ import { renderAppPageErrorBoundary as __renderAppPageErrorBoundary, renderAppPageHttpAccessFallback as __renderAppPageHttpAccessFallback, } from "/packages/vinext/src/server/app-page-boundary-render.js"; +import { + buildAppPageRouteElement as __buildAppPageRouteElement, + resolveAppPageChildSegments as __resolveAppPageChildSegments, +} from "/packages/vinext/src/server/app-page-route-wiring.js"; import { renderAppPageLifecycle as __renderAppPageLifecycle, } from "/packages/vinext/src/server/app-page-render.js"; @@ -2440,38 +2251,6 @@ function makeThenableParams(obj) { return Object.assign(Promise.resolve(plain), plain); } -// Resolve route tree segments to actual values using matched params. -// Dynamic segments like [id] are replaced with param values, catch-all -// segments like [...slug] are joined with "/", and route groups are kept as-is. -function __resolveChildSegments(routeSegments, treePosition, params) { - var raw = routeSegments.slice(treePosition); - var result = []; - for (var j = 0; j < raw.length; j++) { - var seg = raw[j]; - // Optional catch-all: [[...param]] - if (seg.indexOf("[[...") === 0 && seg.charAt(seg.length - 1) === "]" && seg.charAt(seg.length - 2) === "]") { - var pn = seg.slice(5, -2); - var v = params[pn]; - // Skip empty optional catch-all (e.g., visiting /blog on [[...slug]] route) - if (Array.isArray(v) && v.length === 0) continue; - if (v == null) continue; - result.push(Array.isArray(v) ? v.join("/") : v); - // Catch-all: [...param] - } else if (seg.indexOf("[...") === 0 && seg.charAt(seg.length - 1) === "]") { - var pn2 = seg.slice(4, -1); - var v2 = params[pn2]; - result.push(Array.isArray(v2) ? v2.join("/") : (v2 || seg)); - // Dynamic: [param] - } else if (seg.charAt(0) === "[" && seg.charAt(seg.length - 1) === "]" && seg.indexOf(".") === -1) { - var pn3 = seg.slice(1, -1); - result.push(params[pn3] || seg); - } else { - result.push(seg); - } - } - return result; -} - // djb2 hash — matches Next.js's stringHash for digest generation. // Produces a stable numeric string from error message + stack. function __errorDigest(str) { @@ -2738,7 +2517,7 @@ async function renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, req makeThenableParams, matchedParams: opts?.matchedParams ?? route?.params ?? {}, requestUrl: request.url, - resolveChildSegments: __resolveChildSegments, + resolveChildSegments: __resolveAppPageChildSegments, rootForbiddenModule: rootForbiddenModule, rootLayouts: rootLayouts, rootNotFoundModule: rootNotFoundModule, @@ -2784,7 +2563,7 @@ async function renderErrorBoundaryPage(route, error, isRscRequest, request, matc makeThenableParams, matchedParams: matchedParams ?? route?.params ?? {}, requestUrl: request.url, - resolveChildSegments: __resolveChildSegments, + resolveChildSegments: __resolveAppPageChildSegments, rootLayouts: rootLayouts, route, renderToReadableStream, @@ -2950,12 +2729,10 @@ async function buildPageElement(route, params, opts, searchParams) { const resolvedMetadata = metadataList.length > 0 ? mergeMetadata(metadataList) : null; const resolvedViewport = mergeViewport(viewportList); - // Build nested layout tree from outermost to innermost. - // Next.js 16 passes params/searchParams as Promises (async pattern) - // but pre-16 code accesses them as plain objects (params.id). - // makeThenableParams() normalises null-prototype + preserves both patterns. - const asyncParams = makeThenableParams(params); - const pageProps = { params: asyncParams }; + // Build the route tree from the leaf page, then delegate the boundary/layout/ + // template/segment wiring to a typed runtime helper so the generated entry + // stays thin and the wiring logic can be unit tested directly. + const pageProps = { params: makeThenableParams(params) }; if (searchParams) { // Always provide searchParams prop when the URL object is available, even // when the query string is empty -- pages that do "await searchParams" need @@ -2971,184 +2748,25 @@ async function buildPageElement(route, params, opts, searchParams) { // dynamic, and this avoids false positives from React internals. if (hasSearchParams) markDynamicUsage(); } - let element = createElement(PageComponent, pageProps); - - // Wrap page with empty segment provider so useSelectedLayoutSegments() - // returns [] when called from inside a page component (leaf node). - element = createElement(LayoutSegmentProvider, { segmentMap: { children: [] } }, element); - - // Add metadata + viewport head tags (React 19 hoists title/meta/link to ) - // Next.js always injects charset and default viewport even when no metadata/viewport - // is exported. We replicate that by always emitting these essential head elements. - { - const headElements = []; - // Always emit — Next.js includes this on every page - headElements.push(createElement("meta", { charSet: "utf-8" })); - if (resolvedMetadata) headElements.push(createElement(MetadataHead, { metadata: resolvedMetadata })); - headElements.push(createElement(ViewportHead, { viewport: resolvedViewport })); - element = createElement(Fragment, null, ...headElements, element); - } - - // Wrap with loading.tsx Suspense if present - if (route.loading?.default) { - element = createElement( - Suspense, - { fallback: createElement(route.loading.default) }, - element, - ); - } - - // Wrap with the leaf's error.tsx ErrorBoundary if it's not already covered - // by a per-layout error boundary (i.e., the leaf has error.tsx but no layout). - // Per-layout error boundaries are interleaved with layouts below. - { - const lastLayoutError = route.errors ? route.errors[route.errors.length - 1] : null; - if (route.error?.default && route.error !== lastLayoutError) { - element = createElement(ErrorBoundary, { - fallback: route.error.default, - children: element, - }); - } - } - - // Wrap with NotFoundBoundary so client-side notFound() renders not-found.tsx - // instead of crashing the React tree. Must be above ErrorBoundary since - // ErrorBoundary re-throws notFound errors. - // Pre-render the not-found component as a React element since it may be a - // server component (not a client reference) and can't be passed as a function prop. - { - const NotFoundComponent = route.notFound?.default ?? null; - if (NotFoundComponent) { - element = createElement(NotFoundBoundary, { - fallback: createElement(NotFoundComponent), - children: element, - }); - } - } - - // Wrap with templates (innermost first, then outer) - // Templates are like layouts but re-mount on navigation (client-side concern). - // On the server, they just wrap the content like layouts do. - if (route.templates) { - for (let i = route.templates.length - 1; i >= 0; i--) { - const TemplateComponent = route.templates[i]?.default; - if (TemplateComponent) { - element = createElement(TemplateComponent, { children: element, params }); - } - } - } - - // Wrap with layouts (innermost first, then outer). - // At each layout level, first wrap with that level's error boundary (if any) - // so the boundary is inside the layout and catches errors from children. - // This matches Next.js behavior: Layout > ErrorBoundary > children. - // Parallel slots are passed as named props to the innermost layout - // (the layout at the same directory level as the page/slots) - for (let i = route.layouts.length - 1; i >= 0; i--) { - // Wrap with per-layout error boundary before wrapping with layout. - // This places the ErrorBoundary inside the layout, catching errors - // from child segments (matching Next.js per-segment error handling). - if (route.errors && route.errors[i]?.default) { - element = createElement(ErrorBoundary, { - fallback: route.errors[i].default, - children: element, - }); - } - - const LayoutComponent = route.layouts[i]?.default; - if (LayoutComponent) { - // Per-layout NotFoundBoundary: wraps this layout's children so that - // notFound() thrown from a child layout is caught here. - // Matches Next.js behavior where each segment has its own boundary. - // The boundary at level N catches errors from Layout[N+1] and below, - // but NOT from Layout[N] itself (which propagates to level N-1). - { - const LayoutNotFound = route.notFounds?.[i]?.default; - if (LayoutNotFound) { - element = createElement(NotFoundBoundary, { - fallback: createElement(LayoutNotFound), - children: element, - }); - } - } - - const layoutProps = { children: element, params: makeThenableParams(params) }; - - // Add parallel slot elements to the layout that defines them. - // Each slot has a layoutIndex indicating which layout it belongs to. - if (route.slots) { - for (const [slotName, slotMod] of Object.entries(route.slots)) { - // Attach slot to the layout at its layoutIndex, or to the innermost layout if -1 - const targetIdx = slotMod.layoutIndex >= 0 ? slotMod.layoutIndex : route.layouts.length - 1; - if (i !== targetIdx) continue; - // Check if this slot has an intercepting route that should activate - let SlotPage = null; - let slotParams = params; - - if (opts && opts.interceptSlot === slotName && opts.interceptPage) { - // Use the intercepting route's page component - SlotPage = opts.interceptPage.default; - slotParams = opts.interceptParams || params; - } else { - SlotPage = slotMod.page?.default || slotMod.default?.default; - } - - if (SlotPage) { - let slotElement = createElement(SlotPage, { params: makeThenableParams(slotParams) }); - // Wrap with slot-specific layout if present. - // In Next.js, @slot/layout.tsx wraps the slot's page content - // before it is passed as a prop to the parent layout. - const SlotLayout = slotMod.layout?.default; - if (SlotLayout) { - slotElement = createElement(SlotLayout, { - children: slotElement, - params: makeThenableParams(slotParams), - }); - } - // Wrap with slot-specific loading if present - if (slotMod.loading?.default) { - slotElement = createElement(Suspense, - { fallback: createElement(slotMod.loading.default) }, - slotElement, - ); - } - // Wrap with slot-specific error boundary if present - if (slotMod.error?.default) { - slotElement = createElement(ErrorBoundary, { - fallback: slotMod.error.default, - children: slotElement, - }); - } - layoutProps[slotName] = slotElement; + return __buildAppPageRouteElement({ + element: createElement(PageComponent, pageProps), + globalErrorModule: null, + makeThenableParams, + matchedParams: params, + resolvedMetadata, + resolvedViewport, + rootNotFoundModule: null, + route, + slotOverrides: + opts && opts.interceptSlot && opts.interceptPage + ? { + [opts.interceptSlot]: { + pageModule: opts.interceptPage, + params: opts.interceptParams || params, + }, } - } - } - - element = createElement(LayoutComponent, layoutProps); - - // Wrap the layout with LayoutSegmentProvider so useSelectedLayoutSegments() - // called INSIDE this layout gets the correct child segments. We resolve the - // route tree segments using actual param values and pass them through context. - // We wrap the layout (not just children) because hooks are called from - // components rendered inside the layout's own JSX. - const treePos = route.layoutTreePositions ? route.layoutTreePositions[i] : 0; - const childSegs = __resolveChildSegments(route.routeSegments || [], treePos, params); - element = createElement(LayoutSegmentProvider, { segmentMap: { children: childSegs } }, element); - } - } - - // Wrap with global error boundary if app/global-error.tsx exists. - // This must be present in both HTML and RSC paths so the component tree - // structure matches — otherwise React reconciliation on client-side navigation - // would see a mismatched tree and destroy/recreate the DOM. - // - // For RSC requests (client-side nav), this provides error recovery on the client. - // For HTML requests (initial page load), the ErrorBoundary catches during SSR - // but produces double / (root layout + global-error). The request - // handler detects this via the rscOnError flag and re-renders without layouts. - - - return element; + : null, + }); } @@ -4432,13 +4050,11 @@ function renderToReadableStream(model, options) { } })); } -import { createElement, Suspense, Fragment } from "react"; +import { createElement } from "react"; import { setNavigationContext as _setNavigationContextOrig, getNavigationContext as _getNavigationContext } from "next/navigation"; import { setHeadersContext, headersContextFromRequest, getDraftModeCookieHeader, getAndClearPendingCookies, consumeDynamicUsage, markDynamicUsage, applyMiddlewareRequestHeaders, getHeadersContext, setHeadersAccessPhase } from "next/headers"; import { NextRequest, NextFetchEvent } from "next/server"; -import { ErrorBoundary, NotFoundBoundary } from "vinext/error-boundary"; -import { LayoutSegmentProvider } from "vinext/layout-segment-context"; -import { MetadataHead, mergeMetadata, resolveModuleMetadata, ViewportHead, mergeViewport, resolveModuleViewport } from "vinext/metadata"; +import { mergeMetadata, resolveModuleMetadata, mergeViewport, resolveModuleViewport } from "vinext/metadata"; @@ -4470,6 +4086,10 @@ import { renderAppPageErrorBoundary as __renderAppPageErrorBoundary, renderAppPageHttpAccessFallback as __renderAppPageHttpAccessFallback, } from "/packages/vinext/src/server/app-page-boundary-render.js"; +import { + buildAppPageRouteElement as __buildAppPageRouteElement, + resolveAppPageChildSegments as __resolveAppPageChildSegments, +} from "/packages/vinext/src/server/app-page-route-wiring.js"; import { renderAppPageLifecycle as __renderAppPageLifecycle, } from "/packages/vinext/src/server/app-page-render.js"; @@ -4637,38 +4257,6 @@ function makeThenableParams(obj) { return Object.assign(Promise.resolve(plain), plain); } -// Resolve route tree segments to actual values using matched params. -// Dynamic segments like [id] are replaced with param values, catch-all -// segments like [...slug] are joined with "/", and route groups are kept as-is. -function __resolveChildSegments(routeSegments, treePosition, params) { - var raw = routeSegments.slice(treePosition); - var result = []; - for (var j = 0; j < raw.length; j++) { - var seg = raw[j]; - // Optional catch-all: [[...param]] - if (seg.indexOf("[[...") === 0 && seg.charAt(seg.length - 1) === "]" && seg.charAt(seg.length - 2) === "]") { - var pn = seg.slice(5, -2); - var v = params[pn]; - // Skip empty optional catch-all (e.g., visiting /blog on [[...slug]] route) - if (Array.isArray(v) && v.length === 0) continue; - if (v == null) continue; - result.push(Array.isArray(v) ? v.join("/") : v); - // Catch-all: [...param] - } else if (seg.indexOf("[...") === 0 && seg.charAt(seg.length - 1) === "]") { - var pn2 = seg.slice(4, -1); - var v2 = params[pn2]; - result.push(Array.isArray(v2) ? v2.join("/") : (v2 || seg)); - // Dynamic: [param] - } else if (seg.charAt(0) === "[" && seg.charAt(seg.length - 1) === "]" && seg.indexOf(".") === -1) { - var pn3 = seg.slice(1, -1); - result.push(params[pn3] || seg); - } else { - result.push(seg); - } - } - return result; -} - // djb2 hash — matches Next.js's stringHash for digest generation. // Produces a stable numeric string from error message + stack. function __errorDigest(str) { @@ -4936,7 +4524,7 @@ async function renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, req makeThenableParams, matchedParams: opts?.matchedParams ?? route?.params ?? {}, requestUrl: request.url, - resolveChildSegments: __resolveChildSegments, + resolveChildSegments: __resolveAppPageChildSegments, rootForbiddenModule: rootForbiddenModule, rootLayouts: rootLayouts, rootNotFoundModule: rootNotFoundModule, @@ -4982,7 +4570,7 @@ async function renderErrorBoundaryPage(route, error, isRscRequest, request, matc makeThenableParams, matchedParams: matchedParams ?? route?.params ?? {}, requestUrl: request.url, - resolveChildSegments: __resolveChildSegments, + resolveChildSegments: __resolveAppPageChildSegments, rootLayouts: rootLayouts, route, renderToReadableStream, @@ -5148,12 +4736,10 @@ async function buildPageElement(route, params, opts, searchParams) { const resolvedMetadata = metadataList.length > 0 ? mergeMetadata(metadataList) : null; const resolvedViewport = mergeViewport(viewportList); - // Build nested layout tree from outermost to innermost. - // Next.js 16 passes params/searchParams as Promises (async pattern) - // but pre-16 code accesses them as plain objects (params.id). - // makeThenableParams() normalises null-prototype + preserves both patterns. - const asyncParams = makeThenableParams(params); - const pageProps = { params: asyncParams }; + // Build the route tree from the leaf page, then delegate the boundary/layout/ + // template/segment wiring to a typed runtime helper so the generated entry + // stays thin and the wiring logic can be unit tested directly. + const pageProps = { params: makeThenableParams(params) }; if (searchParams) { // Always provide searchParams prop when the URL object is available, even // when the query string is empty -- pages that do "await searchParams" need @@ -5169,192 +4755,25 @@ async function buildPageElement(route, params, opts, searchParams) { // dynamic, and this avoids false positives from React internals. if (hasSearchParams) markDynamicUsage(); } - let element = createElement(PageComponent, pageProps); - - // Wrap page with empty segment provider so useSelectedLayoutSegments() - // returns [] when called from inside a page component (leaf node). - element = createElement(LayoutSegmentProvider, { segmentMap: { children: [] } }, element); - - // Add metadata + viewport head tags (React 19 hoists title/meta/link to ) - // Next.js always injects charset and default viewport even when no metadata/viewport - // is exported. We replicate that by always emitting these essential head elements. - { - const headElements = []; - // Always emit — Next.js includes this on every page - headElements.push(createElement("meta", { charSet: "utf-8" })); - if (resolvedMetadata) headElements.push(createElement(MetadataHead, { metadata: resolvedMetadata })); - headElements.push(createElement(ViewportHead, { viewport: resolvedViewport })); - element = createElement(Fragment, null, ...headElements, element); - } - - // Wrap with loading.tsx Suspense if present - if (route.loading?.default) { - element = createElement( - Suspense, - { fallback: createElement(route.loading.default) }, - element, - ); - } - - // Wrap with the leaf's error.tsx ErrorBoundary if it's not already covered - // by a per-layout error boundary (i.e., the leaf has error.tsx but no layout). - // Per-layout error boundaries are interleaved with layouts below. - { - const lastLayoutError = route.errors ? route.errors[route.errors.length - 1] : null; - if (route.error?.default && route.error !== lastLayoutError) { - element = createElement(ErrorBoundary, { - fallback: route.error.default, - children: element, - }); - } - } - - // Wrap with NotFoundBoundary so client-side notFound() renders not-found.tsx - // instead of crashing the React tree. Must be above ErrorBoundary since - // ErrorBoundary re-throws notFound errors. - // Pre-render the not-found component as a React element since it may be a - // server component (not a client reference) and can't be passed as a function prop. - { - const NotFoundComponent = route.notFound?.default ?? null; - if (NotFoundComponent) { - element = createElement(NotFoundBoundary, { - fallback: createElement(NotFoundComponent), - children: element, - }); - } - } - - // Wrap with templates (innermost first, then outer) - // Templates are like layouts but re-mount on navigation (client-side concern). - // On the server, they just wrap the content like layouts do. - if (route.templates) { - for (let i = route.templates.length - 1; i >= 0; i--) { - const TemplateComponent = route.templates[i]?.default; - if (TemplateComponent) { - element = createElement(TemplateComponent, { children: element, params }); - } - } - } - - // Wrap with layouts (innermost first, then outer). - // At each layout level, first wrap with that level's error boundary (if any) - // so the boundary is inside the layout and catches errors from children. - // This matches Next.js behavior: Layout > ErrorBoundary > children. - // Parallel slots are passed as named props to the innermost layout - // (the layout at the same directory level as the page/slots) - for (let i = route.layouts.length - 1; i >= 0; i--) { - // Wrap with per-layout error boundary before wrapping with layout. - // This places the ErrorBoundary inside the layout, catching errors - // from child segments (matching Next.js per-segment error handling). - if (route.errors && route.errors[i]?.default) { - element = createElement(ErrorBoundary, { - fallback: route.errors[i].default, - children: element, - }); - } - - const LayoutComponent = route.layouts[i]?.default; - if (LayoutComponent) { - // Per-layout NotFoundBoundary: wraps this layout's children so that - // notFound() thrown from a child layout is caught here. - // Matches Next.js behavior where each segment has its own boundary. - // The boundary at level N catches errors from Layout[N+1] and below, - // but NOT from Layout[N] itself (which propagates to level N-1). - { - const LayoutNotFound = route.notFounds?.[i]?.default; - if (LayoutNotFound) { - element = createElement(NotFoundBoundary, { - fallback: createElement(LayoutNotFound), - children: element, - }); - } - } - - const layoutProps = { children: element, params: makeThenableParams(params) }; - - // Add parallel slot elements to the layout that defines them. - // Each slot has a layoutIndex indicating which layout it belongs to. - if (route.slots) { - for (const [slotName, slotMod] of Object.entries(route.slots)) { - // Attach slot to the layout at its layoutIndex, or to the innermost layout if -1 - const targetIdx = slotMod.layoutIndex >= 0 ? slotMod.layoutIndex : route.layouts.length - 1; - if (i !== targetIdx) continue; - // Check if this slot has an intercepting route that should activate - let SlotPage = null; - let slotParams = params; - - if (opts && opts.interceptSlot === slotName && opts.interceptPage) { - // Use the intercepting route's page component - SlotPage = opts.interceptPage.default; - slotParams = opts.interceptParams || params; - } else { - SlotPage = slotMod.page?.default || slotMod.default?.default; - } - - if (SlotPage) { - let slotElement = createElement(SlotPage, { params: makeThenableParams(slotParams) }); - // Wrap with slot-specific layout if present. - // In Next.js, @slot/layout.tsx wraps the slot's page content - // before it is passed as a prop to the parent layout. - const SlotLayout = slotMod.layout?.default; - if (SlotLayout) { - slotElement = createElement(SlotLayout, { - children: slotElement, - params: makeThenableParams(slotParams), - }); - } - // Wrap with slot-specific loading if present - if (slotMod.loading?.default) { - slotElement = createElement(Suspense, - { fallback: createElement(slotMod.loading.default) }, - slotElement, - ); - } - // Wrap with slot-specific error boundary if present - if (slotMod.error?.default) { - slotElement = createElement(ErrorBoundary, { - fallback: slotMod.error.default, - children: slotElement, - }); - } - layoutProps[slotName] = slotElement; + return __buildAppPageRouteElement({ + element: createElement(PageComponent, pageProps), + globalErrorModule: mod_11, + makeThenableParams, + matchedParams: params, + resolvedMetadata, + resolvedViewport, + rootNotFoundModule: null, + route, + slotOverrides: + opts && opts.interceptSlot && opts.interceptPage + ? { + [opts.interceptSlot]: { + pageModule: opts.interceptPage, + params: opts.interceptParams || params, + }, } - } - } - - element = createElement(LayoutComponent, layoutProps); - - // Wrap the layout with LayoutSegmentProvider so useSelectedLayoutSegments() - // called INSIDE this layout gets the correct child segments. We resolve the - // route tree segments using actual param values and pass them through context. - // We wrap the layout (not just children) because hooks are called from - // components rendered inside the layout's own JSX. - const treePos = route.layoutTreePositions ? route.layoutTreePositions[i] : 0; - const childSegs = __resolveChildSegments(route.routeSegments || [], treePos, params); - element = createElement(LayoutSegmentProvider, { segmentMap: { children: childSegs } }, element); - } - } - - // Wrap with global error boundary if app/global-error.tsx exists. - // This must be present in both HTML and RSC paths so the component tree - // structure matches — otherwise React reconciliation on client-side navigation - // would see a mismatched tree and destroy/recreate the DOM. - // - // For RSC requests (client-side nav), this provides error recovery on the client. - // For HTML requests (initial page load), the ErrorBoundary catches during SSR - // but produces double / (root layout + global-error). The request - // handler detects this via the rscOnError flag and re-renders without layouts. - - const GlobalErrorComponent = mod_11.default; - if (GlobalErrorComponent) { - element = createElement(ErrorBoundary, { - fallback: GlobalErrorComponent, - children: element, - }); - } - - - return element; + : null, + }); } @@ -6635,13 +6054,11 @@ function renderToReadableStream(model, options) { } })); } -import { createElement, Suspense, Fragment } from "react"; +import { createElement } from "react"; import { setNavigationContext as _setNavigationContextOrig, getNavigationContext as _getNavigationContext } from "next/navigation"; import { setHeadersContext, headersContextFromRequest, getDraftModeCookieHeader, getAndClearPendingCookies, consumeDynamicUsage, markDynamicUsage, applyMiddlewareRequestHeaders, getHeadersContext, setHeadersAccessPhase } from "next/headers"; import { NextRequest, NextFetchEvent } from "next/server"; -import { ErrorBoundary, NotFoundBoundary } from "vinext/error-boundary"; -import { LayoutSegmentProvider } from "vinext/layout-segment-context"; -import { MetadataHead, mergeMetadata, resolveModuleMetadata, ViewportHead, mergeViewport, resolveModuleViewport } from "vinext/metadata"; +import { mergeMetadata, resolveModuleMetadata, mergeViewport, resolveModuleViewport } from "vinext/metadata"; import * as _instrumentation from "/tmp/test/instrumentation.ts"; @@ -6673,6 +6090,10 @@ import { renderAppPageErrorBoundary as __renderAppPageErrorBoundary, renderAppPageHttpAccessFallback as __renderAppPageHttpAccessFallback, } from "/packages/vinext/src/server/app-page-boundary-render.js"; +import { + buildAppPageRouteElement as __buildAppPageRouteElement, + resolveAppPageChildSegments as __resolveAppPageChildSegments, +} from "/packages/vinext/src/server/app-page-route-wiring.js"; import { renderAppPageLifecycle as __renderAppPageLifecycle, } from "/packages/vinext/src/server/app-page-render.js"; @@ -6840,38 +6261,6 @@ function makeThenableParams(obj) { return Object.assign(Promise.resolve(plain), plain); } -// Resolve route tree segments to actual values using matched params. -// Dynamic segments like [id] are replaced with param values, catch-all -// segments like [...slug] are joined with "/", and route groups are kept as-is. -function __resolveChildSegments(routeSegments, treePosition, params) { - var raw = routeSegments.slice(treePosition); - var result = []; - for (var j = 0; j < raw.length; j++) { - var seg = raw[j]; - // Optional catch-all: [[...param]] - if (seg.indexOf("[[...") === 0 && seg.charAt(seg.length - 1) === "]" && seg.charAt(seg.length - 2) === "]") { - var pn = seg.slice(5, -2); - var v = params[pn]; - // Skip empty optional catch-all (e.g., visiting /blog on [[...slug]] route) - if (Array.isArray(v) && v.length === 0) continue; - if (v == null) continue; - result.push(Array.isArray(v) ? v.join("/") : v); - // Catch-all: [...param] - } else if (seg.indexOf("[...") === 0 && seg.charAt(seg.length - 1) === "]") { - var pn2 = seg.slice(4, -1); - var v2 = params[pn2]; - result.push(Array.isArray(v2) ? v2.join("/") : (v2 || seg)); - // Dynamic: [param] - } else if (seg.charAt(0) === "[" && seg.charAt(seg.length - 1) === "]" && seg.indexOf(".") === -1) { - var pn3 = seg.slice(1, -1); - result.push(params[pn3] || seg); - } else { - result.push(seg); - } - } - return result; -} - // djb2 hash — matches Next.js's stringHash for digest generation. // Produces a stable numeric string from error message + stack. function __errorDigest(str) { @@ -7168,7 +6557,7 @@ async function renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, req makeThenableParams, matchedParams: opts?.matchedParams ?? route?.params ?? {}, requestUrl: request.url, - resolveChildSegments: __resolveChildSegments, + resolveChildSegments: __resolveAppPageChildSegments, rootForbiddenModule: rootForbiddenModule, rootLayouts: rootLayouts, rootNotFoundModule: rootNotFoundModule, @@ -7214,7 +6603,7 @@ async function renderErrorBoundaryPage(route, error, isRscRequest, request, matc makeThenableParams, matchedParams: matchedParams ?? route?.params ?? {}, requestUrl: request.url, - resolveChildSegments: __resolveChildSegments, + resolveChildSegments: __resolveAppPageChildSegments, rootLayouts: rootLayouts, route, renderToReadableStream, @@ -7380,12 +6769,10 @@ async function buildPageElement(route, params, opts, searchParams) { const resolvedMetadata = metadataList.length > 0 ? mergeMetadata(metadataList) : null; const resolvedViewport = mergeViewport(viewportList); - // Build nested layout tree from outermost to innermost. - // Next.js 16 passes params/searchParams as Promises (async pattern) - // but pre-16 code accesses them as plain objects (params.id). - // makeThenableParams() normalises null-prototype + preserves both patterns. - const asyncParams = makeThenableParams(params); - const pageProps = { params: asyncParams }; + // Build the route tree from the leaf page, then delegate the boundary/layout/ + // template/segment wiring to a typed runtime helper so the generated entry + // stays thin and the wiring logic can be unit tested directly. + const pageProps = { params: makeThenableParams(params) }; if (searchParams) { // Always provide searchParams prop when the URL object is available, even // when the query string is empty -- pages that do "await searchParams" need @@ -7401,184 +6788,25 @@ async function buildPageElement(route, params, opts, searchParams) { // dynamic, and this avoids false positives from React internals. if (hasSearchParams) markDynamicUsage(); } - let element = createElement(PageComponent, pageProps); - - // Wrap page with empty segment provider so useSelectedLayoutSegments() - // returns [] when called from inside a page component (leaf node). - element = createElement(LayoutSegmentProvider, { segmentMap: { children: [] } }, element); - - // Add metadata + viewport head tags (React 19 hoists title/meta/link to ) - // Next.js always injects charset and default viewport even when no metadata/viewport - // is exported. We replicate that by always emitting these essential head elements. - { - const headElements = []; - // Always emit — Next.js includes this on every page - headElements.push(createElement("meta", { charSet: "utf-8" })); - if (resolvedMetadata) headElements.push(createElement(MetadataHead, { metadata: resolvedMetadata })); - headElements.push(createElement(ViewportHead, { viewport: resolvedViewport })); - element = createElement(Fragment, null, ...headElements, element); - } - - // Wrap with loading.tsx Suspense if present - if (route.loading?.default) { - element = createElement( - Suspense, - { fallback: createElement(route.loading.default) }, - element, - ); - } - - // Wrap with the leaf's error.tsx ErrorBoundary if it's not already covered - // by a per-layout error boundary (i.e., the leaf has error.tsx but no layout). - // Per-layout error boundaries are interleaved with layouts below. - { - const lastLayoutError = route.errors ? route.errors[route.errors.length - 1] : null; - if (route.error?.default && route.error !== lastLayoutError) { - element = createElement(ErrorBoundary, { - fallback: route.error.default, - children: element, - }); - } - } - - // Wrap with NotFoundBoundary so client-side notFound() renders not-found.tsx - // instead of crashing the React tree. Must be above ErrorBoundary since - // ErrorBoundary re-throws notFound errors. - // Pre-render the not-found component as a React element since it may be a - // server component (not a client reference) and can't be passed as a function prop. - { - const NotFoundComponent = route.notFound?.default ?? null; - if (NotFoundComponent) { - element = createElement(NotFoundBoundary, { - fallback: createElement(NotFoundComponent), - children: element, - }); - } - } - - // Wrap with templates (innermost first, then outer) - // Templates are like layouts but re-mount on navigation (client-side concern). - // On the server, they just wrap the content like layouts do. - if (route.templates) { - for (let i = route.templates.length - 1; i >= 0; i--) { - const TemplateComponent = route.templates[i]?.default; - if (TemplateComponent) { - element = createElement(TemplateComponent, { children: element, params }); - } - } - } - - // Wrap with layouts (innermost first, then outer). - // At each layout level, first wrap with that level's error boundary (if any) - // so the boundary is inside the layout and catches errors from children. - // This matches Next.js behavior: Layout > ErrorBoundary > children. - // Parallel slots are passed as named props to the innermost layout - // (the layout at the same directory level as the page/slots) - for (let i = route.layouts.length - 1; i >= 0; i--) { - // Wrap with per-layout error boundary before wrapping with layout. - // This places the ErrorBoundary inside the layout, catching errors - // from child segments (matching Next.js per-segment error handling). - if (route.errors && route.errors[i]?.default) { - element = createElement(ErrorBoundary, { - fallback: route.errors[i].default, - children: element, - }); - } - - const LayoutComponent = route.layouts[i]?.default; - if (LayoutComponent) { - // Per-layout NotFoundBoundary: wraps this layout's children so that - // notFound() thrown from a child layout is caught here. - // Matches Next.js behavior where each segment has its own boundary. - // The boundary at level N catches errors from Layout[N+1] and below, - // but NOT from Layout[N] itself (which propagates to level N-1). - { - const LayoutNotFound = route.notFounds?.[i]?.default; - if (LayoutNotFound) { - element = createElement(NotFoundBoundary, { - fallback: createElement(LayoutNotFound), - children: element, - }); - } - } - - const layoutProps = { children: element, params: makeThenableParams(params) }; - - // Add parallel slot elements to the layout that defines them. - // Each slot has a layoutIndex indicating which layout it belongs to. - if (route.slots) { - for (const [slotName, slotMod] of Object.entries(route.slots)) { - // Attach slot to the layout at its layoutIndex, or to the innermost layout if -1 - const targetIdx = slotMod.layoutIndex >= 0 ? slotMod.layoutIndex : route.layouts.length - 1; - if (i !== targetIdx) continue; - // Check if this slot has an intercepting route that should activate - let SlotPage = null; - let slotParams = params; - - if (opts && opts.interceptSlot === slotName && opts.interceptPage) { - // Use the intercepting route's page component - SlotPage = opts.interceptPage.default; - slotParams = opts.interceptParams || params; - } else { - SlotPage = slotMod.page?.default || slotMod.default?.default; - } - - if (SlotPage) { - let slotElement = createElement(SlotPage, { params: makeThenableParams(slotParams) }); - // Wrap with slot-specific layout if present. - // In Next.js, @slot/layout.tsx wraps the slot's page content - // before it is passed as a prop to the parent layout. - const SlotLayout = slotMod.layout?.default; - if (SlotLayout) { - slotElement = createElement(SlotLayout, { - children: slotElement, - params: makeThenableParams(slotParams), - }); - } - // Wrap with slot-specific loading if present - if (slotMod.loading?.default) { - slotElement = createElement(Suspense, - { fallback: createElement(slotMod.loading.default) }, - slotElement, - ); - } - // Wrap with slot-specific error boundary if present - if (slotMod.error?.default) { - slotElement = createElement(ErrorBoundary, { - fallback: slotMod.error.default, - children: slotElement, - }); - } - layoutProps[slotName] = slotElement; + return __buildAppPageRouteElement({ + element: createElement(PageComponent, pageProps), + globalErrorModule: null, + makeThenableParams, + matchedParams: params, + resolvedMetadata, + resolvedViewport, + rootNotFoundModule: null, + route, + slotOverrides: + opts && opts.interceptSlot && opts.interceptPage + ? { + [opts.interceptSlot]: { + pageModule: opts.interceptPage, + params: opts.interceptParams || params, + }, } - } - } - - element = createElement(LayoutComponent, layoutProps); - - // Wrap the layout with LayoutSegmentProvider so useSelectedLayoutSegments() - // called INSIDE this layout gets the correct child segments. We resolve the - // route tree segments using actual param values and pass them through context. - // We wrap the layout (not just children) because hooks are called from - // components rendered inside the layout's own JSX. - const treePos = route.layoutTreePositions ? route.layoutTreePositions[i] : 0; - const childSegs = __resolveChildSegments(route.routeSegments || [], treePos, params); - element = createElement(LayoutSegmentProvider, { segmentMap: { children: childSegs } }, element); - } - } - - // Wrap with global error boundary if app/global-error.tsx exists. - // This must be present in both HTML and RSC paths so the component tree - // structure matches — otherwise React reconciliation on client-side navigation - // would see a mismatched tree and destroy/recreate the DOM. - // - // For RSC requests (client-side nav), this provides error recovery on the client. - // For HTML requests (initial page load), the ErrorBoundary catches during SSR - // but produces double / (root layout + global-error). The request - // handler detects this via the rscOnError flag and re-renders without layouts. - - - return element; + : null, + }); } @@ -8862,13 +8090,11 @@ function renderToReadableStream(model, options) { } })); } -import { createElement, Suspense, Fragment } from "react"; +import { createElement } from "react"; import { setNavigationContext as _setNavigationContextOrig, getNavigationContext as _getNavigationContext } from "next/navigation"; import { setHeadersContext, headersContextFromRequest, getDraftModeCookieHeader, getAndClearPendingCookies, consumeDynamicUsage, markDynamicUsage, applyMiddlewareRequestHeaders, getHeadersContext, setHeadersAccessPhase } from "next/headers"; import { NextRequest, NextFetchEvent } from "next/server"; -import { ErrorBoundary, NotFoundBoundary } from "vinext/error-boundary"; -import { LayoutSegmentProvider } from "vinext/layout-segment-context"; -import { MetadataHead, mergeMetadata, resolveModuleMetadata, ViewportHead, mergeViewport, resolveModuleViewport } from "vinext/metadata"; +import { mergeMetadata, resolveModuleMetadata, mergeViewport, resolveModuleViewport } from "vinext/metadata"; import { sitemapToXml, robotsToText, manifestToJson } from "/packages/vinext/src/server/metadata-routes.js"; @@ -8900,6 +8126,10 @@ import { renderAppPageErrorBoundary as __renderAppPageErrorBoundary, renderAppPageHttpAccessFallback as __renderAppPageHttpAccessFallback, } from "/packages/vinext/src/server/app-page-boundary-render.js"; +import { + buildAppPageRouteElement as __buildAppPageRouteElement, + resolveAppPageChildSegments as __resolveAppPageChildSegments, +} from "/packages/vinext/src/server/app-page-route-wiring.js"; import { renderAppPageLifecycle as __renderAppPageLifecycle, } from "/packages/vinext/src/server/app-page-render.js"; @@ -9067,38 +8297,6 @@ function makeThenableParams(obj) { return Object.assign(Promise.resolve(plain), plain); } -// Resolve route tree segments to actual values using matched params. -// Dynamic segments like [id] are replaced with param values, catch-all -// segments like [...slug] are joined with "/", and route groups are kept as-is. -function __resolveChildSegments(routeSegments, treePosition, params) { - var raw = routeSegments.slice(treePosition); - var result = []; - for (var j = 0; j < raw.length; j++) { - var seg = raw[j]; - // Optional catch-all: [[...param]] - if (seg.indexOf("[[...") === 0 && seg.charAt(seg.length - 1) === "]" && seg.charAt(seg.length - 2) === "]") { - var pn = seg.slice(5, -2); - var v = params[pn]; - // Skip empty optional catch-all (e.g., visiting /blog on [[...slug]] route) - if (Array.isArray(v) && v.length === 0) continue; - if (v == null) continue; - result.push(Array.isArray(v) ? v.join("/") : v); - // Catch-all: [...param] - } else if (seg.indexOf("[...") === 0 && seg.charAt(seg.length - 1) === "]") { - var pn2 = seg.slice(4, -1); - var v2 = params[pn2]; - result.push(Array.isArray(v2) ? v2.join("/") : (v2 || seg)); - // Dynamic: [param] - } else if (seg.charAt(0) === "[" && seg.charAt(seg.length - 1) === "]" && seg.indexOf(".") === -1) { - var pn3 = seg.slice(1, -1); - result.push(params[pn3] || seg); - } else { - result.push(seg); - } - } - return result; -} - // djb2 hash — matches Next.js's stringHash for digest generation. // Produces a stable numeric string from error message + stack. function __errorDigest(str) { @@ -9372,7 +8570,7 @@ async function renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, req makeThenableParams, matchedParams: opts?.matchedParams ?? route?.params ?? {}, requestUrl: request.url, - resolveChildSegments: __resolveChildSegments, + resolveChildSegments: __resolveAppPageChildSegments, rootForbiddenModule: rootForbiddenModule, rootLayouts: rootLayouts, rootNotFoundModule: rootNotFoundModule, @@ -9418,7 +8616,7 @@ async function renderErrorBoundaryPage(route, error, isRscRequest, request, matc makeThenableParams, matchedParams: matchedParams ?? route?.params ?? {}, requestUrl: request.url, - resolveChildSegments: __resolveChildSegments, + resolveChildSegments: __resolveAppPageChildSegments, rootLayouts: rootLayouts, route, renderToReadableStream, @@ -9584,12 +8782,10 @@ async function buildPageElement(route, params, opts, searchParams) { const resolvedMetadata = metadataList.length > 0 ? mergeMetadata(metadataList) : null; const resolvedViewport = mergeViewport(viewportList); - // Build nested layout tree from outermost to innermost. - // Next.js 16 passes params/searchParams as Promises (async pattern) - // but pre-16 code accesses them as plain objects (params.id). - // makeThenableParams() normalises null-prototype + preserves both patterns. - const asyncParams = makeThenableParams(params); - const pageProps = { params: asyncParams }; + // Build the route tree from the leaf page, then delegate the boundary/layout/ + // template/segment wiring to a typed runtime helper so the generated entry + // stays thin and the wiring logic can be unit tested directly. + const pageProps = { params: makeThenableParams(params) }; if (searchParams) { // Always provide searchParams prop when the URL object is available, even // when the query string is empty -- pages that do "await searchParams" need @@ -9605,184 +8801,25 @@ async function buildPageElement(route, params, opts, searchParams) { // dynamic, and this avoids false positives from React internals. if (hasSearchParams) markDynamicUsage(); } - let element = createElement(PageComponent, pageProps); - - // Wrap page with empty segment provider so useSelectedLayoutSegments() - // returns [] when called from inside a page component (leaf node). - element = createElement(LayoutSegmentProvider, { segmentMap: { children: [] } }, element); - - // Add metadata + viewport head tags (React 19 hoists title/meta/link to ) - // Next.js always injects charset and default viewport even when no metadata/viewport - // is exported. We replicate that by always emitting these essential head elements. - { - const headElements = []; - // Always emit — Next.js includes this on every page - headElements.push(createElement("meta", { charSet: "utf-8" })); - if (resolvedMetadata) headElements.push(createElement(MetadataHead, { metadata: resolvedMetadata })); - headElements.push(createElement(ViewportHead, { viewport: resolvedViewport })); - element = createElement(Fragment, null, ...headElements, element); - } - - // Wrap with loading.tsx Suspense if present - if (route.loading?.default) { - element = createElement( - Suspense, - { fallback: createElement(route.loading.default) }, - element, - ); - } - - // Wrap with the leaf's error.tsx ErrorBoundary if it's not already covered - // by a per-layout error boundary (i.e., the leaf has error.tsx but no layout). - // Per-layout error boundaries are interleaved with layouts below. - { - const lastLayoutError = route.errors ? route.errors[route.errors.length - 1] : null; - if (route.error?.default && route.error !== lastLayoutError) { - element = createElement(ErrorBoundary, { - fallback: route.error.default, - children: element, - }); - } - } - - // Wrap with NotFoundBoundary so client-side notFound() renders not-found.tsx - // instead of crashing the React tree. Must be above ErrorBoundary since - // ErrorBoundary re-throws notFound errors. - // Pre-render the not-found component as a React element since it may be a - // server component (not a client reference) and can't be passed as a function prop. - { - const NotFoundComponent = route.notFound?.default ?? null; - if (NotFoundComponent) { - element = createElement(NotFoundBoundary, { - fallback: createElement(NotFoundComponent), - children: element, - }); - } - } - - // Wrap with templates (innermost first, then outer) - // Templates are like layouts but re-mount on navigation (client-side concern). - // On the server, they just wrap the content like layouts do. - if (route.templates) { - for (let i = route.templates.length - 1; i >= 0; i--) { - const TemplateComponent = route.templates[i]?.default; - if (TemplateComponent) { - element = createElement(TemplateComponent, { children: element, params }); - } - } - } - - // Wrap with layouts (innermost first, then outer). - // At each layout level, first wrap with that level's error boundary (if any) - // so the boundary is inside the layout and catches errors from children. - // This matches Next.js behavior: Layout > ErrorBoundary > children. - // Parallel slots are passed as named props to the innermost layout - // (the layout at the same directory level as the page/slots) - for (let i = route.layouts.length - 1; i >= 0; i--) { - // Wrap with per-layout error boundary before wrapping with layout. - // This places the ErrorBoundary inside the layout, catching errors - // from child segments (matching Next.js per-segment error handling). - if (route.errors && route.errors[i]?.default) { - element = createElement(ErrorBoundary, { - fallback: route.errors[i].default, - children: element, - }); - } - - const LayoutComponent = route.layouts[i]?.default; - if (LayoutComponent) { - // Per-layout NotFoundBoundary: wraps this layout's children so that - // notFound() thrown from a child layout is caught here. - // Matches Next.js behavior where each segment has its own boundary. - // The boundary at level N catches errors from Layout[N+1] and below, - // but NOT from Layout[N] itself (which propagates to level N-1). - { - const LayoutNotFound = route.notFounds?.[i]?.default; - if (LayoutNotFound) { - element = createElement(NotFoundBoundary, { - fallback: createElement(LayoutNotFound), - children: element, - }); - } - } - - const layoutProps = { children: element, params: makeThenableParams(params) }; - - // Add parallel slot elements to the layout that defines them. - // Each slot has a layoutIndex indicating which layout it belongs to. - if (route.slots) { - for (const [slotName, slotMod] of Object.entries(route.slots)) { - // Attach slot to the layout at its layoutIndex, or to the innermost layout if -1 - const targetIdx = slotMod.layoutIndex >= 0 ? slotMod.layoutIndex : route.layouts.length - 1; - if (i !== targetIdx) continue; - // Check if this slot has an intercepting route that should activate - let SlotPage = null; - let slotParams = params; - - if (opts && opts.interceptSlot === slotName && opts.interceptPage) { - // Use the intercepting route's page component - SlotPage = opts.interceptPage.default; - slotParams = opts.interceptParams || params; - } else { - SlotPage = slotMod.page?.default || slotMod.default?.default; - } - - if (SlotPage) { - let slotElement = createElement(SlotPage, { params: makeThenableParams(slotParams) }); - // Wrap with slot-specific layout if present. - // In Next.js, @slot/layout.tsx wraps the slot's page content - // before it is passed as a prop to the parent layout. - const SlotLayout = slotMod.layout?.default; - if (SlotLayout) { - slotElement = createElement(SlotLayout, { - children: slotElement, - params: makeThenableParams(slotParams), - }); - } - // Wrap with slot-specific loading if present - if (slotMod.loading?.default) { - slotElement = createElement(Suspense, - { fallback: createElement(slotMod.loading.default) }, - slotElement, - ); - } - // Wrap with slot-specific error boundary if present - if (slotMod.error?.default) { - slotElement = createElement(ErrorBoundary, { - fallback: slotMod.error.default, - children: slotElement, - }); - } - layoutProps[slotName] = slotElement; + return __buildAppPageRouteElement({ + element: createElement(PageComponent, pageProps), + globalErrorModule: null, + makeThenableParams, + matchedParams: params, + resolvedMetadata, + resolvedViewport, + rootNotFoundModule: null, + route, + slotOverrides: + opts && opts.interceptSlot && opts.interceptPage + ? { + [opts.interceptSlot]: { + pageModule: opts.interceptPage, + params: opts.interceptParams || params, + }, } - } - } - - element = createElement(LayoutComponent, layoutProps); - - // Wrap the layout with LayoutSegmentProvider so useSelectedLayoutSegments() - // called INSIDE this layout gets the correct child segments. We resolve the - // route tree segments using actual param values and pass them through context. - // We wrap the layout (not just children) because hooks are called from - // components rendered inside the layout's own JSX. - const treePos = route.layoutTreePositions ? route.layoutTreePositions[i] : 0; - const childSegs = __resolveChildSegments(route.routeSegments || [], treePos, params); - element = createElement(LayoutSegmentProvider, { segmentMap: { children: childSegs } }, element); - } - } - - // Wrap with global error boundary if app/global-error.tsx exists. - // This must be present in both HTML and RSC paths so the component tree - // structure matches — otherwise React reconciliation on client-side navigation - // would see a mismatched tree and destroy/recreate the DOM. - // - // For RSC requests (client-side nav), this provides error recovery on the client. - // For HTML requests (initial page load), the ErrorBoundary catches during SSR - // but produces double / (root layout + global-error). The request - // handler detects this via the rscOnError flag and re-renders without layouts. - - - return element; + : null, + }); } @@ -11063,13 +10100,11 @@ function renderToReadableStream(model, options) { } })); } -import { createElement, Suspense, Fragment } from "react"; +import { createElement } from "react"; import { setNavigationContext as _setNavigationContextOrig, getNavigationContext as _getNavigationContext } from "next/navigation"; import { setHeadersContext, headersContextFromRequest, getDraftModeCookieHeader, getAndClearPendingCookies, consumeDynamicUsage, markDynamicUsage, applyMiddlewareRequestHeaders, getHeadersContext, setHeadersAccessPhase } from "next/headers"; import { NextRequest, NextFetchEvent } from "next/server"; -import { ErrorBoundary, NotFoundBoundary } from "vinext/error-boundary"; -import { LayoutSegmentProvider } from "vinext/layout-segment-context"; -import { MetadataHead, mergeMetadata, resolveModuleMetadata, ViewportHead, mergeViewport, resolveModuleViewport } from "vinext/metadata"; +import { mergeMetadata, resolveModuleMetadata, mergeViewport, resolveModuleViewport } from "vinext/metadata"; import * as middlewareModule from "/tmp/test/middleware.ts"; @@ -11101,6 +10136,10 @@ import { renderAppPageErrorBoundary as __renderAppPageErrorBoundary, renderAppPageHttpAccessFallback as __renderAppPageHttpAccessFallback, } from "/packages/vinext/src/server/app-page-boundary-render.js"; +import { + buildAppPageRouteElement as __buildAppPageRouteElement, + resolveAppPageChildSegments as __resolveAppPageChildSegments, +} from "/packages/vinext/src/server/app-page-route-wiring.js"; import { renderAppPageLifecycle as __renderAppPageLifecycle, } from "/packages/vinext/src/server/app-page-render.js"; @@ -11268,38 +10307,6 @@ function makeThenableParams(obj) { return Object.assign(Promise.resolve(plain), plain); } -// Resolve route tree segments to actual values using matched params. -// Dynamic segments like [id] are replaced with param values, catch-all -// segments like [...slug] are joined with "/", and route groups are kept as-is. -function __resolveChildSegments(routeSegments, treePosition, params) { - var raw = routeSegments.slice(treePosition); - var result = []; - for (var j = 0; j < raw.length; j++) { - var seg = raw[j]; - // Optional catch-all: [[...param]] - if (seg.indexOf("[[...") === 0 && seg.charAt(seg.length - 1) === "]" && seg.charAt(seg.length - 2) === "]") { - var pn = seg.slice(5, -2); - var v = params[pn]; - // Skip empty optional catch-all (e.g., visiting /blog on [[...slug]] route) - if (Array.isArray(v) && v.length === 0) continue; - if (v == null) continue; - result.push(Array.isArray(v) ? v.join("/") : v); - // Catch-all: [...param] - } else if (seg.indexOf("[...") === 0 && seg.charAt(seg.length - 1) === "]") { - var pn2 = seg.slice(4, -1); - var v2 = params[pn2]; - result.push(Array.isArray(v2) ? v2.join("/") : (v2 || seg)); - // Dynamic: [param] - } else if (seg.charAt(0) === "[" && seg.charAt(seg.length - 1) === "]" && seg.indexOf(".") === -1) { - var pn3 = seg.slice(1, -1); - result.push(params[pn3] || seg); - } else { - result.push(seg); - } - } - return result; -} - // djb2 hash — matches Next.js's stringHash for digest generation. // Produces a stable numeric string from error message + stack. function __errorDigest(str) { @@ -11566,7 +10573,7 @@ async function renderHTTPAccessFallbackPage(route, statusCode, isRscRequest, req makeThenableParams, matchedParams: opts?.matchedParams ?? route?.params ?? {}, requestUrl: request.url, - resolveChildSegments: __resolveChildSegments, + resolveChildSegments: __resolveAppPageChildSegments, rootForbiddenModule: rootForbiddenModule, rootLayouts: rootLayouts, rootNotFoundModule: rootNotFoundModule, @@ -11612,7 +10619,7 @@ async function renderErrorBoundaryPage(route, error, isRscRequest, request, matc makeThenableParams, matchedParams: matchedParams ?? route?.params ?? {}, requestUrl: request.url, - resolveChildSegments: __resolveChildSegments, + resolveChildSegments: __resolveAppPageChildSegments, rootLayouts: rootLayouts, route, renderToReadableStream, @@ -11778,12 +10785,10 @@ async function buildPageElement(route, params, opts, searchParams) { const resolvedMetadata = metadataList.length > 0 ? mergeMetadata(metadataList) : null; const resolvedViewport = mergeViewport(viewportList); - // Build nested layout tree from outermost to innermost. - // Next.js 16 passes params/searchParams as Promises (async pattern) - // but pre-16 code accesses them as plain objects (params.id). - // makeThenableParams() normalises null-prototype + preserves both patterns. - const asyncParams = makeThenableParams(params); - const pageProps = { params: asyncParams }; + // Build the route tree from the leaf page, then delegate the boundary/layout/ + // template/segment wiring to a typed runtime helper so the generated entry + // stays thin and the wiring logic can be unit tested directly. + const pageProps = { params: makeThenableParams(params) }; if (searchParams) { // Always provide searchParams prop when the URL object is available, even // when the query string is empty -- pages that do "await searchParams" need @@ -11799,184 +10804,25 @@ async function buildPageElement(route, params, opts, searchParams) { // dynamic, and this avoids false positives from React internals. if (hasSearchParams) markDynamicUsage(); } - let element = createElement(PageComponent, pageProps); - - // Wrap page with empty segment provider so useSelectedLayoutSegments() - // returns [] when called from inside a page component (leaf node). - element = createElement(LayoutSegmentProvider, { segmentMap: { children: [] } }, element); - - // Add metadata + viewport head tags (React 19 hoists title/meta/link to ) - // Next.js always injects charset and default viewport even when no metadata/viewport - // is exported. We replicate that by always emitting these essential head elements. - { - const headElements = []; - // Always emit — Next.js includes this on every page - headElements.push(createElement("meta", { charSet: "utf-8" })); - if (resolvedMetadata) headElements.push(createElement(MetadataHead, { metadata: resolvedMetadata })); - headElements.push(createElement(ViewportHead, { viewport: resolvedViewport })); - element = createElement(Fragment, null, ...headElements, element); - } - - // Wrap with loading.tsx Suspense if present - if (route.loading?.default) { - element = createElement( - Suspense, - { fallback: createElement(route.loading.default) }, - element, - ); - } - - // Wrap with the leaf's error.tsx ErrorBoundary if it's not already covered - // by a per-layout error boundary (i.e., the leaf has error.tsx but no layout). - // Per-layout error boundaries are interleaved with layouts below. - { - const lastLayoutError = route.errors ? route.errors[route.errors.length - 1] : null; - if (route.error?.default && route.error !== lastLayoutError) { - element = createElement(ErrorBoundary, { - fallback: route.error.default, - children: element, - }); - } - } - - // Wrap with NotFoundBoundary so client-side notFound() renders not-found.tsx - // instead of crashing the React tree. Must be above ErrorBoundary since - // ErrorBoundary re-throws notFound errors. - // Pre-render the not-found component as a React element since it may be a - // server component (not a client reference) and can't be passed as a function prop. - { - const NotFoundComponent = route.notFound?.default ?? null; - if (NotFoundComponent) { - element = createElement(NotFoundBoundary, { - fallback: createElement(NotFoundComponent), - children: element, - }); - } - } - - // Wrap with templates (innermost first, then outer) - // Templates are like layouts but re-mount on navigation (client-side concern). - // On the server, they just wrap the content like layouts do. - if (route.templates) { - for (let i = route.templates.length - 1; i >= 0; i--) { - const TemplateComponent = route.templates[i]?.default; - if (TemplateComponent) { - element = createElement(TemplateComponent, { children: element, params }); - } - } - } - - // Wrap with layouts (innermost first, then outer). - // At each layout level, first wrap with that level's error boundary (if any) - // so the boundary is inside the layout and catches errors from children. - // This matches Next.js behavior: Layout > ErrorBoundary > children. - // Parallel slots are passed as named props to the innermost layout - // (the layout at the same directory level as the page/slots) - for (let i = route.layouts.length - 1; i >= 0; i--) { - // Wrap with per-layout error boundary before wrapping with layout. - // This places the ErrorBoundary inside the layout, catching errors - // from child segments (matching Next.js per-segment error handling). - if (route.errors && route.errors[i]?.default) { - element = createElement(ErrorBoundary, { - fallback: route.errors[i].default, - children: element, - }); - } - - const LayoutComponent = route.layouts[i]?.default; - if (LayoutComponent) { - // Per-layout NotFoundBoundary: wraps this layout's children so that - // notFound() thrown from a child layout is caught here. - // Matches Next.js behavior where each segment has its own boundary. - // The boundary at level N catches errors from Layout[N+1] and below, - // but NOT from Layout[N] itself (which propagates to level N-1). - { - const LayoutNotFound = route.notFounds?.[i]?.default; - if (LayoutNotFound) { - element = createElement(NotFoundBoundary, { - fallback: createElement(LayoutNotFound), - children: element, - }); - } - } - - const layoutProps = { children: element, params: makeThenableParams(params) }; - - // Add parallel slot elements to the layout that defines them. - // Each slot has a layoutIndex indicating which layout it belongs to. - if (route.slots) { - for (const [slotName, slotMod] of Object.entries(route.slots)) { - // Attach slot to the layout at its layoutIndex, or to the innermost layout if -1 - const targetIdx = slotMod.layoutIndex >= 0 ? slotMod.layoutIndex : route.layouts.length - 1; - if (i !== targetIdx) continue; - // Check if this slot has an intercepting route that should activate - let SlotPage = null; - let slotParams = params; - - if (opts && opts.interceptSlot === slotName && opts.interceptPage) { - // Use the intercepting route's page component - SlotPage = opts.interceptPage.default; - slotParams = opts.interceptParams || params; - } else { - SlotPage = slotMod.page?.default || slotMod.default?.default; - } - - if (SlotPage) { - let slotElement = createElement(SlotPage, { params: makeThenableParams(slotParams) }); - // Wrap with slot-specific layout if present. - // In Next.js, @slot/layout.tsx wraps the slot's page content - // before it is passed as a prop to the parent layout. - const SlotLayout = slotMod.layout?.default; - if (SlotLayout) { - slotElement = createElement(SlotLayout, { - children: slotElement, - params: makeThenableParams(slotParams), - }); - } - // Wrap with slot-specific loading if present - if (slotMod.loading?.default) { - slotElement = createElement(Suspense, - { fallback: createElement(slotMod.loading.default) }, - slotElement, - ); - } - // Wrap with slot-specific error boundary if present - if (slotMod.error?.default) { - slotElement = createElement(ErrorBoundary, { - fallback: slotMod.error.default, - children: slotElement, - }); - } - layoutProps[slotName] = slotElement; + return __buildAppPageRouteElement({ + element: createElement(PageComponent, pageProps), + globalErrorModule: null, + makeThenableParams, + matchedParams: params, + resolvedMetadata, + resolvedViewport, + rootNotFoundModule: null, + route, + slotOverrides: + opts && opts.interceptSlot && opts.interceptPage + ? { + [opts.interceptSlot]: { + pageModule: opts.interceptPage, + params: opts.interceptParams || params, + }, } - } - } - - element = createElement(LayoutComponent, layoutProps); - - // Wrap the layout with LayoutSegmentProvider so useSelectedLayoutSegments() - // called INSIDE this layout gets the correct child segments. We resolve the - // route tree segments using actual param values and pass them through context. - // We wrap the layout (not just children) because hooks are called from - // components rendered inside the layout's own JSX. - const treePos = route.layoutTreePositions ? route.layoutTreePositions[i] : 0; - const childSegs = __resolveChildSegments(route.routeSegments || [], treePos, params); - element = createElement(LayoutSegmentProvider, { segmentMap: { children: childSegs } }, element); - } - } - - // Wrap with global error boundary if app/global-error.tsx exists. - // This must be present in both HTML and RSC paths so the component tree - // structure matches — otherwise React reconciliation on client-side navigation - // would see a mismatched tree and destroy/recreate the DOM. - // - // For RSC requests (client-side nav), this provides error recovery on the client. - // For HTML requests (initial page load), the ErrorBoundary catches during SSR - // but produces double / (root layout + global-error). The request - // handler detects this via the rscOnError flag and re-renders without layouts. - - - return element; + : null, + }); } diff --git a/tests/app-page-route-wiring.test.ts b/tests/app-page-route-wiring.test.ts new file mode 100644 index 000000000..0e3826922 --- /dev/null +++ b/tests/app-page-route-wiring.test.ts @@ -0,0 +1,152 @@ +import { createElement, isValidElement, type ReactNode } from "react"; +import ReactDOMServer from "react-dom/server"; +import { describe, expect, it } from "vite-plus/test"; +import { useSelectedLayoutSegments } from "../packages/vinext/src/shims/navigation.js"; +import { + buildAppPageRouteElement, + createAppPageLayoutEntries, + resolveAppPageChildSegments, +} from "../packages/vinext/src/server/app-page-route-wiring.js"; + +function readNode(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +function readChildren(value: unknown): ReactNode { + if ( + value === null || + value === undefined || + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ) { + return value; + } + + if (Array.isArray(value)) { + return value.map((item) => readChildren(item)); + } + + if (isValidElement(value)) { + return value; + } + + return null; +} + +function RootLayout(props: Record) { + const segments = useSelectedLayoutSegments(); + return createElement( + "div", + { + "data-layout": "root", + "data-segments": segments.join("|"), + }, + createElement("aside", { "data-slot": "sidebar" }, readChildren(props.sidebar)), + readChildren(props.children), + ); +} + +function GroupLayout(props: Record) { + const segments = useSelectedLayoutSegments(); + return createElement( + "section", + { + "data-layout": "group", + "data-segments": segments.join("|"), + }, + readChildren(props.children), + ); +} + +function SlotLayout(props: Record) { + return createElement("div", { "data-slot-layout": "sidebar" }, readChildren(props.children)); +} + +function SlotPage(props: Record) { + return createElement("p", { "data-slot-page": readNode(props.label) }, readNode(props.label)); +} + +function Template(props: Record) { + return createElement("div", { "data-template": "group" }, readChildren(props.children)); +} + +function PageProbe() { + const segments = useSelectedLayoutSegments(); + return createElement("main", { "data-page-segments": segments.join("|") }, "Page"); +} + +describe("app page route wiring helpers", () => { + it("resolves child segments from tree positions and preserves route groups", () => { + expect( + resolveAppPageChildSegments(["(marketing)", "blog", "[slug]", "[...parts]"], 1, { + parts: ["a", "b"], + slug: "post", + }), + ).toEqual(["blog", "post", "a/b"]); + }); + + it("builds layout entries from tree paths instead of visible URL segments", () => { + const entries = createAppPageLayoutEntries({ + layouts: [{ default: RootLayout }, { default: GroupLayout }], + layoutTreePositions: [0, 1], + notFounds: [null, null], + routeSegments: ["(marketing)", "blog", "[slug]"], + }); + + expect(entries.map((entry) => entry.id)).toEqual(["layout:/", "layout:/(marketing)"]); + expect(entries.map((entry) => entry.treePath)).toEqual(["/", "/(marketing)"]); + }); + + it("wires templates, slots, and layout segment providers from the route tree", () => { + const element = buildAppPageRouteElement({ + element: createElement(PageProbe), + makeThenableParams(params) { + return Promise.resolve(params); + }, + matchedParams: { slug: "post" }, + resolvedMetadata: null, + resolvedViewport: {}, + route: { + error: null, + errors: [null, null], + layoutTreePositions: [0, 1], + layouts: [{ default: RootLayout }, { default: GroupLayout }], + loading: null, + notFound: null, + notFounds: [null, null], + routeSegments: ["(marketing)", "blog", "[slug]"], + slots: { + sidebar: { + default: null, + error: null, + layout: { default: SlotLayout }, + layoutIndex: 0, + loading: null, + page: { default: SlotPage }, + }, + }, + templates: [{ default: Template }], + }, + rootNotFoundModule: null, + slotOverrides: { + sidebar: { + pageModule: { default: SlotPage }, + params: { slug: "post" }, + props: { label: "intercepted" }, + }, + }, + }); + + const html = ReactDOMServer.renderToStaticMarkup(element); + + expect(html).toContain('data-layout="root"'); + expect(html).toContain('data-layout="group"'); + expect(html).toContain('data-template="group"'); + expect(html).toContain('data-slot-layout="sidebar"'); + expect(html).toContain('data-slot-page="intercepted"'); + expect(html).toContain('data-page-segments=""'); + expect(html).toContain('data-segments="(marketing)|blog|post"'); + expect(html).toContain('data-segments="blog|post"'); + }); +}); diff --git a/tests/error-boundary.test.ts b/tests/error-boundary.test.ts index 3639bb9fc..c0a308acc 100644 --- a/tests/error-boundary.test.ts +++ b/tests/error-boundary.test.ts @@ -19,47 +19,72 @@ vi.mock("next/navigation", () => ({ })); // The error boundary is primarily a client-side component. +type ErrorBoundaryInnerConstructor = { + getDerivedStateFromError(error: Error): { + error: Error | null; + previousPathname: string; + }; + getDerivedStateFromProps( + props: { + children: React.ReactNode; + fallback: React.ComponentType<{ error: Error; reset: () => void }>; + pathname: string; + }, + state: { + error: Error | null; + previousPathname: string; + }, + ): { + error: Error | null; + previousPathname: string; + } | null; +}; + +function isErrorBoundaryInnerConstructor(value: unknown): value is ErrorBoundaryInnerConstructor { + return value !== null && typeof value === "function"; +} + +function createErrorWithDigest(message: string, digest: string) { + return Object.assign(new Error(message), { digest }); +} + // Test the digest detection patterns used by the boundaries describe("ErrorBoundary digest patterns", () => { it("NEXT_NOT_FOUND digest matches legacy not-found pattern", () => { - const error = new Error("Not Found"); - (error as any).digest = "NEXT_NOT_FOUND"; - - // The ErrorBoundary re-throws errors with these digests - const digest = (error as any).digest; - expect(digest === "NEXT_NOT_FOUND").toBe(true); + const error = createErrorWithDigest("Not Found", "NEXT_NOT_FOUND"); + expect(Reflect.get(error, "digest")).toBe("NEXT_NOT_FOUND"); }); it("NEXT_HTTP_ERROR_FALLBACK;404 matches new not-found pattern", () => { - const error = new Error("Not Found"); - (error as any).digest = "NEXT_HTTP_ERROR_FALLBACK;404"; + const digest = "NEXT_HTTP_ERROR_FALLBACK;404"; + const error = createErrorWithDigest("Not Found", digest); - const digest = (error as any).digest; + expect(Reflect.get(error, "digest")).toBe(digest); expect(digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")).toBe(true); expect(digest).toBe("NEXT_HTTP_ERROR_FALLBACK;404"); }); it("NEXT_HTTP_ERROR_FALLBACK;403 matches forbidden pattern", () => { - const error = new Error("Forbidden"); - (error as any).digest = "NEXT_HTTP_ERROR_FALLBACK;403"; + const digest = "NEXT_HTTP_ERROR_FALLBACK;403"; + const error = createErrorWithDigest("Forbidden", digest); - const digest = (error as any).digest; + expect(Reflect.get(error, "digest")).toBe(digest); expect(digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")).toBe(true); }); it("NEXT_HTTP_ERROR_FALLBACK;401 matches unauthorized pattern", () => { - const error = new Error("Unauthorized"); - (error as any).digest = "NEXT_HTTP_ERROR_FALLBACK;401"; + const digest = "NEXT_HTTP_ERROR_FALLBACK;401"; + const error = createErrorWithDigest("Unauthorized", digest); - const digest = (error as any).digest; + expect(Reflect.get(error, "digest")).toBe(digest); expect(digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")).toBe(true); }); it("NEXT_REDIRECT digest matches redirect pattern", () => { - const error = new Error("Redirect"); - (error as any).digest = "NEXT_REDIRECT;replace;/login;307;"; + const digest = "NEXT_REDIRECT;replace;/login;307;"; + const error = createErrorWithDigest("Redirect", digest); - const digest = (error as any).digest; + expect(Reflect.get(error, "digest")).toBe(digest); expect(digest.startsWith("NEXT_REDIRECT;")).toBe(true); }); @@ -70,12 +95,12 @@ describe("ErrorBoundary digest patterns", () => { }); it("errors with non-special digests are caught by ErrorBoundary", () => { - const error = new Error("Custom error"); - (error as any).digest = "SOME_CUSTOM_DIGEST"; + const digest = "SOME_CUSTOM_DIGEST"; + const error = createErrorWithDigest("Custom error", digest); - const digest = (error as any).digest; + expect(Reflect.get(error, "digest")).toBe(digest); // These should NOT be re-thrown — they should be caught - expect(digest === "NEXT_NOT_FOUND").toBe(false); + expect(digest).not.toBe("NEXT_NOT_FOUND"); expect(digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")).toBe(false); expect(digest.startsWith("NEXT_REDIRECT;")).toBe(false); }); @@ -85,53 +110,94 @@ describe("ErrorBoundary digest patterns", () => { // The real method THROWS for digest errors (re-throwing them past the boundary) // and returns { error } for regular errors (catching them). describe("ErrorBoundary digest classification (actual class)", () => { - let ErrorBoundary: any; + let ErrorBoundaryInnerClass: ErrorBoundaryInnerConstructor | null = null; + let ErrorBoundaryInner: ErrorBoundaryInnerConstructor | null = null; beforeAll(async () => { const mod = await import("../packages/vinext/src/shims/error-boundary.js"); - ErrorBoundary = mod.ErrorBoundary; + const maybeInner = Reflect.get(mod, "ErrorBoundaryInner"); + if (isErrorBoundaryInnerConstructor(maybeInner)) { + ErrorBoundaryInnerClass = maybeInner; + ErrorBoundaryInner = maybeInner; + } }); it("rethrows NEXT_NOT_FOUND", () => { const e = Object.assign(new Error(), { digest: "NEXT_NOT_FOUND" }); - expect(() => ErrorBoundary.getDerivedStateFromError(e)).toThrow(e); + expect(ErrorBoundaryInnerClass).not.toBeNull(); + expect(() => ErrorBoundaryInnerClass?.getDerivedStateFromError(e)).toThrow(e); }); it("rethrows NEXT_HTTP_ERROR_FALLBACK;404", () => { const e = Object.assign(new Error(), { digest: "NEXT_HTTP_ERROR_FALLBACK;404" }); - expect(() => ErrorBoundary.getDerivedStateFromError(e)).toThrow(e); + expect(ErrorBoundaryInnerClass).not.toBeNull(); + expect(() => ErrorBoundaryInnerClass?.getDerivedStateFromError(e)).toThrow(e); }); it("rethrows NEXT_HTTP_ERROR_FALLBACK;403", () => { const e = Object.assign(new Error(), { digest: "NEXT_HTTP_ERROR_FALLBACK;403" }); - expect(() => ErrorBoundary.getDerivedStateFromError(e)).toThrow(e); + expect(ErrorBoundaryInnerClass).not.toBeNull(); + expect(() => ErrorBoundaryInnerClass?.getDerivedStateFromError(e)).toThrow(e); }); it("rethrows NEXT_HTTP_ERROR_FALLBACK;401", () => { const e = Object.assign(new Error(), { digest: "NEXT_HTTP_ERROR_FALLBACK;401" }); - expect(() => ErrorBoundary.getDerivedStateFromError(e)).toThrow(e); + expect(ErrorBoundaryInnerClass).not.toBeNull(); + expect(() => ErrorBoundaryInnerClass?.getDerivedStateFromError(e)).toThrow(e); }); it("rethrows NEXT_REDIRECT", () => { const e = Object.assign(new Error(), { digest: "NEXT_REDIRECT;replace;/login;307;" }); - expect(() => ErrorBoundary.getDerivedStateFromError(e)).toThrow(e); + expect(ErrorBoundaryInnerClass).not.toBeNull(); + expect(() => ErrorBoundaryInnerClass?.getDerivedStateFromError(e)).toThrow(e); }); it("catches regular errors (no digest)", () => { const e = new Error("oops"); - const state = ErrorBoundary.getDerivedStateFromError(e); - expect(state).toEqual({ error: e }); + expect(ErrorBoundaryInnerClass).not.toBeNull(); + const state = ErrorBoundaryInnerClass?.getDerivedStateFromError(e); + expect(state).toMatchObject({ error: e }); }); it("catches errors with unknown digest", () => { const e = Object.assign(new Error(), { digest: "CUSTOM_ERROR" }); - const state = ErrorBoundary.getDerivedStateFromError(e); - expect(state).toEqual({ error: e }); + expect(ErrorBoundaryInnerClass).not.toBeNull(); + const state = ErrorBoundaryInnerClass?.getDerivedStateFromError(e); + expect(state).toMatchObject({ error: e }); }); it("catches errors with empty digest", () => { const e = Object.assign(new Error(), { digest: "" }); - const state = ErrorBoundary.getDerivedStateFromError(e); - expect(state).toEqual({ error: e }); + expect(ErrorBoundaryInnerClass).not.toBeNull(); + const state = ErrorBoundaryInnerClass?.getDerivedStateFromError(e); + expect(state).toMatchObject({ error: e }); + }); + + it("resets caught errors when the pathname changes", () => { + expect(ErrorBoundaryInner).not.toBeNull(); + if (!ErrorBoundaryInner) { + throw new Error("Expected ErrorBoundaryInner export"); + } + + function Fallback() { + return null; + } + + const state = ErrorBoundaryInner.getDerivedStateFromProps( + { + children: null, + fallback: Fallback, + pathname: "/next", + }, + { + error: new Error("stuck"), + previousPathname: "/previous", + }, + ); + + expect(state).toEqual({ + error: null, + previousPathname: "/next", + }); }); }); From 5d8525b9f72a9f5df0054cf2b46bfaf0c08c9a97 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Thu, 2 Apr 2026 12:33:43 +1100 Subject: [PATCH 02/32] Add slot client primitives --- packages/vinext/src/shims/slot.tsx | 78 +++++++++ tests/slot.test.ts | 265 +++++++++++++++++++++++++++++ 2 files changed, 343 insertions(+) create mode 100644 packages/vinext/src/shims/slot.tsx create mode 100644 tests/slot.test.ts diff --git a/packages/vinext/src/shims/slot.tsx b/packages/vinext/src/shims/slot.tsx new file mode 100644 index 000000000..19de9c528 --- /dev/null +++ b/packages/vinext/src/shims/slot.tsx @@ -0,0 +1,78 @@ +"use client"; + +import * as React from "react"; +import { notFound } from "./navigation.js"; + +type Elements = Record; + +const EMPTY_ELEMENTS_PROMISE = Promise.resolve({}); +const mergeCache = new WeakMap, WeakMap, Promise>>(); + +export const UNMATCHED_SLOT = Symbol.for("vinext.unmatchedSlot"); + +export const ElementsContext = React.createContext>(EMPTY_ELEMENTS_PROMISE); + +export const ChildrenContext = React.createContext(null); + +export const ParallelSlotsContext = React.createContext +> | null>(null); + +export function mergeElementsPromise( + prev: Promise, + next: Promise, +): Promise { + let nextCache = mergeCache.get(prev); + if (!nextCache) { + nextCache = new WeakMap(); + mergeCache.set(prev, nextCache); + } + + const cached = nextCache.get(next); + if (cached) { + return cached; + } + + const merged = Promise.all([prev, next]).then(([prevElements, nextElements]) => ({ + ...prevElements, + ...nextElements, + })); + nextCache.set(next, merged); + return merged; +} + +export function Slot({ + id, + children, + parallelSlots, +}: { + id: string; + children?: React.ReactNode; + parallelSlots?: Readonly>; +}) { + const elements = React.use(React.useContext(ElementsContext)); + + if (!(id in elements)) { + return null; + } + + const element = elements[id]; + if (element === UNMATCHED_SLOT) { + notFound(); + } + + return ( + + {element} + + ); +} + +export function Children() { + return React.useContext(ChildrenContext); +} + +export function ParallelSlot({ name }: { name: string }) { + const slots = React.useContext(ParallelSlotsContext); + return slots?.[name] ?? null; +} diff --git a/tests/slot.test.ts b/tests/slot.test.ts new file mode 100644 index 000000000..62d52b40a --- /dev/null +++ b/tests/slot.test.ts @@ -0,0 +1,265 @@ +import React, { Suspense } from "react"; +import { renderToReadableStream } from "react-dom/server.edge"; +import { describe, expect, it, vi } from "vite-plus/test"; + +vi.mock("next/navigation", () => ({ + usePathname: () => "/", +})); + +type Deferred = { + promise: Promise; + resolve: (value: T) => void; +}; + +function createContextProvider( + context: React.Context, + value: TValue, + child: React.ReactNode, +): React.ReactElement { + return React.createElement(context.Provider, { value }, child); +} + +function createDeferred(): Deferred { + let resolvePromise: ((value: T) => void) | undefined; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + if (!resolvePromise) { + throw new Error("Deferred promise resolver was not created"); + } + return { + promise, + resolve: resolvePromise, + }; +} + +async function readStream(stream: ReadableStream): Promise { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let text = ""; + + for (;;) { + const { done, value } = await reader.read(); + if (done) { + break; + } + text += decoder.decode(value, { stream: true }); + } + + return text + decoder.decode(); +} + +async function renderHtml(element: React.ReactElement): Promise { + const stream = await renderToReadableStream(element); + await stream.allReady; + return readStream(stream); +} + +describe("slot primitives", () => { + it("exports the client primitives", async () => { + const mod = await import("../packages/vinext/src/shims/slot.js"); + + expect(typeof mod.Slot).toBe("function"); + expect(typeof mod.Children).toBe("function"); + expect(typeof mod.ParallelSlot).toBe("function"); + expect(typeof mod.mergeElementsPromise).toBe("function"); + expect(mod.ElementsContext).toBeDefined(); + expect(mod.ChildrenContext).toBeDefined(); + expect(mod.ParallelSlotsContext).toBeDefined(); + expect(mod.UNMATCHED_SLOT).toBe(Symbol.for("vinext.unmatchedSlot")); + }); + + it("Children renders null outside a Slot provider", async () => { + const { Children } = await import("../packages/vinext/src/shims/slot.js"); + + const html = await renderHtml(React.createElement(Children)); + expect(html).toBe(""); + }); + + it("ParallelSlot renders null outside a Slot provider", async () => { + const { ParallelSlot } = await import("../packages/vinext/src/shims/slot.js"); + + const html = await renderHtml(React.createElement(ParallelSlot, { name: "modal" })); + expect(html).toBe(""); + }); + + it("Slot renders the matched element and provides children and parallel slots", async () => { + const mod = await import("../packages/vinext/src/shims/slot.js"); + + function LayoutShell(): React.ReactElement { + return React.createElement( + "div", + null, + React.createElement("main", null, React.createElement(mod.Children)), + React.createElement( + "aside", + null, + React.createElement(mod.ParallelSlot, { name: "modal" }), + ), + ); + } + + const slotElement = createContextProvider( + mod.ElementsContext, + Promise.resolve({ "layout:/": React.createElement(LayoutShell) }), + React.createElement( + mod.Slot, + { + id: "layout:/", + parallelSlots: { + modal: React.createElement("em", null, "modal content"), + }, + }, + React.createElement("span", null, "child content"), + ), + ); + + const html = await renderHtml(slotElement); + expect(html).toContain("child content"); + expect(html).toContain("modal content"); + }); + + it("Slot returns null when the entry is absent", async () => { + const mod = await import("../packages/vinext/src/shims/slot.js"); + + const html = await renderHtml( + createContextProvider( + mod.ElementsContext, + Promise.resolve({}), + React.createElement(mod.Slot, { id: "slot:modal:/" }), + ), + ); + + expect(html).toBe(""); + }); + + it("Slot throws the notFound signal for an unmatched slot sentinel", async () => { + const mod = await import("../packages/vinext/src/shims/slot.js"); + const renderPromise = renderHtml( + createContextProvider( + mod.ElementsContext, + Promise.resolve({ "slot:modal:/": mod.UNMATCHED_SLOT }), + React.createElement(mod.Slot, { id: "slot:modal:/" }), + ), + ); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + + try { + await expect(renderPromise).rejects.toMatchObject({ digest: "NEXT_HTTP_ERROR_FALLBACK;404" }); + } finally { + consoleError.mockRestore(); + } + }); + + it("Slot renders a present null entry without triggering notFound", async () => { + const mod = await import("../packages/vinext/src/shims/slot.js"); + const errors: Error[] = []; + + const stream = await renderToReadableStream( + createContextProvider( + mod.ElementsContext, + Promise.resolve({ "slot:modal:/": null }), + React.createElement(mod.Slot, { id: "slot:modal:/" }), + ), + { + onError(error: unknown) { + if (error instanceof Error) { + errors.push(error); + } + }, + }, + ); + + await stream.allReady; + const html = await readStream(stream); + + expect(html).toBe(""); + expect(errors).toEqual([]); + }); + + it("mergeElementsPromise shallow-merges previous and next elements", async () => { + const { mergeElementsPromise } = await import("../packages/vinext/src/shims/slot.js"); + + const merged = await mergeElementsPromise( + Promise.resolve({ + "layout:/": React.createElement("div", null, "layout"), + "slot:modal:/": React.createElement("div", null, "previous slot"), + }), + Promise.resolve({ + "page:/blog/hello": React.createElement("div", null, "page"), + "slot:modal:/": React.createElement("div", null, "next slot"), + }), + ); + + expect(Object.keys(merged)).toEqual(["layout:/", "slot:modal:/", "page:/blog/hello"]); + expect(merged["layout:/"]).toBeDefined(); + expect(merged["page:/blog/hello"]).toBeDefined(); + expect(merged["slot:modal:/"]).not.toBeNull(); + }); + + it("mergeElementsPromise caches by input promise pair", async () => { + const { mergeElementsPromise } = await import("../packages/vinext/src/shims/slot.js"); + + const previous = Promise.resolve({ "layout:/": React.createElement("div", null, "layout") }); + const next = Promise.resolve({ "page:/blog/hello": React.createElement("div", null, "page") }); + + const first = mergeElementsPromise(previous, next); + const second = mergeElementsPromise(previous, next); + const third = mergeElementsPromise(previous, Promise.resolve({})); + + expect(first).toBe(second); + expect(first).not.toBe(third); + }); + + it("Slot suspends on the elements promise and streams the Suspense fallback first", async () => { + const mod = await import("../packages/vinext/src/shims/slot.js"); + const deferred = createDeferred>>(); + + const stream = await renderToReadableStream( + React.createElement( + Suspense, + { fallback: React.createElement("p", null, "loading slot") }, + createContextProvider( + mod.ElementsContext, + deferred.promise, + React.createElement(mod.Slot, { id: "layout:/" }), + ), + ), + ); + + const reader = stream.getReader(); + const decoder = new TextDecoder(); + const firstChunkPromise = reader.read(); + const firstReadState = await Promise.race([ + firstChunkPromise.then(() => "resolved"), + Promise.resolve("pending"), + ]); + + expect(firstReadState).toBe("pending"); + + const resolvedPromise = new Promise((resolve) => { + setTimeout(() => { + deferred.resolve({ + "layout:/": React.createElement("div", null, "resolved slot"), + }); + resolve(); + }, 20); + }); + + const firstChunk = await firstChunkPromise; + const firstHtml = decoder.decode(firstChunk.value, { stream: true }); + await resolvedPromise; + + let rest = ""; + for (;;) { + const { done, value } = await reader.read(); + if (done) { + break; + } + rest += decoder.decode(value, { stream: true }); + } + rest += decoder.decode(); + + expect(firstHtml + rest).toContain("resolved slot"); + }, 10000); +}); From be33773470b44b3767e6f3b2ed4aecceefa075b4 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Thu, 2 Apr 2026 12:42:18 +1100 Subject: [PATCH 03/32] Fix app page error boundary serialization --- .../src/server/app-page-route-wiring.tsx | 83 +++++++++++-------- 1 file changed, 50 insertions(+), 33 deletions(-) diff --git a/packages/vinext/src/server/app-page-route-wiring.tsx b/packages/vinext/src/server/app-page-route-wiring.tsx index 936826612..8936afd29 100644 --- a/packages/vinext/src/server/app-page-route-wiring.tsx +++ b/packages/vinext/src/server/app-page-route-wiring.tsx @@ -12,31 +12,41 @@ type AppPageComponentProps = { } & Record; type AppPageComponent = ComponentType; -type ErrorBoundaryFallbackComponent = ComponentType<{ error: Error; reset: () => void }>; +type AppPageErrorComponent = ComponentType<{ error: Error; reset: () => void }>; export type AppPageModule = Record & { default?: AppPageComponent | null | undefined; }; -export type AppPageRouteWiringSlot = { +export type AppPageErrorModule = Record & { + default?: AppPageErrorComponent | null | undefined; +}; + +export type AppPageRouteWiringSlot< + TModule extends AppPageModule = AppPageModule, + TErrorModule extends AppPageErrorModule = AppPageErrorModule, +> = { default?: TModule | null; - error?: TModule | null; + error?: TErrorModule | null; layout?: TModule | null; layoutIndex: number; loading?: TModule | null; page?: TModule | null; }; -export type AppPageRouteWiringRoute = { - error?: TModule | null; - errors?: readonly (TModule | null | undefined)[] | null; +export type AppPageRouteWiringRoute< + TModule extends AppPageModule = AppPageModule, + TErrorModule extends AppPageErrorModule = AppPageErrorModule, +> = { + error?: TErrorModule | null; + errors?: readonly (TErrorModule | null | undefined)[] | null; layoutTreePositions?: readonly number[] | null; layouts: readonly (TModule | null | undefined)[]; loading?: TModule | null; notFound?: TModule | null; notFounds?: readonly (TModule | null | undefined)[] | null; routeSegments?: readonly string[]; - slots?: Readonly>> | null; + slots?: Readonly>> | null; templates?: readonly (TModule | null | undefined)[] | null; }; @@ -46,8 +56,11 @@ export type AppPageSlotOverride = props?: Readonly>; }; -export type AppPageLayoutEntry = { - errorModule?: TModule | null | undefined; +export type AppPageLayoutEntry< + TModule extends AppPageModule = AppPageModule, + TErrorModule extends AppPageErrorModule = AppPageErrorModule, +> = { + errorModule?: TErrorModule | null | undefined; id: string; layoutModule?: TModule | null | undefined; notFoundModule?: TModule | null | undefined; @@ -55,15 +68,18 @@ export type AppPageLayoutEntry = treePosition: number; }; -export type BuildAppPageRouteElementOptions = { +export type BuildAppPageRouteElementOptions< + TModule extends AppPageModule = AppPageModule, + TErrorModule extends AppPageErrorModule = AppPageErrorModule, +> = { element: ReactNode; - globalErrorModule?: TModule | null; + globalErrorModule?: TErrorModule | null; makeThenableParams: (params: AppPageParams) => unknown; matchedParams: AppPageParams; resolvedMetadata: Metadata | null; resolvedViewport: Viewport; rootNotFoundModule?: TModule | null; - route: AppPageRouteWiringRoute; + route: AppPageRouteWiringRoute; slotOverrides?: Readonly>> | null; }; @@ -73,13 +89,10 @@ function getDefaultExport( return module?.default ?? null; } -function wrapWithErrorBoundary(fallback: AppPageComponent, children: ReactNode): ReactNode { - const FallbackBoundary: ErrorBoundaryFallbackComponent = ({ error, reset }) => { - const FallbackComponent = fallback; - return ; - }; - - return {children}; +function getErrorBoundaryExport( + module: TModule | null | undefined, +): AppPageErrorComponent | null { + return module?.default ?? null; } export function createAppPageTreePath( @@ -93,12 +106,15 @@ export function createAppPageTreePath( return `/${treePathSegments.join("/")}`; } -export function createAppPageLayoutEntries( +export function createAppPageLayoutEntries< + TModule extends AppPageModule, + TErrorModule extends AppPageErrorModule, +>( route: Pick< - AppPageRouteWiringRoute, + AppPageRouteWiringRoute, "errors" | "layoutTreePositions" | "layouts" | "notFounds" | "routeSegments" >, -): AppPageLayoutEntry[] { +): AppPageLayoutEntry[] { return route.layouts.map((layoutModule, index) => { const treePosition = route.layoutTreePositions?.[index] ?? 0; const treePath = createAppPageTreePath(route.routeSegments, treePosition); @@ -165,9 +181,10 @@ export function resolveAppPageChildSegments( return resolvedSegments; } -export function buildAppPageRouteElement( - options: BuildAppPageRouteElementOptions, -): ReactNode { +export function buildAppPageRouteElement< + TModule extends AppPageModule, + TErrorModule extends AppPageErrorModule, +>(options: BuildAppPageRouteElementOptions): ReactNode { let element: ReactNode = ( {options.element} ); @@ -191,9 +208,9 @@ export function buildAppPageRouteElement( options.route.errors && options.route.errors.length > 0 ? options.route.errors[options.route.errors.length - 1] : null; - const pageErrorComponent = getDefaultExport(options.route.error); + const pageErrorComponent = getErrorBoundaryExport(options.route.error); if (pageErrorComponent && options.route.error !== lastLayoutErrorModule) { - element = wrapWithErrorBoundary(pageErrorComponent, element); + element = {element}; } const notFoundComponent = @@ -219,9 +236,9 @@ export function buildAppPageRouteElement( for (let index = layoutEntries.length - 1; index >= 0; index--) { const layoutEntry = layoutEntries[index]; - const layoutErrorComponent = getDefaultExport(layoutEntry.errorModule); + const layoutErrorComponent = getErrorBoundaryExport(layoutEntry.errorModule); if (layoutErrorComponent) { - element = wrapWithErrorBoundary(layoutErrorComponent, element); + element = {element}; } const layoutComponent = getDefaultExport(layoutEntry.layoutModule); @@ -283,9 +300,9 @@ export function buildAppPageRouteElement( slotElement = }>{slotElement}; } - const slotErrorComponent = getDefaultExport(slot.error); + const slotErrorComponent = getErrorBoundaryExport(slot.error); if (slotErrorComponent) { - slotElement = wrapWithErrorBoundary(slotErrorComponent, slotElement); + slotElement = {slotElement}; } layoutProps[slotName] = slotElement; @@ -308,9 +325,9 @@ export function buildAppPageRouteElement( ); } - const globalErrorComponent = getDefaultExport(options.globalErrorModule); + const globalErrorComponent = getErrorBoundaryExport(options.globalErrorModule); if (globalErrorComponent) { - element = wrapWithErrorBoundary(globalErrorComponent, element); + element = {element}; } return element; From ca40d05f70e9fad6b5e557d5233cbf020573a081 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Thu, 2 Apr 2026 12:55:24 +1100 Subject: [PATCH 04/32] Fix client error boundary pathname reset --- packages/vinext/src/shims/error-boundary.tsx | 4 +-- tests/error-boundary.test.ts | 35 ++++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/packages/vinext/src/shims/error-boundary.tsx b/packages/vinext/src/shims/error-boundary.tsx index b7eb76fd5..cadcbdb92 100644 --- a/packages/vinext/src/shims/error-boundary.tsx +++ b/packages/vinext/src/shims/error-boundary.tsx @@ -42,7 +42,7 @@ export class ErrorBoundaryInner extends React.Component< return { error: state.error, previousPathname: props.pathname }; } - static getDerivedStateFromError(error: Error): ErrorBoundaryState { + static getDerivedStateFromError(error: Error): Partial { // notFound(), forbidden(), unauthorized(), and redirect() must propagate // past error boundaries. Re-throw them so they bubble up to the // framework's HTTP access fallback / redirect handler. @@ -56,7 +56,7 @@ export class ErrorBoundaryInner extends React.Component< throw error; } } - return { error, previousPathname: "" }; + return { error }; } reset = () => { diff --git a/tests/error-boundary.test.ts b/tests/error-boundary.test.ts index c0a308acc..8e5958fb9 100644 --- a/tests/error-boundary.test.ts +++ b/tests/error-boundary.test.ts @@ -200,4 +200,39 @@ describe("ErrorBoundary digest classification (actual class)", () => { previousPathname: "/next", }); }); + + it("does not immediately clear a caught error on the same pathname", () => { + expect(ErrorBoundaryInner).not.toBeNull(); + if (!ErrorBoundaryInner) { + throw new Error("Expected ErrorBoundaryInner export"); + } + + const error = new Error("stuck"); + const baseState = { + error: null, + previousPathname: "/error-test", + }; + const stateAfterError = { + ...baseState, + ...ErrorBoundaryInner.getDerivedStateFromError(error), + }; + + function Fallback() { + return null; + } + + const stateAfterProps = ErrorBoundaryInner.getDerivedStateFromProps( + { + children: null, + fallback: Fallback, + pathname: "/error-test", + }, + stateAfterError, + ); + + expect(stateAfterProps).toEqual({ + error, + previousPathname: "/error-test", + }); + }); }); From bddda39ac3ee2504bd99df2b71248024b5d2efe8 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Thu, 2 Apr 2026 13:13:04 +1100 Subject: [PATCH 05/32] Document Next.js error boundary verification --- tests/error-boundary.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/error-boundary.test.ts b/tests/error-boundary.test.ts index 8e5958fb9..65fcb3fb5 100644 --- a/tests/error-boundary.test.ts +++ b/tests/error-boundary.test.ts @@ -18,6 +18,14 @@ vi.mock("next/navigation", () => ({ usePathname: () => "/", })); // The error boundary is primarily a client-side component. +// +// Verified against Next.js source: +// - packages/next/src/client/components/error-boundary.tsx +// - packages/next/src/client/components/navigation.ts +// +// Next.js resets segment error boundaries on pathname changes using a +// previousPathname field, and usePathname() is pathname-only rather than +// query-aware. These tests lock our shim to that behavior. type ErrorBoundaryInnerConstructor = { getDerivedStateFromError(error: Error): { From d488978d6505d079f8cbc05793561a1625fa8e28 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Thu, 2 Apr 2026 13:55:20 +1100 Subject: [PATCH 06/32] Implement flat App Router payload for layout persistence --- packages/vinext/src/entries/app-rsc-entry.ts | 37 ++- packages/vinext/src/routing/app-router.ts | 5 + .../vinext/src/server/app-browser-entry.ts | 273 ++++++++++------ .../vinext/src/server/app-browser-state.ts | 124 +++++++ packages/vinext/src/server/app-elements.ts | 46 +++ .../src/server/app-page-boundary-render.ts | 63 +++- .../src/server/app-page-route-wiring.tsx | 309 ++++++++++++++++++ packages/vinext/src/server/app-ssr-entry.ts | 23 +- packages/vinext/src/shims/slot.tsx | 20 +- .../entry-templates.test.ts.snap | 240 +++++++++++--- tests/app-browser-entry.test.ts | 163 +++++++++ tests/app-elements.test.ts | 68 ++++ tests/app-page-boundary-render.test.ts | 81 ++++- tests/app-page-route-wiring.test.ts | 53 +++ tests/app-router.test.ts | 18 + tests/entry-templates.test.ts | 4 + tests/slot.test.ts | 14 + 17 files changed, 1373 insertions(+), 168 deletions(-) create mode 100644 packages/vinext/src/server/app-browser-state.ts create mode 100644 packages/vinext/src/server/app-elements.ts create mode 100644 tests/app-browser-entry.test.ts create mode 100644 tests/app-elements.test.ts diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index 52b21e0ba..fe18feddf 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -210,6 +210,7 @@ ${interceptEntries.join(",\n")} routeHandler: ${route.routePath ? getImportVar(route.routePath) : "null"}, layouts: [${layoutVars.join(", ")}], routeSegments: ${JSON.stringify(route.routeSegments)}, + templateTreePositions: ${JSON.stringify(route.templateTreePositions)}, layoutTreePositions: ${JSON.stringify(route.layoutTreePositions)}, templates: [${templateVars.join(", ")}], errors: [${layoutErrorVars.join(", ")}], @@ -378,7 +379,7 @@ import { renderAppPageHttpAccessFallback as __renderAppPageHttpAccessFallback, } from ${JSON.stringify(appPageBoundaryRenderPath)}; import { - buildAppPageRouteElement as __buildAppPageRouteElement, + buildAppPageElements as __buildAppPageElements, resolveAppPageChildSegments as __resolveAppPageChildSegments, } from ${JSON.stringify(appPageRouteWiringPath)}; import { @@ -881,7 +882,7 @@ function findIntercept(pathname) { return null; } -async function buildPageElement(route, params, opts, searchParams) { +async function buildPageElements(route, params, routePath, opts, searchParams) { const PageComponent = route.page?.default; if (!PageComponent) { return createElement("div", null, "Page has no default export"); @@ -982,13 +983,13 @@ async function buildPageElement(route, params, opts, searchParams) { // dynamic, and this avoids false positives from React internals. if (hasSearchParams) markDynamicUsage(); } - return __buildAppPageRouteElement({ + return __buildAppPageElements({ element: createElement(PageComponent, pageProps), - globalErrorModule: ${globalErrorVar ? globalErrorVar : "null"}, makeThenableParams, matchedParams: params, resolvedMetadata, resolvedViewport, + routePath, rootNotFoundModule: ${rootNotFoundVar ? rootNotFoundVar : "null"}, route, slotOverrides: @@ -1701,7 +1702,13 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { searchParams: url.searchParams, params: actionParams, }); - element = buildPageElement(actionRoute, actionParams, undefined, url.searchParams); + element = buildPageElements( + actionRoute, + actionParams, + cleanPathname, + undefined, + url.searchParams, + ); } else { element = createElement("div", null, "Page not found"); } @@ -2055,7 +2062,13 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { return _runWithUnifiedCtx(__revalUCtx, async () => { _ensureFetchPatch(); setNavigationContext({ pathname: cleanPathname, searchParams: new URLSearchParams(), params }); - const __revalElement = await buildPageElement(route, params, undefined, new URLSearchParams()); + const __revalElement = await buildPageElements( + route, + params, + cleanPathname, + undefined, + new URLSearchParams(), + ); const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); const __revalRscCapture = __teeAppPageRscStreamForCapture(__revalRscStream, true); @@ -2104,7 +2117,15 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // If the target URL matches an intercepting route in a parallel slot, // render the source route with the intercepting page in the slot. const __interceptResult = await __resolveAppPageIntercept({ - buildPageElement, + buildPageElement(interceptRoute, interceptParams, interceptOpts, interceptSearchParams) { + return buildPageElements( + interceptRoute, + interceptParams, + cleanPathname, + interceptOpts, + interceptSearchParams, + ); + }, cleanPathname, currentRoute: route, findIntercept, @@ -2152,7 +2173,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const __pageBuildResult = await __buildAppPageElement({ buildPageElement() { - return buildPageElement(route, params, interceptOpts, url.searchParams); + return buildPageElements(route, params, cleanPathname, interceptOpts, url.searchParams); }, renderErrorBoundaryPage(buildErr) { return renderErrorBoundaryPage(route, buildErr, isRscRequest, request, params); diff --git a/packages/vinext/src/routing/app-router.ts b/packages/vinext/src/routing/app-router.ts index c9142a36a..7e85341fe 100644 --- a/packages/vinext/src/routing/app-router.ts +++ b/packages/vinext/src/routing/app-router.ts @@ -104,6 +104,8 @@ export type AppRoute = { * Used at render time to compute the child segments for useSelectedLayoutSegments(). */ routeSegments: string[]; + /** Tree position (directory depth from app/ root) for each template. */ + templateTreePositions?: number[]; /** * Tree position (directory depth from app/ root) for each layout. * Used to slice routeSegments and determine which segments are below each layout. @@ -327,6 +329,7 @@ function discoverSlotSubRoutes( forbiddenPath: parentRoute.forbiddenPath, unauthorizedPath: parentRoute.unauthorizedPath, routeSegments: [...parentRoute.routeSegments, ...rawSegments], + templateTreePositions: parentRoute.templateTreePositions, layoutTreePositions: parentRoute.layoutTreePositions, isDynamic: parentRoute.isDynamic || subIsDynamic, params: [...parentRoute.params, ...subParams], @@ -405,6 +408,7 @@ function fileToAppRoute( // Discover layouts and templates from root to leaf const layouts = discoverLayouts(segments, appDir, matcher); const templates = discoverTemplates(segments, appDir, matcher); + const templateTreePositions = computeLayoutTreePositions(appDir, templates); // Compute the tree position (directory depth) for each layout. const layoutTreePositions = computeLayoutTreePositions(appDir, layouts); @@ -449,6 +453,7 @@ function fileToAppRoute( forbiddenPath, unauthorizedPath, routeSegments: segments, + templateTreePositions, layoutTreePositions, isDynamic, params, diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index dd74e35e8..49c3bb15f 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -5,10 +5,9 @@ import { startTransition, use, useLayoutEffect, - useState, + useReducer, type Dispatch, type ReactNode, - type SetStateAction, } from "react"; import { createFromFetch, @@ -46,22 +45,31 @@ import { createProgressiveRscStream, getVinextBrowserGlobal, } from "./app-browser-stream.js"; +import { + normalizeAppElements, + readAppElementsMetadata, + type AppElements, + type AppWireElements, +} from "./app-elements.js"; +import { + createPendingNavigationCommit, + routerReducer, + shouldHardNavigate, + type AppRouterAction, + type AppRouterState, +} from "./app-browser-state.js"; +import { ElementsContext, Slot } from "../shims/slot.js"; type SearchParamInput = ConstructorParameters[0]; type ServerActionResult = { - root: ReactNode; + root: AppWireElements; returnValue?: { ok: boolean; data: unknown; }; }; -type BrowserTreeState = { - renderId: number; - node: ReactNode; - navigationSnapshot: ClientNavigationRenderSnapshot; -}; type NavigationKind = "navigate" | "traverse" | "refresh"; type HistoryUpdateMode = "push" | "replace"; type VisitedResponseCacheEntry = { @@ -89,7 +97,8 @@ let nextNavigationRenderId = 0; let activeNavigationId = 0; const pendingNavigationCommits = new Map void>(); const pendingNavigationPrePaintEffects = new Map void>(); -let setBrowserTreeState: Dispatch> | null = null; +let dispatchBrowserRouterAction: Dispatch | null = null; +let readBrowserRouterState: (() => AppRouterState) | null = null; let latestClientParams: Record = {}; const visitedResponseCache = new Map(); @@ -97,11 +106,18 @@ function isServerActionResult(value: unknown): value is ServerActionResult { return !!value && typeof value === "object" && "root" in value; } -function getBrowserTreeStateSetter(): Dispatch> { - if (!setBrowserTreeState) { - throw new Error("[vinext] Browser tree state is not initialized"); +function getBrowserRouterDispatch(): Dispatch { + if (!dispatchBrowserRouterAction) { + throw new Error("[vinext] Browser router dispatch is not initialized"); + } + return dispatchBrowserRouterAction; +} + +function getBrowserRouterState(): AppRouterState { + if (!readBrowserRouterState) { + throw new Error("[vinext] Browser router state is not initialized"); } - return setBrowserTreeState; + return readBrowserRouterState(); } function applyClientParams(params: Record): void { @@ -171,9 +187,11 @@ function drainPrePaintEffects(upToRenderId: number): void { function createNavigationCommitEffect( href: string, historyUpdateMode: HistoryUpdateMode | undefined, + params: Record, ): () => void { return () => { const targetHref = new URL(href, window.location.origin).href; + stageClientParams(params); if (historyUpdateMode === "replace" && window.location.href !== targetHref) { replaceHistoryStateWithoutNotify(null, "", href); @@ -286,34 +304,46 @@ function NavigationCommitSignal({ return children; } +function normalizeAppElementsPromise(payload: Promise): Promise { + return payload.then((elements) => normalizeAppElements(elements)); +} + function BrowserRoot({ - initialNode, + initialElements, initialNavigationSnapshot, }: { - initialNode: ReactNode | Promise; + initialElements: Promise; initialNavigationSnapshot: ClientNavigationRenderSnapshot; }) { - const resolvedNode = use(initialNode as Promise); - const [treeState, setTreeState] = useState({ - renderId: 0, - node: resolvedNode, + const resolvedElements = use(initialElements); + const initialMetadata = readAppElementsMetadata(resolvedElements); + const [treeState, dispatchTreeState] = useReducer(routerReducer, { + elements: Promise.resolve(resolvedElements), navigationSnapshot: initialNavigationSnapshot, + renderId: 0, + rootLayoutTreePath: initialMetadata.rootLayoutTreePath, + routeId: initialMetadata.routeId, }); // Assign the module-level setter via useLayoutEffect instead of during render // to avoid side effects that React Strict Mode / concurrent features may // call multiple times. useLayoutEffect fires synchronously during commit, - // before hydrateRoot returns to main(), so setBrowserTreeState is available - // before __VINEXT_RSC_NAVIGATE__ is assigned. setTreeState is referentially + // before hydrateRoot returns to main(), so the router dispatch is available + // before __VINEXT_RSC_NAVIGATE__ is assigned. dispatchTreeState is referentially // stable so the effect only runs on mount. useLayoutEffect(() => { - setBrowserTreeState = setTreeState; - }, []); // eslint-disable-line react-hooks/exhaustive-deps -- setTreeState is referentially stable + dispatchBrowserRouterAction = dispatchTreeState; + readBrowserRouterState = () => treeState; + }, [dispatchTreeState, treeState]); const committedTree = createElement( NavigationCommitSignal, { renderId: treeState.renderId }, - treeState.node, + createElement( + ElementsContext.Provider, + { value: treeState.elements }, + createElement(Slot, { id: treeState.routeId }), + ), ); const ClientNavigationRenderContext = getClientNavigationRenderContext(); @@ -328,18 +358,17 @@ function BrowserRoot({ ); } -function updateBrowserTree( - node: ReactNode | Promise, +function dispatchBrowserTree( + elements: Promise, navigationSnapshot: ClientNavigationRenderSnapshot, renderId: number, + actionType: "navigate" | "replace", + routeId: string, + rootLayoutTreePath: string | null, useTransitionMode: boolean, snapshotActivated = false, ): void { - const setter = getBrowserTreeStateSetter(); - - const resolvedThenSet = (resolvedNode: ReactNode) => { - setter({ renderId, node: resolvedNode, navigationSnapshot }); - }; + const dispatch = getBrowserRouterDispatch(); // Balance the activate/commit pairing if the async payload rejects after // activateNavigationSnapshot() was called. Only decrement when snapshotActivated @@ -356,47 +385,68 @@ function updateBrowserTree( resolve?.(); }; - if (node != null && typeof (node as PromiseLike).then === "function") { - const thenable = node as PromiseLike; + const applyAction = () => + dispatch({ + elements, + navigationSnapshot, + renderId, + rootLayoutTreePath, + routeId, + type: actionType, + }); + + void elements.then(() => { if (useTransitionMode) { - void thenable.then( - (resolved) => startTransition(() => resolvedThenSet(resolved)), - handleAsyncError, - ); + startTransition(applyAction); } else { - void thenable.then(resolvedThenSet, handleAsyncError); + applyAction(); } - return; - } - - const syncNode = node as ReactNode; - if (useTransitionMode) { - startTransition(() => resolvedThenSet(syncNode)); - return; - } - - resolvedThenSet(syncNode); + }, handleAsyncError); } -function renderNavigationPayload( - payload: Promise | ReactNode, +async function renderNavigationPayload( + payload: Promise, navigationSnapshot: ClientNavigationRenderSnapshot, + targetHref: string, prePaintEffect: (() => void) | null = null, useTransition = true, + actionType: "navigate" | "replace" = "navigate", ): Promise { const renderId = ++nextNavigationRenderId; - queuePrePaintNavigationEffect(renderId, prePaintEffect); - const committed = new Promise((resolve) => { pendingNavigationCommits.set(renderId, resolve); }); - activateNavigationSnapshot(); - // Wrap updateBrowserTree in try-catch to ensure counter is decremented // if a synchronous error occurs before the async promise chain is established. try { - updateBrowserTree(payload, navigationSnapshot, renderId, useTransition, true); + const currentState = getBrowserRouterState(); + const pending = await createPendingNavigationCommit({ + currentState, + nextElements: payload, + navigationSnapshot, + renderId, + type: actionType, + }); + + if (shouldHardNavigate(currentState.rootLayoutTreePath, pending.rootLayoutTreePath)) { + pendingNavigationCommits.delete(renderId); + window.location.assign(targetHref); + return; + } + + queuePrePaintNavigationEffect(renderId, prePaintEffect); + activateNavigationSnapshot(); + dispatchBrowserTree( + pending.action.elements, + navigationSnapshot, + renderId, + actionType, + pending.routeId, + pending.rootLayoutTreePath, + useTransition, + true, + ); } catch (error) { // Clean up pending state and decrement counter on synchronous error. pendingNavigationPrePaintEffects.delete(renderId); @@ -534,7 +584,7 @@ function registerServerActionCallback(): void { clearClientNavigationCaches(); - const result = await createFromFetch( + const result = await createFromFetch( Promise.resolve(fetchResponse), { temporaryReferences }, ); @@ -548,10 +598,24 @@ function registerServerActionCallback(): void { // If server actions ever trigger URL changes via RSC payload (instead of hard // redirects), this would need renderNavigationPayload() + snapshotActivated=true. if (isServerActionResult(result)) { - updateBrowserTree( - result.root, - createClientNavigationRenderSnapshot(window.location.href, latestClientParams), - ++nextNavigationRenderId, + const navigationSnapshot = createClientNavigationRenderSnapshot( + window.location.href, + latestClientParams, + ); + const pending = await createPendingNavigationCommit({ + currentState: getBrowserRouterState(), + nextElements: Promise.resolve(normalizeAppElements(result.root)), + navigationSnapshot, + renderId: ++nextNavigationRenderId, + type: "navigate", + }); + dispatchBrowserTree( + pending.action.elements, + navigationSnapshot, + pending.action.renderId, + "navigate", + pending.routeId, + pending.rootLayoutTreePath, false, ); if (result.returnValue) { @@ -561,11 +625,24 @@ function registerServerActionCallback(): void { return undefined; } - // Same reasoning as above: snapshotActivated omitted intentionally. - updateBrowserTree( - result, - createClientNavigationRenderSnapshot(window.location.href, latestClientParams), - ++nextNavigationRenderId, + const navigationSnapshot = createClientNavigationRenderSnapshot( + window.location.href, + latestClientParams, + ); + const pending = await createPendingNavigationCommit({ + currentState: getBrowserRouterState(), + nextElements: Promise.resolve(normalizeAppElements(result)), + navigationSnapshot, + renderId: ++nextNavigationRenderId, + type: "navigate", + }); + dispatchBrowserTree( + pending.action.elements, + navigationSnapshot, + pending.action.renderId, + "navigate", + pending.routeId, + pending.rootLayoutTreePath, false, ); return result; @@ -576,7 +653,7 @@ async function main(): Promise { registerServerActionCallback(); const rscStream = await readInitialRscStream(); - const root = createFromReadableStream(rscStream); + const root = normalizeAppElementsPromise(createFromReadableStream(rscStream)); const initialNavigationSnapshot = createClientNavigationRenderSnapshot( window.location.href, latestClientParams, @@ -585,7 +662,7 @@ async function main(): Promise { window.__VINEXT_RSC_ROOT__ = hydrateRoot( document, createElement(BrowserRoot, { - initialNode: root, + initialElements: root, initialNavigationSnapshot, }), import.meta.env.DEV ? { onCaughtError() {} } : undefined, @@ -625,8 +702,6 @@ async function main(): Promise { stripBasePath(url.pathname, __basePath) === stripBasePath(window.location.pathname, __basePath); const cachedRoute = getVisitedResponse(rscUrl, navigationKind); - const navigationCommitEffect = createNavigationCommitEffect(href, historyUpdateMode); - if (cachedRoute) { // Check stale-navigation before and after createFromFetch. The pre-check // avoids wasted parse work; the post-check catches supersessions that @@ -642,23 +717,19 @@ async function main(): Promise { // wrapping only) — no stale-navigation recheck needed between here and the // next await. const cachedNavigationSnapshot = createClientNavigationRenderSnapshot(href, cachedParams); - const cachedPayload = await createFromFetch( - Promise.resolve(restoreRscResponse(cachedRoute.response)), + const cachedPayload = normalizeAppElementsPromise( + createFromFetch( + Promise.resolve(restoreRscResponse(cachedRoute.response)), + ), ); if (navId !== activeNavigationId) return; - // Stage params only after confirming this navigation hasn't been superseded. - // Set _snapshotPending before stageClientParams: if renderNavigationPayload - // throws synchronously, its inner catch calls commitClientNavigationState() - // which would flush pendingClientParams for a route that never rendered. - // Ordering _snapshotPending first makes the intent explicit — params are - // staged as part of an in-flight snapshot, not as a standalone side-effect. _snapshotPending = true; // Set before renderNavigationPayload - stageClientParams(cachedParams); // NB: if this throws, outer catch hard-navigates, resetting all JS state try { await renderNavigationPayload( cachedPayload, cachedNavigationSnapshot, - navigationCommitEffect, + href, + createNavigationCommitEffect(href, historyUpdateMode, cachedParams), isSameRoute, ); } finally { @@ -726,23 +797,19 @@ async function main(): Promise { if (navId !== activeNavigationId) return; - const rscPayload = await createFromFetch( - Promise.resolve(restoreRscResponse(responseSnapshot)), + const rscPayload = normalizeAppElementsPromise( + createFromFetch(Promise.resolve(restoreRscResponse(responseSnapshot))), ); if (navId !== activeNavigationId) return; - // Stage params only after confirming this navigation hasn't been superseded - // (avoids stale cache entries). Set _snapshotPending before stageClientParams - // for the same reason as the cached path above: ensures params are only staged - // as part of an in-flight snapshot. _snapshotPending = true; // Set before renderNavigationPayload - stageClientParams(navParams); // NB: if this throws, outer catch hard-navigates, resetting all JS state try { await renderNavigationPayload( rscPayload, navigationSnapshot, - navigationCommitEffect, + href, + createNavigationCommitEffect(href, historyUpdateMode, navParams), isSameRoute, ); } finally { @@ -801,14 +868,28 @@ async function main(): Promise { import.meta.hot.on("rsc:update", async () => { try { clearClientNavigationCaches(); - const rscPayload = await createFromFetch( - fetch(toRscUrl(window.location.pathname + window.location.search)), + const navigationSnapshot = createClientNavigationRenderSnapshot( + window.location.href, + latestClientParams, ); - // HMR updates skip renderNavigationPayload — no snapshot activated. - updateBrowserTree( - rscPayload, - createClientNavigationRenderSnapshot(window.location.href, latestClientParams), - ++nextNavigationRenderId, + const pending = await createPendingNavigationCommit({ + currentState: getBrowserRouterState(), + nextElements: normalizeAppElementsPromise( + createFromFetch( + fetch(toRscUrl(window.location.pathname + window.location.search)), + ), + ), + navigationSnapshot, + renderId: ++nextNavigationRenderId, + type: "replace", + }); + dispatchBrowserTree( + pending.action.elements, + navigationSnapshot, + pending.action.renderId, + "replace", + pending.routeId, + pending.rootLayoutTreePath, false, ); } catch (error) { @@ -818,4 +899,6 @@ async function main(): Promise { } } -void main(); +if (typeof document !== "undefined") { + void main(); +} diff --git a/packages/vinext/src/server/app-browser-state.ts b/packages/vinext/src/server/app-browser-state.ts new file mode 100644 index 000000000..10ea50649 --- /dev/null +++ b/packages/vinext/src/server/app-browser-state.ts @@ -0,0 +1,124 @@ +import type { ReactNode } from "react"; +import { mergeElementsPromise } from "../shims/slot.js"; +import { readAppElementsMetadata, type AppElements } from "./app-elements.js"; +import type { ClientNavigationRenderSnapshot } from "../shims/navigation.js"; + +export type AppRouterState = { + elements: Promise; + renderId: number; + navigationSnapshot: ClientNavigationRenderSnapshot; + rootLayoutTreePath: string | null; + routeId: string; +}; + +export type AppRouterAction = { + elements: Promise; + navigationSnapshot: ClientNavigationRenderSnapshot; + renderId: number; + rootLayoutTreePath: string | null; + routeId: string; + type: "navigate" | "replace"; +}; + +export type PendingNavigationCommit = { + action: AppRouterAction; + rootLayoutTreePath: string | null; + routeId: string; +}; + +export function routerReducer(state: AppRouterState, action: AppRouterAction): AppRouterState { + switch (action.type) { + case "navigate": + return { + elements: mergeElementsPromise(state.elements, action.elements), + navigationSnapshot: action.navigationSnapshot, + renderId: action.renderId, + rootLayoutTreePath: action.rootLayoutTreePath, + routeId: action.routeId, + }; + case "replace": + return { + elements: action.elements, + navigationSnapshot: action.navigationSnapshot, + renderId: action.renderId, + rootLayoutTreePath: action.rootLayoutTreePath, + routeId: action.routeId, + }; + } +} + +export function shouldHardNavigate( + currentRootLayoutTreePath: string | null, + nextRootLayoutTreePath: string | null, +): boolean { + return ( + currentRootLayoutTreePath !== null && + nextRootLayoutTreePath !== null && + currentRootLayoutTreePath !== nextRootLayoutTreePath + ); +} + +export async function createPendingNavigationCommit(options: { + currentState: AppRouterState; + nextElements: Promise; + navigationSnapshot: ClientNavigationRenderSnapshot; + renderId?: number; + type: "navigate" | "replace"; +}): Promise { + const elements = await options.nextElements; + const metadata = readAppElementsMetadata(elements); + + return { + action: { + elements: Promise.resolve(elements), + navigationSnapshot: options.navigationSnapshot, + renderId: options.renderId ?? options.currentState.renderId + 1, + rootLayoutTreePath: metadata.rootLayoutTreePath, + routeId: metadata.routeId, + type: options.type, + }, + rootLayoutTreePath: metadata.rootLayoutTreePath, + routeId: metadata.routeId, + }; +} + +export async function applyAppRouterStateUpdate(options: { + commit: () => void; + currentState: AppRouterState; + dispatch: (action: AppRouterAction) => void; + nextElements: Promise; + navigationSnapshot?: ClientNavigationRenderSnapshot; + onHardNavigate: (href: string) => void; + targetHref: string; + transition: (callback: () => void) => void; + type?: "navigate" | "replace"; +}): Promise<{ type: "dispatched" | "hard-navigate" }> { + const pending = await createPendingNavigationCommit({ + currentState: options.currentState, + nextElements: options.nextElements, + navigationSnapshot: options.navigationSnapshot ?? options.currentState.navigationSnapshot, + type: options.type ?? "navigate", + }); + + if (shouldHardNavigate(options.currentState.rootLayoutTreePath, pending.rootLayoutTreePath)) { + options.onHardNavigate(options.targetHref); + return { type: "hard-navigate" }; + } + + options.transition(() => { + options.commit(); + options.dispatch(pending.action); + }); + + return { type: "dispatched" }; +} + +export function createRouteNodeSnapshot( + elements: Promise, + routeId: string, +): { elements: Promise; routeId: string } { + return { elements, routeId }; +} + +export type AppRouteNodeSnapshot = ReturnType; +export type AppRouteNodeValue = ReactNode; diff --git a/packages/vinext/src/server/app-elements.ts b/packages/vinext/src/server/app-elements.ts new file mode 100644 index 000000000..e04828971 --- /dev/null +++ b/packages/vinext/src/server/app-elements.ts @@ -0,0 +1,46 @@ +import type { ReactNode } from "react"; + +export const APP_ROUTE_KEY = "__route"; +export const APP_ROOT_LAYOUT_KEY = "__rootLayout"; +export const APP_UNMATCHED_SLOT_WIRE_VALUE = "__VINEXT_UNMATCHED_SLOT__"; + +export const UNMATCHED_SLOT = Symbol.for("vinext.unmatchedSlot"); + +export type AppElementValue = ReactNode | typeof UNMATCHED_SLOT | string | null; +export type AppWireElementValue = ReactNode | string | null; + +export type AppElements = Readonly>; +export type AppWireElements = Readonly>; + +export type AppElementsMetadata = { + routeId: string; + rootLayoutTreePath: string | null; +}; + +export function normalizeAppElements(elements: AppWireElements): AppElements { + const normalized: Record = {}; + + for (const [key, value] of Object.entries(elements)) { + normalized[key] = + key.startsWith("slot:") && value === APP_UNMATCHED_SLOT_WIRE_VALUE ? UNMATCHED_SLOT : value; + } + + return normalized; +} + +export function readAppElementsMetadata(elements: AppElements): AppElementsMetadata { + const routeId = elements[APP_ROUTE_KEY]; + if (typeof routeId !== "string") { + throw new Error("[vinext] Missing __route string in App Router payload"); + } + + const rootLayoutTreePath = elements[APP_ROOT_LAYOUT_KEY]; + if (rootLayoutTreePath !== null && typeof rootLayoutTreePath !== "string") { + throw new Error("[vinext] Invalid __rootLayout in App Router payload"); + } + + return { + routeId, + rootLayoutTreePath, + }; +} diff --git a/packages/vinext/src/server/app-page-boundary-render.ts b/packages/vinext/src/server/app-page-boundary-render.ts index 1aca237a3..871ded9de 100644 --- a/packages/vinext/src/server/app-page-boundary-render.ts +++ b/packages/vinext/src/server/app-page-boundary-render.ts @@ -24,6 +24,8 @@ import { renderAppPageHtmlResponse, type AppPageSsrHandler, } from "./app-page-stream.js"; +import { APP_ROOT_LAYOUT_KEY, APP_ROUTE_KEY, type AppElements } from "./app-elements.js"; +import { createAppPageLayoutEntries } from "./app-page-route-wiring.js"; // oxlint-disable-next-line @typescript-eslint/no-explicit-any type AppPageComponent = ComponentType; @@ -36,6 +38,13 @@ type AppPageBoundaryOnError = ( errorContext: unknown, ) => unknown; +type AppPageBoundaryRscPayloadOptions = { + element: ReactNode; + layoutModules: readonly (TModule | null | undefined)[]; + pathname: string; + route?: AppPageBoundaryRoute | null; +}; + export type AppPageBoundaryRoute = { error?: TModule | null; errors?: readonly (TModule | null | undefined)[] | null; @@ -62,7 +71,7 @@ type AppPageBoundaryRenderCommonOptions Promise; makeThenableParams: (params: AppPageParams) => unknown; renderToReadableStream: ( - element: ReactNode, + element: ReactNode | AppElements, options: { onError: AppPageBoundaryOnError }, ) => ReadableStream; requestUrl: string; @@ -200,14 +209,60 @@ function wrapRenderedBoundaryElement( }); } +function resolveAppPageBoundaryRootLayoutTreePath( + route: AppPageBoundaryRoute | null | undefined, + layoutModules: readonly (TModule | null | undefined)[], +): string | null { + if (route?.layouts) { + const rootLayoutEntry = createAppPageLayoutEntries({ + errors: route.errors, + layoutTreePositions: route.layoutTreePositions, + layouts: route.layouts, + notFounds: null, + routeSegments: route.routeSegments, + })[0]; + + if (rootLayoutEntry) { + return rootLayoutEntry.treePath; + } + } + + return layoutModules.length > 0 ? "/" : null; +} + +function createAppPageBoundaryRscPayload( + options: AppPageBoundaryRscPayloadOptions, +): AppElements { + const routeId = `route:${options.pathname}`; + + return { + [APP_ROUTE_KEY]: routeId, + [APP_ROOT_LAYOUT_KEY]: resolveAppPageBoundaryRootLayoutTreePath( + options.route, + options.layoutModules, + ), + [routeId]: options.element, + }; +} + async function renderAppPageBoundaryElementResponse( options: AppPageBoundaryRenderCommonOptions & { element: ReactNode; + layoutModules: readonly (TModule | null | undefined)[]; + route?: AppPageBoundaryRoute | null; routePattern?: string; status: number; }, ): Promise { const pathname = new URL(options.requestUrl).pathname; + const payload = options.isRscRequest + ? createAppPageBoundaryRscPayload({ + element: options.element, + layoutModules: options.layoutModules, + pathname, + route: options.route, + }) + : options.element; return renderAppPageBoundaryResponse({ async createHtmlResponse(rscStream, responseStatus) { @@ -230,7 +285,7 @@ async function renderAppPageBoundaryElementResponse( return renderAppPageBoundaryElementResponse({ ...options, element, + layoutModules, + route: options.route, routePattern: options.route?.pattern, status: 200, }); diff --git a/packages/vinext/src/server/app-page-route-wiring.tsx b/packages/vinext/src/server/app-page-route-wiring.tsx index 8936afd29..a605fe877 100644 --- a/packages/vinext/src/server/app-page-route-wiring.tsx +++ b/packages/vinext/src/server/app-page-route-wiring.tsx @@ -1,7 +1,14 @@ import { Suspense, type ComponentType, type ReactNode } from "react"; +import { + APP_ROOT_LAYOUT_KEY, + APP_ROUTE_KEY, + APP_UNMATCHED_SLOT_WIRE_VALUE, + type AppElements, +} from "./app-elements.js"; import { ErrorBoundary, NotFoundBoundary } from "../shims/error-boundary.js"; import { LayoutSegmentProvider } from "../shims/layout-segment-context.js"; import { MetadataHead, ViewportHead, type Metadata, type Viewport } from "../shims/metadata.js"; +import { Children, ParallelSlot, Slot } from "../shims/slot.js"; import type { AppPageParams } from "./app-page-boundary.js"; type AppPageComponentProps = { @@ -47,6 +54,7 @@ export type AppPageRouteWiringRoute< notFounds?: readonly (TModule | null | undefined)[] | null; routeSegments?: readonly string[]; slots?: Readonly>> | null; + templateTreePositions?: readonly number[] | null; templates?: readonly (TModule | null | undefined)[] | null; }; @@ -83,6 +91,20 @@ export type BuildAppPageRouteElementOptions< slotOverrides?: Readonly>> | null; }; +export type BuildAppPageElementsOptions< + TModule extends AppPageModule = AppPageModule, + TErrorModule extends AppPageErrorModule = AppPageErrorModule, +> = Omit, "globalErrorModule"> & { + routePath: string; +}; + +type AppPageTemplateEntry = { + id: string; + templateModule?: TModule | null | undefined; + treePath: string; + treePosition: number; +}; + function getDefaultExport( module: TModule | null | undefined, ): AppPageComponent | null { @@ -129,6 +151,24 @@ export function createAppPageLayoutEntries< }); } +export function createAppPageTemplateEntries( + route: Pick< + AppPageRouteWiringRoute, + "routeSegments" | "templateTreePositions" | "templates" + >, +): AppPageTemplateEntry[] { + return (route.templates ?? []).map((templateModule, index) => { + const treePosition = route.templateTreePositions?.[index] ?? 0; + const treePath = createAppPageTreePath(route.routeSegments, treePosition); + return { + id: `template:${treePath}`, + templateModule, + treePath, + treePosition, + }; + }); +} + export function resolveAppPageChildSegments( routeSegments: readonly string[], treePosition: number, @@ -181,10 +221,279 @@ export function resolveAppPageChildSegments( return resolvedSegments; } +function resolveAppPageVisibleSegments( + routeSegments: readonly string[], + params: AppPageParams, +): string[] { + const resolvedSegments = resolveAppPageChildSegments(routeSegments, 0, params); + return resolvedSegments.filter((segment) => !(segment.startsWith("(") && segment.endsWith(")"))); +} + +function resolveAppPageTemplateKey( + routeSegments: readonly string[], + treePosition: number, + params: AppPageParams, +): string { + const visibleSegments = resolveAppPageVisibleSegments(routeSegments.slice(treePosition), params); + return visibleSegments[0] ?? ""; +} + +function createAppPageParallelSlotEntries< + TModule extends AppPageModule, + TErrorModule extends AppPageErrorModule, +>( + layoutIndex: number, + layoutEntries: readonly AppPageLayoutEntry[], + route: AppPageRouteWiringRoute, +): Readonly> | undefined { + const parallelSlots: Record = {}; + + for (const [slotName, slot] of Object.entries(route.slots ?? {})) { + const targetIndex = slot.layoutIndex >= 0 ? slot.layoutIndex : layoutEntries.length - 1; + if (targetIndex !== layoutIndex) { + continue; + } + + const layoutEntry = layoutEntries[targetIndex]; + const treePath = layoutEntry?.treePath ?? "/"; + parallelSlots[slotName] = ( + + + + ); + } + + return Object.keys(parallelSlots).length > 0 ? parallelSlots : undefined; +} + +function createAppPageRouteHead(metadata: Metadata | null, viewport: Viewport): ReactNode { + return ( + <> + + {metadata ? : null} + + + ); +} + +export function buildAppPageElements< + TModule extends AppPageModule, + TErrorModule extends AppPageErrorModule, +>(options: BuildAppPageElementsOptions): AppElements { + const elements: Record = {}; + const routeId = `route:${options.routePath}`; + const pageId = `page:${options.routePath}`; + const layoutEntries = createAppPageLayoutEntries(options.route); + const templateEntries = createAppPageTemplateEntries(options.route); + const routeThenableParams = options.makeThenableParams(options.matchedParams); + const rootLayoutTreePath = layoutEntries[0]?.treePath ?? null; + + elements[APP_ROUTE_KEY] = routeId; + elements[APP_ROOT_LAYOUT_KEY] = rootLayoutTreePath; + elements[pageId] = options.element; + + for (const templateEntry of templateEntries) { + const templateComponent = getDefaultExport(templateEntry.templateModule); + if (!templateComponent) { + continue; + } + const TemplateComponent = templateComponent; + elements[templateEntry.id] = ( + {} + ); + } + + for (let index = 0; index < layoutEntries.length; index++) { + const layoutEntry = layoutEntries[index]; + const layoutComponent = getDefaultExport(layoutEntry.layoutModule); + if (!layoutComponent) { + continue; + } + + const layoutProps: Record = { + params: routeThenableParams, + }; + + for (const [slotName, slot] of Object.entries(options.route.slots ?? {})) { + const targetIndex = slot.layoutIndex >= 0 ? slot.layoutIndex : layoutEntries.length - 1; + if (targetIndex !== index) { + continue; + } + layoutProps[slotName] = ; + } + + const LayoutComponent = layoutComponent; + elements[layoutEntry.id] = ( + + + + ); + } + + for (const [slotName, slot] of Object.entries(options.route.slots ?? {})) { + const targetIndex = slot.layoutIndex >= 0 ? slot.layoutIndex : layoutEntries.length - 1; + const treePath = layoutEntries[targetIndex]?.treePath ?? "/"; + const slotId = `slot:${slotName}:${treePath}`; + const slotOverride = options.slotOverrides?.[slotName]; + const slotParams = slotOverride?.params ?? options.matchedParams; + const slotComponent = + getDefaultExport(slotOverride?.pageModule) ?? + getDefaultExport(slot.page) ?? + getDefaultExport(slot.default); + + if (!slotComponent) { + elements[slotId] = APP_UNMATCHED_SLOT_WIRE_VALUE; + continue; + } + + const slotProps: Record = { + params: options.makeThenableParams(slotParams), + }; + if (slotOverride?.props) { + Object.assign(slotProps, slotOverride.props); + } + + const SlotComponent = slotComponent; + let slotElement: ReactNode = ; + + const slotLayoutComponent = getDefaultExport(slot.layout); + if (slotLayoutComponent) { + const SlotLayoutComponent = slotLayoutComponent; + slotElement = ( + + {slotElement} + + ); + } + + const slotLoadingComponent = getDefaultExport(slot.loading); + if (slotLoadingComponent) { + const SlotLoadingComponent = slotLoadingComponent; + slotElement = }>{slotElement}; + } + + const slotErrorComponent = getErrorBoundaryExport(slot.error); + if (slotErrorComponent) { + slotElement = {slotElement}; + } + + elements[slotId] = slotElement; + } + + let routeChildren: ReactNode = ( + + + + ); + + const routeLoadingComponent = getDefaultExport(options.route.loading); + if (routeLoadingComponent) { + const RouteLoadingComponent = routeLoadingComponent; + routeChildren = }>{routeChildren}; + } + + const lastLayoutErrorModule = + options.route.errors && options.route.errors.length > 0 + ? options.route.errors[options.route.errors.length - 1] + : null; + const pageErrorComponent = getErrorBoundaryExport(options.route.error); + if (pageErrorComponent && options.route.error !== lastLayoutErrorModule) { + routeChildren = {routeChildren}; + } + + const notFoundComponent = + getDefaultExport(options.route.notFound) ?? getDefaultExport(options.rootNotFoundModule); + if (notFoundComponent) { + const NotFoundComponent = notFoundComponent; + routeChildren = ( + }>{routeChildren} + ); + } + + for (let index = layoutEntries.length - 1; index >= 0; index--) { + const layoutEntry = layoutEntries[index]; + let layoutChildren = routeChildren; + const templateEntry = templateEntries.find( + (entry) => entry.treePosition === layoutEntry.treePosition, + ); + if (templateEntry) { + layoutChildren = ( + + {layoutChildren} + + ); + } + + const layoutErrorComponent = getErrorBoundaryExport(layoutEntry.errorModule); + if (layoutErrorComponent) { + layoutChildren = ( + {layoutChildren} + ); + } + + const layoutNotFoundComponent = getDefaultExport(layoutEntry.notFoundModule); + if (layoutNotFoundComponent) { + const LayoutNotFoundComponent = layoutNotFoundComponent; + layoutChildren = ( + }>{layoutChildren} + ); + } + + routeChildren = ( + { + const targetIndex = + slot.layoutIndex >= 0 ? slot.layoutIndex : layoutEntries.length - 1; + return targetIndex === index; + }) + .map(([slotName]) => [slotName, []]), + ), + }} + > + + {layoutChildren} + + + ); + } + + elements[routeId] = ( + <> + {createAppPageRouteHead(options.resolvedMetadata, options.resolvedViewport)} + {routeChildren} + + ); + + return elements; +} + export function buildAppPageRouteElement< TModule extends AppPageModule, TErrorModule extends AppPageErrorModule, >(options: BuildAppPageRouteElementOptions): ReactNode { + /** + * @deprecated PR 2c introduces buildAppPageElements() for the flat payload + * cutover. Keep this helper during the transition so intermediate test runs + * remain stable, then delete it only after all call sites have switched. + */ let element: ReactNode = ( {options.element} ); diff --git a/packages/vinext/src/server/app-ssr-entry.ts b/packages/vinext/src/server/app-ssr-entry.ts index 32d754c47..a9a37e0e9 100644 --- a/packages/vinext/src/server/app-ssr-entry.ts +++ b/packages/vinext/src/server/app-ssr-entry.ts @@ -1,7 +1,7 @@ /// import type { ReactNode } from "react"; -import { Fragment, createElement as createReactElement } from "react"; +import { Fragment, createElement as createReactElement, use } from "react"; import { createFromReadableStream } from "@vitejs/plugin-rsc/ssr"; import { renderToReadableStream, renderToStaticMarkup } from "react-dom/server.edge"; import * as clientReferences from "virtual:vite-rsc/client-references"; @@ -16,6 +16,13 @@ import { import { runWithNavigationContext } from "../shims/navigation-state.js"; import { safeJsonStringify } from "./html.js"; import { createRscEmbedTransform, createTickBufferedTransform } from "./app-ssr-stream.js"; +import { + normalizeAppElements, + readAppElementsMetadata, + type AppElements, + type AppWireElements, +} from "./app-elements.js"; +import { ElementsContext, Slot } from "../shims/slot.js"; export type FontPreload = { href: string; @@ -167,13 +174,21 @@ export async function handleSsr( const [ssrStream, embedStream] = rscStream.tee(); const rscEmbed = createRscEmbedTransform(embedStream); - let flightRoot: Promise | null = null; + let flightRoot: Promise | null = null; function VinextFlightRoot(): ReactNode { if (!flightRoot) { - flightRoot = createFromReadableStream(ssrStream); + flightRoot = createFromReadableStream(ssrStream).then((elements) => + normalizeAppElements(elements), + ); } - return flightRoot as unknown as ReactNode; + const elements = use(flightRoot); + const metadata = readAppElementsMetadata(elements); + return createReactElement( + ElementsContext.Provider, + { value: Promise.resolve(elements) }, + createReactElement(Slot, { id: metadata.routeId }), + ); } const root = createReactElement(VinextFlightRoot); diff --git a/packages/vinext/src/shims/slot.tsx b/packages/vinext/src/shims/slot.tsx index 19de9c528..d5c4c6365 100644 --- a/packages/vinext/src/shims/slot.tsx +++ b/packages/vinext/src/shims/slot.tsx @@ -1,16 +1,18 @@ "use client"; import * as React from "react"; +import { UNMATCHED_SLOT, type AppElements } from "../server/app-elements.js"; import { notFound } from "./navigation.js"; -type Elements = Record; +const EMPTY_ELEMENTS_PROMISE = Promise.resolve({}); +const mergeCache = new WeakMap< + Promise, + WeakMap, Promise> +>(); -const EMPTY_ELEMENTS_PROMISE = Promise.resolve({}); -const mergeCache = new WeakMap, WeakMap, Promise>>(); +export { UNMATCHED_SLOT }; -export const UNMATCHED_SLOT = Symbol.for("vinext.unmatchedSlot"); - -export const ElementsContext = React.createContext>(EMPTY_ELEMENTS_PROMISE); +export const ElementsContext = React.createContext>(EMPTY_ELEMENTS_PROMISE); export const ChildrenContext = React.createContext(null); @@ -19,9 +21,9 @@ export const ParallelSlotsContext = React.createContext | null>(null); export function mergeElementsPromise( - prev: Promise, - next: Promise, -): Promise { + prev: Promise, + next: Promise, +): Promise { let nextCache = mergeCache.get(prev); if (!nextCache) { nextCache = new WeakMap(); diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index 96989ffff..fa31701cc 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -78,7 +78,7 @@ import { renderAppPageHttpAccessFallback as __renderAppPageHttpAccessFallback, } from "/packages/vinext/src/server/app-page-boundary-render.js"; import { - buildAppPageRouteElement as __buildAppPageRouteElement, + buildAppPageElements as __buildAppPageElements, resolveAppPageChildSegments as __resolveAppPageChildSegments, } from "/packages/vinext/src/server/app-page-route-wiring.js"; import { @@ -392,6 +392,7 @@ const routes = [ routeHandler: null, layouts: [mod_1], routeSegments: [], + templateTreePositions: [], layoutTreePositions: [0], templates: [], errors: [null], @@ -414,6 +415,7 @@ const routes = [ routeHandler: null, layouts: [mod_1], routeSegments: ["about"], + templateTreePositions: [], layoutTreePositions: [0], templates: [], errors: [null], @@ -436,6 +438,7 @@ const routes = [ routeHandler: null, layouts: [mod_1, mod_4], routeSegments: ["blog",":slug"], + templateTreePositions: [], layoutTreePositions: [0,1], templates: [], errors: [null, null], @@ -458,6 +461,7 @@ const routes = [ routeHandler: null, layouts: [mod_1, mod_6], routeSegments: ["dashboard"], + templateTreePositions: [1], layoutTreePositions: [0,1], templates: [mod_7], errors: [null, mod_9], @@ -644,7 +648,7 @@ function findIntercept(pathname) { return null; } -async function buildPageElement(route, params, opts, searchParams) { +async function buildPageElements(route, params, routePath, opts, searchParams) { const PageComponent = route.page?.default; if (!PageComponent) { return createElement("div", null, "Page has no default export"); @@ -745,13 +749,13 @@ async function buildPageElement(route, params, opts, searchParams) { // dynamic, and this avoids false positives from React internals. if (hasSearchParams) markDynamicUsage(); } - return __buildAppPageRouteElement({ + return __buildAppPageElements({ element: createElement(PageComponent, pageProps), - globalErrorModule: null, makeThenableParams, matchedParams: params, resolvedMetadata, resolvedViewport, + routePath, rootNotFoundModule: null, route, slotOverrides: @@ -1435,7 +1439,13 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { searchParams: url.searchParams, params: actionParams, }); - element = buildPageElement(actionRoute, actionParams, undefined, url.searchParams); + element = buildPageElements( + actionRoute, + actionParams, + cleanPathname, + undefined, + url.searchParams, + ); } else { element = createElement("div", null, "Page not found"); } @@ -1759,7 +1769,13 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { return _runWithUnifiedCtx(__revalUCtx, async () => { _ensureFetchPatch(); setNavigationContext({ pathname: cleanPathname, searchParams: new URLSearchParams(), params }); - const __revalElement = await buildPageElement(route, params, undefined, new URLSearchParams()); + const __revalElement = await buildPageElements( + route, + params, + cleanPathname, + undefined, + new URLSearchParams(), + ); const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); const __revalRscCapture = __teeAppPageRscStreamForCapture(__revalRscStream, true); @@ -1808,7 +1824,15 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // If the target URL matches an intercepting route in a parallel slot, // render the source route with the intercepting page in the slot. const __interceptResult = await __resolveAppPageIntercept({ - buildPageElement, + buildPageElement(interceptRoute, interceptParams, interceptOpts, interceptSearchParams) { + return buildPageElements( + interceptRoute, + interceptParams, + cleanPathname, + interceptOpts, + interceptSearchParams, + ); + }, cleanPathname, currentRoute: route, findIntercept, @@ -1856,7 +1880,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const __pageBuildResult = await __buildAppPageElement({ buildPageElement() { - return buildPageElement(route, params, interceptOpts, url.searchParams); + return buildPageElements(route, params, cleanPathname, interceptOpts, url.searchParams); }, renderErrorBoundaryPage(buildErr) { return renderErrorBoundaryPage(route, buildErr, isRscRequest, request, params); @@ -2081,7 +2105,7 @@ import { renderAppPageHttpAccessFallback as __renderAppPageHttpAccessFallback, } from "/packages/vinext/src/server/app-page-boundary-render.js"; import { - buildAppPageRouteElement as __buildAppPageRouteElement, + buildAppPageElements as __buildAppPageElements, resolveAppPageChildSegments as __resolveAppPageChildSegments, } from "/packages/vinext/src/server/app-page-route-wiring.js"; import { @@ -2395,6 +2419,7 @@ const routes = [ routeHandler: null, layouts: [mod_1], routeSegments: [], + templateTreePositions: [], layoutTreePositions: [0], templates: [], errors: [null], @@ -2417,6 +2442,7 @@ const routes = [ routeHandler: null, layouts: [mod_1], routeSegments: ["about"], + templateTreePositions: [], layoutTreePositions: [0], templates: [], errors: [null], @@ -2439,6 +2465,7 @@ const routes = [ routeHandler: null, layouts: [mod_1, mod_4], routeSegments: ["blog",":slug"], + templateTreePositions: [], layoutTreePositions: [0,1], templates: [], errors: [null, null], @@ -2461,6 +2488,7 @@ const routes = [ routeHandler: null, layouts: [mod_1, mod_6], routeSegments: ["dashboard"], + templateTreePositions: [1], layoutTreePositions: [0,1], templates: [mod_7], errors: [null, mod_9], @@ -2647,7 +2675,7 @@ function findIntercept(pathname) { return null; } -async function buildPageElement(route, params, opts, searchParams) { +async function buildPageElements(route, params, routePath, opts, searchParams) { const PageComponent = route.page?.default; if (!PageComponent) { return createElement("div", null, "Page has no default export"); @@ -2748,13 +2776,13 @@ async function buildPageElement(route, params, opts, searchParams) { // dynamic, and this avoids false positives from React internals. if (hasSearchParams) markDynamicUsage(); } - return __buildAppPageRouteElement({ + return __buildAppPageElements({ element: createElement(PageComponent, pageProps), - globalErrorModule: null, makeThenableParams, matchedParams: params, resolvedMetadata, resolvedViewport, + routePath, rootNotFoundModule: null, route, slotOverrides: @@ -3441,7 +3469,13 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { searchParams: url.searchParams, params: actionParams, }); - element = buildPageElement(actionRoute, actionParams, undefined, url.searchParams); + element = buildPageElements( + actionRoute, + actionParams, + cleanPathname, + undefined, + url.searchParams, + ); } else { element = createElement("div", null, "Page not found"); } @@ -3765,7 +3799,13 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { return _runWithUnifiedCtx(__revalUCtx, async () => { _ensureFetchPatch(); setNavigationContext({ pathname: cleanPathname, searchParams: new URLSearchParams(), params }); - const __revalElement = await buildPageElement(route, params, undefined, new URLSearchParams()); + const __revalElement = await buildPageElements( + route, + params, + cleanPathname, + undefined, + new URLSearchParams(), + ); const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); const __revalRscCapture = __teeAppPageRscStreamForCapture(__revalRscStream, true); @@ -3814,7 +3854,15 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // If the target URL matches an intercepting route in a parallel slot, // render the source route with the intercepting page in the slot. const __interceptResult = await __resolveAppPageIntercept({ - buildPageElement, + buildPageElement(interceptRoute, interceptParams, interceptOpts, interceptSearchParams) { + return buildPageElements( + interceptRoute, + interceptParams, + cleanPathname, + interceptOpts, + interceptSearchParams, + ); + }, cleanPathname, currentRoute: route, findIntercept, @@ -3862,7 +3910,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const __pageBuildResult = await __buildAppPageElement({ buildPageElement() { - return buildPageElement(route, params, interceptOpts, url.searchParams); + return buildPageElements(route, params, cleanPathname, interceptOpts, url.searchParams); }, renderErrorBoundaryPage(buildErr) { return renderErrorBoundaryPage(route, buildErr, isRscRequest, request, params); @@ -4087,7 +4135,7 @@ import { renderAppPageHttpAccessFallback as __renderAppPageHttpAccessFallback, } from "/packages/vinext/src/server/app-page-boundary-render.js"; import { - buildAppPageRouteElement as __buildAppPageRouteElement, + buildAppPageElements as __buildAppPageElements, resolveAppPageChildSegments as __resolveAppPageChildSegments, } from "/packages/vinext/src/server/app-page-route-wiring.js"; import { @@ -4402,6 +4450,7 @@ const routes = [ routeHandler: null, layouts: [mod_1], routeSegments: [], + templateTreePositions: [], layoutTreePositions: [0], templates: [], errors: [null], @@ -4424,6 +4473,7 @@ const routes = [ routeHandler: null, layouts: [mod_1], routeSegments: ["about"], + templateTreePositions: [], layoutTreePositions: [0], templates: [], errors: [null], @@ -4446,6 +4496,7 @@ const routes = [ routeHandler: null, layouts: [mod_1, mod_4], routeSegments: ["blog",":slug"], + templateTreePositions: [], layoutTreePositions: [0,1], templates: [], errors: [null, null], @@ -4468,6 +4519,7 @@ const routes = [ routeHandler: null, layouts: [mod_1, mod_6], routeSegments: ["dashboard"], + templateTreePositions: [1], layoutTreePositions: [0,1], templates: [mod_7], errors: [null, mod_9], @@ -4654,7 +4706,7 @@ function findIntercept(pathname) { return null; } -async function buildPageElement(route, params, opts, searchParams) { +async function buildPageElements(route, params, routePath, opts, searchParams) { const PageComponent = route.page?.default; if (!PageComponent) { return createElement("div", null, "Page has no default export"); @@ -4755,13 +4807,13 @@ async function buildPageElement(route, params, opts, searchParams) { // dynamic, and this avoids false positives from React internals. if (hasSearchParams) markDynamicUsage(); } - return __buildAppPageRouteElement({ + return __buildAppPageElements({ element: createElement(PageComponent, pageProps), - globalErrorModule: mod_11, makeThenableParams, matchedParams: params, resolvedMetadata, resolvedViewport, + routePath, rootNotFoundModule: null, route, slotOverrides: @@ -5445,7 +5497,13 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { searchParams: url.searchParams, params: actionParams, }); - element = buildPageElement(actionRoute, actionParams, undefined, url.searchParams); + element = buildPageElements( + actionRoute, + actionParams, + cleanPathname, + undefined, + url.searchParams, + ); } else { element = createElement("div", null, "Page not found"); } @@ -5769,7 +5827,13 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { return _runWithUnifiedCtx(__revalUCtx, async () => { _ensureFetchPatch(); setNavigationContext({ pathname: cleanPathname, searchParams: new URLSearchParams(), params }); - const __revalElement = await buildPageElement(route, params, undefined, new URLSearchParams()); + const __revalElement = await buildPageElements( + route, + params, + cleanPathname, + undefined, + new URLSearchParams(), + ); const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); const __revalRscCapture = __teeAppPageRscStreamForCapture(__revalRscStream, true); @@ -5818,7 +5882,15 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // If the target URL matches an intercepting route in a parallel slot, // render the source route with the intercepting page in the slot. const __interceptResult = await __resolveAppPageIntercept({ - buildPageElement, + buildPageElement(interceptRoute, interceptParams, interceptOpts, interceptSearchParams) { + return buildPageElements( + interceptRoute, + interceptParams, + cleanPathname, + interceptOpts, + interceptSearchParams, + ); + }, cleanPathname, currentRoute: route, findIntercept, @@ -5866,7 +5938,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const __pageBuildResult = await __buildAppPageElement({ buildPageElement() { - return buildPageElement(route, params, interceptOpts, url.searchParams); + return buildPageElements(route, params, cleanPathname, interceptOpts, url.searchParams); }, renderErrorBoundaryPage(buildErr) { return renderErrorBoundaryPage(route, buildErr, isRscRequest, request, params); @@ -6091,7 +6163,7 @@ import { renderAppPageHttpAccessFallback as __renderAppPageHttpAccessFallback, } from "/packages/vinext/src/server/app-page-boundary-render.js"; import { - buildAppPageRouteElement as __buildAppPageRouteElement, + buildAppPageElements as __buildAppPageElements, resolveAppPageChildSegments as __resolveAppPageChildSegments, } from "/packages/vinext/src/server/app-page-route-wiring.js"; import { @@ -6435,6 +6507,7 @@ const routes = [ routeHandler: null, layouts: [mod_1], routeSegments: [], + templateTreePositions: [], layoutTreePositions: [0], templates: [], errors: [null], @@ -6457,6 +6530,7 @@ const routes = [ routeHandler: null, layouts: [mod_1], routeSegments: ["about"], + templateTreePositions: [], layoutTreePositions: [0], templates: [], errors: [null], @@ -6479,6 +6553,7 @@ const routes = [ routeHandler: null, layouts: [mod_1, mod_4], routeSegments: ["blog",":slug"], + templateTreePositions: [], layoutTreePositions: [0,1], templates: [], errors: [null, null], @@ -6501,6 +6576,7 @@ const routes = [ routeHandler: null, layouts: [mod_1, mod_6], routeSegments: ["dashboard"], + templateTreePositions: [1], layoutTreePositions: [0,1], templates: [mod_7], errors: [null, mod_9], @@ -6687,7 +6763,7 @@ function findIntercept(pathname) { return null; } -async function buildPageElement(route, params, opts, searchParams) { +async function buildPageElements(route, params, routePath, opts, searchParams) { const PageComponent = route.page?.default; if (!PageComponent) { return createElement("div", null, "Page has no default export"); @@ -6788,13 +6864,13 @@ async function buildPageElement(route, params, opts, searchParams) { // dynamic, and this avoids false positives from React internals. if (hasSearchParams) markDynamicUsage(); } - return __buildAppPageRouteElement({ + return __buildAppPageElements({ element: createElement(PageComponent, pageProps), - globalErrorModule: null, makeThenableParams, matchedParams: params, resolvedMetadata, resolvedViewport, + routePath, rootNotFoundModule: null, route, slotOverrides: @@ -7481,7 +7557,13 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { searchParams: url.searchParams, params: actionParams, }); - element = buildPageElement(actionRoute, actionParams, undefined, url.searchParams); + element = buildPageElements( + actionRoute, + actionParams, + cleanPathname, + undefined, + url.searchParams, + ); } else { element = createElement("div", null, "Page not found"); } @@ -7805,7 +7887,13 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { return _runWithUnifiedCtx(__revalUCtx, async () => { _ensureFetchPatch(); setNavigationContext({ pathname: cleanPathname, searchParams: new URLSearchParams(), params }); - const __revalElement = await buildPageElement(route, params, undefined, new URLSearchParams()); + const __revalElement = await buildPageElements( + route, + params, + cleanPathname, + undefined, + new URLSearchParams(), + ); const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); const __revalRscCapture = __teeAppPageRscStreamForCapture(__revalRscStream, true); @@ -7854,7 +7942,15 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // If the target URL matches an intercepting route in a parallel slot, // render the source route with the intercepting page in the slot. const __interceptResult = await __resolveAppPageIntercept({ - buildPageElement, + buildPageElement(interceptRoute, interceptParams, interceptOpts, interceptSearchParams) { + return buildPageElements( + interceptRoute, + interceptParams, + cleanPathname, + interceptOpts, + interceptSearchParams, + ); + }, cleanPathname, currentRoute: route, findIntercept, @@ -7902,7 +7998,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const __pageBuildResult = await __buildAppPageElement({ buildPageElement() { - return buildPageElement(route, params, interceptOpts, url.searchParams); + return buildPageElements(route, params, cleanPathname, interceptOpts, url.searchParams); }, renderErrorBoundaryPage(buildErr) { return renderErrorBoundaryPage(route, buildErr, isRscRequest, request, params); @@ -8127,7 +8223,7 @@ import { renderAppPageHttpAccessFallback as __renderAppPageHttpAccessFallback, } from "/packages/vinext/src/server/app-page-boundary-render.js"; import { - buildAppPageRouteElement as __buildAppPageRouteElement, + buildAppPageElements as __buildAppPageElements, resolveAppPageChildSegments as __resolveAppPageChildSegments, } from "/packages/vinext/src/server/app-page-route-wiring.js"; import { @@ -8442,6 +8538,7 @@ const routes = [ routeHandler: null, layouts: [mod_1], routeSegments: [], + templateTreePositions: [], layoutTreePositions: [0], templates: [], errors: [null], @@ -8464,6 +8561,7 @@ const routes = [ routeHandler: null, layouts: [mod_1], routeSegments: ["about"], + templateTreePositions: [], layoutTreePositions: [0], templates: [], errors: [null], @@ -8486,6 +8584,7 @@ const routes = [ routeHandler: null, layouts: [mod_1, mod_4], routeSegments: ["blog",":slug"], + templateTreePositions: [], layoutTreePositions: [0,1], templates: [], errors: [null, null], @@ -8508,6 +8607,7 @@ const routes = [ routeHandler: null, layouts: [mod_1, mod_6], routeSegments: ["dashboard"], + templateTreePositions: [1], layoutTreePositions: [0,1], templates: [mod_7], errors: [null, mod_9], @@ -8700,7 +8800,7 @@ function findIntercept(pathname) { return null; } -async function buildPageElement(route, params, opts, searchParams) { +async function buildPageElements(route, params, routePath, opts, searchParams) { const PageComponent = route.page?.default; if (!PageComponent) { return createElement("div", null, "Page has no default export"); @@ -8801,13 +8901,13 @@ async function buildPageElement(route, params, opts, searchParams) { // dynamic, and this avoids false positives from React internals. if (hasSearchParams) markDynamicUsage(); } - return __buildAppPageRouteElement({ + return __buildAppPageElements({ element: createElement(PageComponent, pageProps), - globalErrorModule: null, makeThenableParams, matchedParams: params, resolvedMetadata, resolvedViewport, + routePath, rootNotFoundModule: null, route, slotOverrides: @@ -9491,7 +9591,13 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { searchParams: url.searchParams, params: actionParams, }); - element = buildPageElement(actionRoute, actionParams, undefined, url.searchParams); + element = buildPageElements( + actionRoute, + actionParams, + cleanPathname, + undefined, + url.searchParams, + ); } else { element = createElement("div", null, "Page not found"); } @@ -9815,7 +9921,13 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { return _runWithUnifiedCtx(__revalUCtx, async () => { _ensureFetchPatch(); setNavigationContext({ pathname: cleanPathname, searchParams: new URLSearchParams(), params }); - const __revalElement = await buildPageElement(route, params, undefined, new URLSearchParams()); + const __revalElement = await buildPageElements( + route, + params, + cleanPathname, + undefined, + new URLSearchParams(), + ); const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); const __revalRscCapture = __teeAppPageRscStreamForCapture(__revalRscStream, true); @@ -9864,7 +9976,15 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // If the target URL matches an intercepting route in a parallel slot, // render the source route with the intercepting page in the slot. const __interceptResult = await __resolveAppPageIntercept({ - buildPageElement, + buildPageElement(interceptRoute, interceptParams, interceptOpts, interceptSearchParams) { + return buildPageElements( + interceptRoute, + interceptParams, + cleanPathname, + interceptOpts, + interceptSearchParams, + ); + }, cleanPathname, currentRoute: route, findIntercept, @@ -9912,7 +10032,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const __pageBuildResult = await __buildAppPageElement({ buildPageElement() { - return buildPageElement(route, params, interceptOpts, url.searchParams); + return buildPageElements(route, params, cleanPathname, interceptOpts, url.searchParams); }, renderErrorBoundaryPage(buildErr) { return renderErrorBoundaryPage(route, buildErr, isRscRequest, request, params); @@ -10137,7 +10257,7 @@ import { renderAppPageHttpAccessFallback as __renderAppPageHttpAccessFallback, } from "/packages/vinext/src/server/app-page-boundary-render.js"; import { - buildAppPageRouteElement as __buildAppPageRouteElement, + buildAppPageElements as __buildAppPageElements, resolveAppPageChildSegments as __resolveAppPageChildSegments, } from "/packages/vinext/src/server/app-page-route-wiring.js"; import { @@ -10451,6 +10571,7 @@ const routes = [ routeHandler: null, layouts: [mod_1], routeSegments: [], + templateTreePositions: [], layoutTreePositions: [0], templates: [], errors: [null], @@ -10473,6 +10594,7 @@ const routes = [ routeHandler: null, layouts: [mod_1], routeSegments: ["about"], + templateTreePositions: [], layoutTreePositions: [0], templates: [], errors: [null], @@ -10495,6 +10617,7 @@ const routes = [ routeHandler: null, layouts: [mod_1, mod_4], routeSegments: ["blog",":slug"], + templateTreePositions: [], layoutTreePositions: [0,1], templates: [], errors: [null, null], @@ -10517,6 +10640,7 @@ const routes = [ routeHandler: null, layouts: [mod_1, mod_6], routeSegments: ["dashboard"], + templateTreePositions: [1], layoutTreePositions: [0,1], templates: [mod_7], errors: [null, mod_9], @@ -10703,7 +10827,7 @@ function findIntercept(pathname) { return null; } -async function buildPageElement(route, params, opts, searchParams) { +async function buildPageElements(route, params, routePath, opts, searchParams) { const PageComponent = route.page?.default; if (!PageComponent) { return createElement("div", null, "Page has no default export"); @@ -10804,13 +10928,13 @@ async function buildPageElement(route, params, opts, searchParams) { // dynamic, and this avoids false positives from React internals. if (hasSearchParams) markDynamicUsage(); } - return __buildAppPageRouteElement({ + return __buildAppPageElements({ element: createElement(PageComponent, pageProps), - globalErrorModule: null, makeThenableParams, matchedParams: params, resolvedMetadata, resolvedViewport, + routePath, rootNotFoundModule: null, route, slotOverrides: @@ -11858,7 +11982,13 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { searchParams: url.searchParams, params: actionParams, }); - element = buildPageElement(actionRoute, actionParams, undefined, url.searchParams); + element = buildPageElements( + actionRoute, + actionParams, + cleanPathname, + undefined, + url.searchParams, + ); } else { element = createElement("div", null, "Page not found"); } @@ -12182,7 +12312,13 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { return _runWithUnifiedCtx(__revalUCtx, async () => { _ensureFetchPatch(); setNavigationContext({ pathname: cleanPathname, searchParams: new URLSearchParams(), params }); - const __revalElement = await buildPageElement(route, params, undefined, new URLSearchParams()); + const __revalElement = await buildPageElements( + route, + params, + cleanPathname, + undefined, + new URLSearchParams(), + ); const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); const __revalRscCapture = __teeAppPageRscStreamForCapture(__revalRscStream, true); @@ -12231,7 +12367,15 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // If the target URL matches an intercepting route in a parallel slot, // render the source route with the intercepting page in the slot. const __interceptResult = await __resolveAppPageIntercept({ - buildPageElement, + buildPageElement(interceptRoute, interceptParams, interceptOpts, interceptSearchParams) { + return buildPageElements( + interceptRoute, + interceptParams, + cleanPathname, + interceptOpts, + interceptSearchParams, + ); + }, cleanPathname, currentRoute: route, findIntercept, @@ -12279,7 +12423,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const __pageBuildResult = await __buildAppPageElement({ buildPageElement() { - return buildPageElement(route, params, interceptOpts, url.searchParams); + return buildPageElements(route, params, cleanPathname, interceptOpts, url.searchParams); }, renderErrorBoundaryPage(buildErr) { return renderErrorBoundaryPage(route, buildErr, isRscRequest, request, params); diff --git a/tests/app-browser-entry.test.ts b/tests/app-browser-entry.test.ts new file mode 100644 index 000000000..32856e299 --- /dev/null +++ b/tests/app-browser-entry.test.ts @@ -0,0 +1,163 @@ +import React from "react"; +import { describe, expect, it, vi } from "vite-plus/test"; +import { + APP_ROOT_LAYOUT_KEY, + APP_ROUTE_KEY, + normalizeAppElements, + type AppElements, +} from "../packages/vinext/src/server/app-elements.js"; +import { createClientNavigationRenderSnapshot } from "../packages/vinext/src/shims/navigation.js"; +import { + applyAppRouterStateUpdate, + createPendingNavigationCommit, + routerReducer, + type AppRouterState, +} from "../packages/vinext/src/server/app-browser-state.js"; + +function createResolvedElements( + routeId: string, + rootLayoutTreePath: string | null, + extraEntries: Record = {}, +) { + return Promise.resolve( + normalizeAppElements({ + [APP_ROUTE_KEY]: routeId, + [APP_ROOT_LAYOUT_KEY]: rootLayoutTreePath, + ...extraEntries, + }), + ); +} + +function createState(overrides: Partial = {}): AppRouterState { + return { + elements: createResolvedElements("route:/initial", "/"), + navigationSnapshot: createClientNavigationRenderSnapshot("https://example.com/initial", {}), + renderId: 0, + rootLayoutTreePath: "/", + routeId: "route:/initial", + ...overrides, + }; +} + +describe("app browser entry state helpers", () => { + it("merges elements on navigate", async () => { + const previousElements = createResolvedElements("route:/initial", "/", { + "layout:/": React.createElement("div", null, "layout"), + }); + const nextElements = createResolvedElements("route:/next", "/", { + "page:/next": React.createElement("main", null, "next"), + }); + + const nextState = routerReducer( + createState({ + elements: previousElements, + }), + { + elements: nextElements, + navigationSnapshot: createState().navigationSnapshot, + renderId: 1, + rootLayoutTreePath: "/", + routeId: "route:/next", + type: "navigate", + }, + ); + + expect(nextState.routeId).toBe("route:/next"); + expect(nextState.rootLayoutTreePath).toBe("/"); + await expect(nextState.elements).resolves.toMatchObject({ + "layout:/": expect.anything(), + "page:/next": expect.anything(), + }); + }); + + it("replaces elements on replace", async () => { + const nextElements = createResolvedElements("route:/next", "/", { + "page:/next": React.createElement("main", null, "next"), + }); + + const nextState = routerReducer(createState(), { + elements: nextElements, + navigationSnapshot: createState().navigationSnapshot, + renderId: 1, + rootLayoutTreePath: "/", + routeId: "route:/next", + type: "replace", + }); + + expect(nextState.elements).toBe(nextElements); + await expect(nextState.elements).resolves.toMatchObject({ + "page:/next": expect.anything(), + }); + }); + + it("hard navigates instead of merging when the root layout changes", async () => { + const assign = vi.fn<(href: string) => void>(); + + const result = await applyAppRouterStateUpdate({ + commit: vi.fn(), + currentState: createState({ + rootLayoutTreePath: "/(marketing)", + }), + dispatch: vi.fn(), + nextElements: createResolvedElements("route:/dashboard", "/(dashboard)"), + onHardNavigate: assign, + targetHref: "/dashboard", + transition: (callback) => callback(), + }); + + expect(result).toEqual({ type: "hard-navigate" }); + expect(assign).toHaveBeenCalledWith("/dashboard"); + }); + + it("defers commit side effects until the payload has resolved and dispatched", async () => { + let resolveElements: ((value: AppElements) => void) | undefined; + const nextElements = new Promise((resolve) => { + resolveElements = resolve; + }); + const dispatch = vi.fn(); + const commit = vi.fn(); + + const pending = applyAppRouterStateUpdate({ + commit, + currentState: createState(), + dispatch, + nextElements, + onHardNavigate: vi.fn(), + targetHref: "/dashboard", + transition: (callback) => callback(), + }); + + expect(dispatch).not.toHaveBeenCalled(); + expect(commit).not.toHaveBeenCalled(); + + if (!resolveElements) { + throw new Error("Expected deferred elements resolver"); + } + + resolveElements( + normalizeAppElements({ + [APP_ROUTE_KEY]: "route:/dashboard", + [APP_ROOT_LAYOUT_KEY]: "/", + "page:/dashboard": React.createElement("main", null, "dashboard"), + }), + ); + + await pending; + + expect(dispatch).toHaveBeenCalledOnce(); + expect(commit).toHaveBeenCalledOnce(); + }); + + it("builds a merge commit for refresh and server-action payloads", async () => { + const refreshCommit = await createPendingNavigationCommit({ + currentState: createState(), + nextElements: createResolvedElements("route:/dashboard", "/"), + navigationSnapshot: createState().navigationSnapshot, + type: "navigate", + }); + + expect(refreshCommit.action.type).toBe("navigate"); + expect(refreshCommit.routeId).toBe("route:/dashboard"); + expect(refreshCommit.rootLayoutTreePath).toBe("/"); + }); +}); diff --git a/tests/app-elements.test.ts b/tests/app-elements.test.ts new file mode 100644 index 000000000..ceb0d5fe2 --- /dev/null +++ b/tests/app-elements.test.ts @@ -0,0 +1,68 @@ +import React from "react"; +import { describe, expect, it } from "vite-plus/test"; +import { UNMATCHED_SLOT } from "../packages/vinext/src/shims/slot.js"; +import { + APP_ROOT_LAYOUT_KEY, + APP_ROUTE_KEY, + APP_UNMATCHED_SLOT_WIRE_VALUE, + normalizeAppElements, + readAppElementsMetadata, +} from "../packages/vinext/src/server/app-elements.js"; + +describe("app elements payload helpers", () => { + it("normalizes the unmatched-slot wire marker to UNMATCHED_SLOT for slot entries", () => { + const normalized = normalizeAppElements({ + [APP_ROOT_LAYOUT_KEY]: "/", + [APP_ROUTE_KEY]: "route:/dashboard", + "page:/dashboard": React.createElement("main", null, "dashboard"), + "slot:modal:/": APP_UNMATCHED_SLOT_WIRE_VALUE, + }); + + expect(normalized["slot:modal:/"]).toBe(UNMATCHED_SLOT); + expect(normalized["page:/dashboard"]).not.toBe(UNMATCHED_SLOT); + }); + + it("does not rewrite the unmatched-slot wire marker for non-slot entries", () => { + const normalized = normalizeAppElements({ + [APP_ROOT_LAYOUT_KEY]: "/", + [APP_ROUTE_KEY]: "route:/dashboard", + "page:/dashboard": APP_UNMATCHED_SLOT_WIRE_VALUE, + }); + + expect(normalized["page:/dashboard"]).toBe(APP_UNMATCHED_SLOT_WIRE_VALUE); + }); + + it("reads route metadata from the normalized payload", () => { + const metadata = readAppElementsMetadata( + normalizeAppElements({ + [APP_ROOT_LAYOUT_KEY]: "/(dashboard)", + [APP_ROUTE_KEY]: "route:/dashboard", + "route:/dashboard": React.createElement("div", null, "route"), + }), + ); + + expect(metadata.routeId).toBe("route:/dashboard"); + expect(metadata.rootLayoutTreePath).toBe("/(dashboard)"); + }); + + it("rejects payloads with a missing __route key", () => { + expect(() => + readAppElementsMetadata( + normalizeAppElements({ + [APP_ROOT_LAYOUT_KEY]: "/", + }), + ), + ).toThrow("[vinext] Missing __route string in App Router payload"); + }); + + it("rejects payloads with an invalid __rootLayout value", () => { + expect(() => + readAppElementsMetadata( + normalizeAppElements({ + [APP_ROOT_LAYOUT_KEY]: 123, + [APP_ROUTE_KEY]: "route:/dashboard", + }), + ), + ).toThrow("[vinext] Invalid __rootLayout in App Router payload"); + }); +}); diff --git a/tests/app-page-boundary-render.test.ts b/tests/app-page-boundary-render.test.ts index b33288666..5b529d890 100644 --- a/tests/app-page-boundary-render.test.ts +++ b/tests/app-page-boundary-render.test.ts @@ -5,6 +5,7 @@ import { renderAppPageErrorBoundary, renderAppPageHttpAccessFallback, } from "../packages/vinext/src/server/app-page-boundary-render.js"; +import type { AppElements } from "../packages/vinext/src/server/app-elements.js"; function createStreamFromMarkup(markup: string): ReadableStream { return new ReadableStream({ @@ -15,10 +16,17 @@ function createStreamFromMarkup(markup: string): ReadableStream { }); } -function renderElementToStream(element: React.ReactNode): ReadableStream { +function renderElementToStream(element: React.ReactNode | AppElements): ReadableStream { + if (element !== null && typeof element === "object" && !React.isValidElement(element)) { + return createStreamFromMarkup(JSON.stringify(element)); + } return createStreamFromMarkup(ReactDOMServer.renderToStaticMarkup(element)); } +function renderWirePayloadToStream(payload: unknown): ReadableStream { + return createStreamFromMarkup(JSON.stringify(payload)); +} + function createCommonOptions() { const clearRequestContext = vi.fn(); const loadSsrHandler = vi.fn(async () => ({ @@ -60,7 +68,7 @@ function createCommonOptions() { resolveChildSegments() { return []; }, - rootLayouts: [], + rootLayouts: EMPTY_ROOT_LAYOUTS, }; } @@ -122,6 +130,15 @@ const globalErrorModule = { default: GlobalErrorBoundary as React.ComponentType, }; +type TestModule = + | typeof rootLayoutModule + | typeof leafLayoutModule + | typeof notFoundModule + | typeof routeErrorModule + | typeof globalErrorModule; + +const EMPTY_ROOT_LAYOUTS: readonly TestModule[] = []; + describe("app page boundary render helpers", () => { it("returns null when no HTTP access fallback boundary exists", async () => { const common = createCommonOptions(); @@ -175,6 +192,35 @@ describe("app page boundary render helpers", () => { expect(html).toContain('content="noindex"'); }); + it("renders HTTP access fallback RSC responses as flat payloads", async () => { + const common = createCommonOptions(); + + const response = await renderAppPageHttpAccessFallback({ + ...common, + isRscRequest: true, + matchedParams: { slug: "missing" }, + renderToReadableStream: renderWirePayloadToStream, + rootLayouts: [rootLayoutModule], + route: { + layoutTreePositions: [0, 1], + layouts: [rootLayoutModule, leafLayoutModule], + notFound: notFoundModule, + params: { slug: "missing" }, + pattern: "/posts/[slug]", + routeSegments: ["posts", "[slug]"], + }, + statusCode: 404, + }); + + expect(response?.status).toBe(404); + expect(response?.headers.get("Content-Type")).toBe("text/x-component; charset=utf-8"); + + const payload = JSON.parse((await response?.text()) ?? "{}") as Record; + expect(payload.__route).toBe("route:/posts/missing"); + expect(payload.__rootLayout).toBe("/"); + expect(payload["route:/posts/missing"]).toBeTruthy(); + }); + it("renders route error boundaries with sanitized errors inside layouts", async () => { const common = createCommonOptions(); const sanitizeErrorForClient = vi.fn((error: Error) => new Error(`safe:${error.message}`)); @@ -202,6 +248,37 @@ describe("app page boundary render helpers", () => { expect(html).toContain("route:safe:secret"); }); + it("renders error boundary RSC responses as flat payloads", async () => { + const common = createCommonOptions(); + + const response = await renderAppPageErrorBoundary({ + ...common, + error: new Error("secret"), + isRscRequest: true, + matchedParams: { slug: "post" }, + renderToReadableStream: renderWirePayloadToStream, + route: { + error: routeErrorModule, + layoutTreePositions: [0], + layouts: [rootLayoutModule], + params: { slug: "post" }, + pattern: "/posts/[slug]", + routeSegments: ["posts", "[slug]"], + }, + sanitizeErrorForClient(error: Error) { + return new Error(`safe:${error.message}`); + }, + }); + + expect(response?.status).toBe(200); + expect(response?.headers.get("Content-Type")).toBe("text/x-component; charset=utf-8"); + + const payload = JSON.parse((await response?.text()) ?? "{}") as Record; + expect(payload.__route).toBe("route:/posts/missing"); + expect(payload.__rootLayout).toBe("/"); + expect(payload["route:/posts/missing"]).toBeTruthy(); + }); + it("renders global-error boundaries without layout wrapping", async () => { const common = createCommonOptions(); diff --git a/tests/app-page-route-wiring.test.ts b/tests/app-page-route-wiring.test.ts index 0e3826922..f4d151ce0 100644 --- a/tests/app-page-route-wiring.test.ts +++ b/tests/app-page-route-wiring.test.ts @@ -3,6 +3,7 @@ import ReactDOMServer from "react-dom/server"; import { describe, expect, it } from "vite-plus/test"; import { useSelectedLayoutSegments } from "../packages/vinext/src/shims/navigation.js"; import { + buildAppPageElements, buildAppPageRouteElement, createAppPageLayoutEntries, resolveAppPageChildSegments, @@ -149,4 +150,56 @@ describe("app page route wiring helpers", () => { expect(html).toContain('data-segments="(marketing)|blog|post"'); expect(html).toContain('data-segments="blog|post"'); }); + + it("builds a flat elements map with route, layout, template, page, and slot entries", () => { + const elements = buildAppPageElements({ + element: createElement(PageProbe), + makeThenableParams(params) { + return Promise.resolve(params); + }, + matchedParams: { slug: "post" }, + resolvedMetadata: null, + resolvedViewport: {}, + route: { + error: null, + errors: [null, null], + layoutTreePositions: [0, 1], + layouts: [{ default: RootLayout }, { default: GroupLayout }], + loading: null, + notFound: null, + notFounds: [null, null], + routeSegments: ["(marketing)", "blog", "[slug]"], + slots: { + sidebar: { + default: null, + error: null, + layout: { default: SlotLayout }, + layoutIndex: 0, + loading: null, + page: { default: SlotPage }, + }, + }, + templateTreePositions: [1], + templates: [{ default: Template }], + }, + routePath: "/blog/post", + rootNotFoundModule: null, + slotOverrides: { + sidebar: { + pageModule: { default: SlotPage }, + params: { slug: "post" }, + props: { label: "intercepted" }, + }, + }, + }); + + expect(elements.__route).toBe("route:/blog/post"); + expect(elements.__rootLayout).toBe("/"); + expect(elements["layout:/"]).toBeDefined(); + expect(elements["layout:/(marketing)"]).toBeDefined(); + expect(elements["template:/(marketing)"]).toBeDefined(); + expect(elements["page:/blog/post"]).toBeDefined(); + expect(elements["slot:sidebar:/"]).toBeDefined(); + expect(elements["route:/blog/post"]).toBeDefined(); + }); }); diff --git a/tests/app-router.test.ts b/tests/app-router.test.ts index 29871a2a0..dd0f4e1d5 100644 --- a/tests/app-router.test.ts +++ b/tests/app-router.test.ts @@ -128,6 +128,24 @@ describe("App Router integration", () => { expect(text.length).toBeGreaterThan(0); }); + it("returns flat payload metadata for app route RSC responses", async () => { + const res = await fetch(`${baseUrl}/dashboard.rsc`, { + headers: { Accept: "text/x-component" }, + }); + const rscText = await res.text(); + if (res.status !== 200) { + throw new Error(rscText); + } + expect(res.headers.get("content-type")).toContain("text/x-component"); + expect(rscText).toContain("__route"); + expect(rscText).toContain("__rootLayout"); + expect(rscText).toContain("route:/dashboard"); + expect(rscText).toContain("layout:/"); + expect(rscText).toContain("layout:/dashboard"); + expect(rscText).toContain("slot:team:/dashboard"); + expect(rscText).toContain("slot:analytics:/dashboard"); + }); + it("wraps pages in the root layout", async () => { const res = await fetch(`${baseUrl}/about`); const html = await res.text(); diff --git a/tests/entry-templates.test.ts b/tests/entry-templates.test.ts index 3e1fd6e37..702d408e6 100644 --- a/tests/entry-templates.test.ts +++ b/tests/entry-templates.test.ts @@ -48,6 +48,7 @@ const minimalAppRoutes: AppRoute[] = [ forbiddenPath: null, unauthorizedPath: null, routeSegments: [], + templateTreePositions: [], layoutTreePositions: [0], isDynamic: false, params: [], @@ -68,6 +69,7 @@ const minimalAppRoutes: AppRoute[] = [ forbiddenPath: null, unauthorizedPath: null, routeSegments: ["about"], + templateTreePositions: [], layoutTreePositions: [0], isDynamic: false, params: [], @@ -88,6 +90,7 @@ const minimalAppRoutes: AppRoute[] = [ forbiddenPath: null, unauthorizedPath: null, routeSegments: ["blog", ":slug"], + templateTreePositions: [], layoutTreePositions: [0, 1], isDynamic: true, params: ["slug"], @@ -108,6 +111,7 @@ const minimalAppRoutes: AppRoute[] = [ forbiddenPath: null, unauthorizedPath: null, routeSegments: ["dashboard"], + templateTreePositions: [1], layoutTreePositions: [0, 1], isDynamic: false, params: [], diff --git a/tests/slot.test.ts b/tests/slot.test.ts index 62d52b40a..af6d13296 100644 --- a/tests/slot.test.ts +++ b/tests/slot.test.ts @@ -177,6 +177,20 @@ describe("slot primitives", () => { expect(errors).toEqual([]); }); + it("normalizes the server unmatched-slot marker to the client sentinel", async () => { + const { normalizeAppElements, APP_UNMATCHED_SLOT_WIRE_VALUE } = + await import("../packages/vinext/src/server/app-elements.js"); + const mod = await import("../packages/vinext/src/shims/slot.js"); + + const normalized = normalizeAppElements({ + __rootLayout: "/", + __route: "route:/dashboard", + "slot:modal:/": APP_UNMATCHED_SLOT_WIRE_VALUE, + }); + + expect(normalized["slot:modal:/"]).toBe(mod.UNMATCHED_SLOT); + }); + it("mergeElementsPromise shallow-merges previous and next elements", async () => { const { mergeElementsPromise } = await import("../packages/vinext/src/shims/slot.js"); From ec008fa3adc43485c48d0a34deec9ffc76988ffd Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Thu, 2 Apr 2026 14:08:20 +1100 Subject: [PATCH 07/32] fix: address review findings in flat payload implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix stale closure on readBrowserRouterState by using a useRef updated synchronously during render instead of a closure captured in useLayoutEffect. External callers (navigate, server actions, HMR) now always read the current router state. - Restore GlobalErrorBoundary wrapping that was dropped when switching from buildPageElement to buildAppPageElements. Apps with app/global-error.tsx now get their global error boundary back. - Add exhaustive default case to routerReducer so new action types produce a compile error and a runtime throw instead of silent undefined. - Remove dead code: createRouteNodeSnapshot, AppRouteNodeSnapshot, AppRouteNodeValue were defined but never imported. - Remove deprecated buildAppPageRouteElement and its test — no production callers remain after the flat payload cutover. - Short-circuit normalizeAppElements when no slot keys need rewriting to avoid unnecessary allocation on every payload. - Align test data in error boundary RSC payload test (matchedParams slug: "post" -> "missing" to match requestUrl /posts/missing). --- packages/vinext/src/entries/app-rsc-entry.ts | 1 + .../vinext/src/server/app-browser-entry.ts | 28 +-- .../vinext/src/server/app-browser-state.ts | 15 +- packages/vinext/src/server/app-elements.ts | 13 +- .../src/server/app-page-route-wiring.tsx | 164 +----------------- .../entry-templates.test.ts.snap | 6 + tests/app-page-boundary-render.test.ts | 4 +- tests/app-page-route-wiring.test.ts | 54 ------ 8 files changed, 48 insertions(+), 237 deletions(-) diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index fe18feddf..7b8227c8c 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -985,6 +985,7 @@ async function buildPageElements(route, params, routePath, opts, searchParams) { } return __buildAppPageElements({ element: createElement(PageComponent, pageProps), + globalErrorModule: ${globalErrorVar ? globalErrorVar : "null"}, makeThenableParams, matchedParams: params, resolvedMetadata, diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index 49c3bb15f..c2a39c6e0 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -6,6 +6,7 @@ import { use, useLayoutEffect, useReducer, + useRef, type Dispatch, type ReactNode, } from "react"; @@ -98,7 +99,7 @@ let activeNavigationId = 0; const pendingNavigationCommits = new Map void>(); const pendingNavigationPrePaintEffects = new Map void>(); let dispatchBrowserRouterAction: Dispatch | null = null; -let readBrowserRouterState: (() => AppRouterState) | null = null; +let browserRouterStateRef: { current: AppRouterState } | null = null; let latestClientParams: Record = {}; const visitedResponseCache = new Map(); @@ -114,10 +115,10 @@ function getBrowserRouterDispatch(): Dispatch { } function getBrowserRouterState(): AppRouterState { - if (!readBrowserRouterState) { + if (!browserRouterStateRef) { throw new Error("[vinext] Browser router state is not initialized"); } - return readBrowserRouterState(); + return browserRouterStateRef.current; } function applyClientParams(params: Record): void { @@ -325,16 +326,21 @@ function BrowserRoot({ routeId: initialMetadata.routeId, }); - // Assign the module-level setter via useLayoutEffect instead of during render - // to avoid side effects that React Strict Mode / concurrent features may - // call multiple times. useLayoutEffect fires synchronously during commit, - // before hydrateRoot returns to main(), so the router dispatch is available - // before __VINEXT_RSC_NAVIGATE__ is assigned. dispatchTreeState is referentially - // stable so the effect only runs on mount. + // Keep the latest router state in a ref so external callers (navigate(), + // server actions, HMR) always read the current state. The ref is updated + // synchronously during render -- not in an effect -- so there is no stale + // window between React committing a new state and the effect firing. + const stateRef = useRef(treeState); + stateRef.current = treeState; + browserRouterStateRef = stateRef; + + // Assign the module-level dispatch via useLayoutEffect. dispatchTreeState + // is referentially stable so the effect only runs on mount. The effect fires + // synchronously during commit, before hydrateRoot returns to main(), so the + // dispatch is available before __VINEXT_RSC_NAVIGATE__ is assigned. useLayoutEffect(() => { dispatchBrowserRouterAction = dispatchTreeState; - readBrowserRouterState = () => treeState; - }, [dispatchTreeState, treeState]); + }, [dispatchTreeState]); const committedTree = createElement( NavigationCommitSignal, diff --git a/packages/vinext/src/server/app-browser-state.ts b/packages/vinext/src/server/app-browser-state.ts index 10ea50649..696ee76ed 100644 --- a/packages/vinext/src/server/app-browser-state.ts +++ b/packages/vinext/src/server/app-browser-state.ts @@ -1,4 +1,3 @@ -import type { ReactNode } from "react"; import { mergeElementsPromise } from "../shims/slot.js"; import { readAppElementsMetadata, type AppElements } from "./app-elements.js"; import type { ClientNavigationRenderSnapshot } from "../shims/navigation.js"; @@ -44,6 +43,10 @@ export function routerReducer(state: AppRouterState, action: AppRouterAction): A rootLayoutTreePath: action.rootLayoutTreePath, routeId: action.routeId, }; + default: { + const _exhaustive: never = action.type; + throw new Error("[vinext] Unknown router action: " + String(_exhaustive)); + } } } @@ -112,13 +115,3 @@ export async function applyAppRouterStateUpdate(options: { return { type: "dispatched" }; } - -export function createRouteNodeSnapshot( - elements: Promise, - routeId: string, -): { elements: Promise; routeId: string } { - return { elements, routeId }; -} - -export type AppRouteNodeSnapshot = ReturnType; -export type AppRouteNodeValue = ReactNode; diff --git a/packages/vinext/src/server/app-elements.ts b/packages/vinext/src/server/app-elements.ts index e04828971..f1e4a8930 100644 --- a/packages/vinext/src/server/app-elements.ts +++ b/packages/vinext/src/server/app-elements.ts @@ -18,8 +18,19 @@ export type AppElementsMetadata = { }; export function normalizeAppElements(elements: AppWireElements): AppElements { - const normalized: Record = {}; + let needsNormalization = false; + for (const [key, value] of Object.entries(elements)) { + if (key.startsWith("slot:") && value === APP_UNMATCHED_SLOT_WIRE_VALUE) { + needsNormalization = true; + break; + } + } + if (!needsNormalization) { + return elements; + } + + const normalized: Record = {}; for (const [key, value] of Object.entries(elements)) { normalized[key] = key.startsWith("slot:") && value === APP_UNMATCHED_SLOT_WIRE_VALUE ? UNMATCHED_SLOT : value; diff --git a/packages/vinext/src/server/app-page-route-wiring.tsx b/packages/vinext/src/server/app-page-route-wiring.tsx index a605fe877..421c8b717 100644 --- a/packages/vinext/src/server/app-page-route-wiring.tsx +++ b/packages/vinext/src/server/app-page-route-wiring.tsx @@ -94,7 +94,7 @@ export type BuildAppPageRouteElementOptions< export type BuildAppPageElementsOptions< TModule extends AppPageModule = AppPageModule, TErrorModule extends AppPageErrorModule = AppPageErrorModule, -> = Omit, "globalErrorModule"> & { +> = BuildAppPageRouteElementOptions & { routePath: string; }; @@ -475,6 +475,11 @@ export function buildAppPageElements< ); } + const globalErrorComponent = getErrorBoundaryExport(options.globalErrorModule); + if (globalErrorComponent) { + routeChildren = {routeChildren}; + } + elements[routeId] = ( <> {createAppPageRouteHead(options.resolvedMetadata, options.resolvedViewport)} @@ -484,160 +489,3 @@ export function buildAppPageElements< return elements; } - -export function buildAppPageRouteElement< - TModule extends AppPageModule, - TErrorModule extends AppPageErrorModule, ->(options: BuildAppPageRouteElementOptions): ReactNode { - /** - * @deprecated PR 2c introduces buildAppPageElements() for the flat payload - * cutover. Keep this helper during the transition so intermediate test runs - * remain stable, then delete it only after all call sites have switched. - */ - let element: ReactNode = ( - {options.element} - ); - - element = ( - <> - - {options.resolvedMetadata ? : null} - - {element} - - ); - - const loadingComponent = getDefaultExport(options.route.loading); - if (loadingComponent) { - const LoadingComponent = loadingComponent; - element = }>{element}; - } - - const lastLayoutErrorModule = - options.route.errors && options.route.errors.length > 0 - ? options.route.errors[options.route.errors.length - 1] - : null; - const pageErrorComponent = getErrorBoundaryExport(options.route.error); - if (pageErrorComponent && options.route.error !== lastLayoutErrorModule) { - element = {element}; - } - - const notFoundComponent = - getDefaultExport(options.route.notFound) ?? getDefaultExport(options.rootNotFoundModule); - if (notFoundComponent) { - const NotFoundComponent = notFoundComponent; - element = }>{element}; - } - - const templates = options.route.templates ?? []; - for (let index = templates.length - 1; index >= 0; index--) { - const templateComponent = getDefaultExport(templates[index]); - if (!templateComponent) { - continue; - } - const TemplateComponent = templateComponent; - element = {element}; - } - - const routeSlots = options.route.slots ?? {}; - const layoutEntries = createAppPageLayoutEntries(options.route); - const routeThenableParams = options.makeThenableParams(options.matchedParams); - - for (let index = layoutEntries.length - 1; index >= 0; index--) { - const layoutEntry = layoutEntries[index]; - const layoutErrorComponent = getErrorBoundaryExport(layoutEntry.errorModule); - if (layoutErrorComponent) { - element = {element}; - } - - const layoutComponent = getDefaultExport(layoutEntry.layoutModule); - if (!layoutComponent) { - continue; - } - - const layoutNotFoundComponent = getDefaultExport(layoutEntry.notFoundModule); - if (layoutNotFoundComponent) { - const LayoutNotFoundComponent = layoutNotFoundComponent; - element = ( - }>{element} - ); - } - - const layoutProps: Record = { - params: routeThenableParams, - }; - - for (const [slotName, slot] of Object.entries(routeSlots)) { - const targetIndex = slot.layoutIndex >= 0 ? slot.layoutIndex : layoutEntries.length - 1; - if (index !== targetIndex) { - continue; - } - - const slotOverride = options.slotOverrides?.[slotName]; - const slotParams = slotOverride?.params ?? options.matchedParams; - const slotComponent = - getDefaultExport(slotOverride?.pageModule) ?? - getDefaultExport(slot.page) ?? - getDefaultExport(slot.default); - if (!slotComponent) { - continue; - } - - const slotProps: Record = { - params: options.makeThenableParams(slotParams), - }; - if (slotOverride?.props) { - Object.assign(slotProps, slotOverride.props); - } - - const SlotComponent = slotComponent; - let slotElement: ReactNode = ; - - const slotLayoutComponent = getDefaultExport(slot.layout); - if (slotLayoutComponent) { - const SlotLayoutComponent = slotLayoutComponent; - slotElement = ( - - {slotElement} - - ); - } - - const slotLoadingComponent = getDefaultExport(slot.loading); - if (slotLoadingComponent) { - const SlotLoadingComponent = slotLoadingComponent; - slotElement = }>{slotElement}; - } - - const slotErrorComponent = getErrorBoundaryExport(slot.error); - if (slotErrorComponent) { - slotElement = {slotElement}; - } - - layoutProps[slotName] = slotElement; - } - - const LayoutComponent = layoutComponent; - element = {element}; - element = ( - - {element} - - ); - } - - const globalErrorComponent = getErrorBoundaryExport(options.globalErrorModule); - if (globalErrorComponent) { - element = {element}; - } - - return element; -} diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index fa31701cc..e370e373b 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -751,6 +751,7 @@ async function buildPageElements(route, params, routePath, opts, searchParams) { } return __buildAppPageElements({ element: createElement(PageComponent, pageProps), + globalErrorModule: null, makeThenableParams, matchedParams: params, resolvedMetadata, @@ -2778,6 +2779,7 @@ async function buildPageElements(route, params, routePath, opts, searchParams) { } return __buildAppPageElements({ element: createElement(PageComponent, pageProps), + globalErrorModule: null, makeThenableParams, matchedParams: params, resolvedMetadata, @@ -4809,6 +4811,7 @@ async function buildPageElements(route, params, routePath, opts, searchParams) { } return __buildAppPageElements({ element: createElement(PageComponent, pageProps), + globalErrorModule: mod_11, makeThenableParams, matchedParams: params, resolvedMetadata, @@ -6866,6 +6869,7 @@ async function buildPageElements(route, params, routePath, opts, searchParams) { } return __buildAppPageElements({ element: createElement(PageComponent, pageProps), + globalErrorModule: null, makeThenableParams, matchedParams: params, resolvedMetadata, @@ -8903,6 +8907,7 @@ async function buildPageElements(route, params, routePath, opts, searchParams) { } return __buildAppPageElements({ element: createElement(PageComponent, pageProps), + globalErrorModule: null, makeThenableParams, matchedParams: params, resolvedMetadata, @@ -10930,6 +10935,7 @@ async function buildPageElements(route, params, routePath, opts, searchParams) { } return __buildAppPageElements({ element: createElement(PageComponent, pageProps), + globalErrorModule: null, makeThenableParams, matchedParams: params, resolvedMetadata, diff --git a/tests/app-page-boundary-render.test.ts b/tests/app-page-boundary-render.test.ts index 5b529d890..060e0acf2 100644 --- a/tests/app-page-boundary-render.test.ts +++ b/tests/app-page-boundary-render.test.ts @@ -255,13 +255,13 @@ describe("app page boundary render helpers", () => { ...common, error: new Error("secret"), isRscRequest: true, - matchedParams: { slug: "post" }, + matchedParams: { slug: "missing" }, renderToReadableStream: renderWirePayloadToStream, route: { error: routeErrorModule, layoutTreePositions: [0], layouts: [rootLayoutModule], - params: { slug: "post" }, + params: { slug: "missing" }, pattern: "/posts/[slug]", routeSegments: ["posts", "[slug]"], }, diff --git a/tests/app-page-route-wiring.test.ts b/tests/app-page-route-wiring.test.ts index f4d151ce0..c4ca42c2e 100644 --- a/tests/app-page-route-wiring.test.ts +++ b/tests/app-page-route-wiring.test.ts @@ -1,10 +1,8 @@ import { createElement, isValidElement, type ReactNode } from "react"; -import ReactDOMServer from "react-dom/server"; import { describe, expect, it } from "vite-plus/test"; import { useSelectedLayoutSegments } from "../packages/vinext/src/shims/navigation.js"; import { buildAppPageElements, - buildAppPageRouteElement, createAppPageLayoutEntries, resolveAppPageChildSegments, } from "../packages/vinext/src/server/app-page-route-wiring.js"; @@ -99,58 +97,6 @@ describe("app page route wiring helpers", () => { expect(entries.map((entry) => entry.treePath)).toEqual(["/", "/(marketing)"]); }); - it("wires templates, slots, and layout segment providers from the route tree", () => { - const element = buildAppPageRouteElement({ - element: createElement(PageProbe), - makeThenableParams(params) { - return Promise.resolve(params); - }, - matchedParams: { slug: "post" }, - resolvedMetadata: null, - resolvedViewport: {}, - route: { - error: null, - errors: [null, null], - layoutTreePositions: [0, 1], - layouts: [{ default: RootLayout }, { default: GroupLayout }], - loading: null, - notFound: null, - notFounds: [null, null], - routeSegments: ["(marketing)", "blog", "[slug]"], - slots: { - sidebar: { - default: null, - error: null, - layout: { default: SlotLayout }, - layoutIndex: 0, - loading: null, - page: { default: SlotPage }, - }, - }, - templates: [{ default: Template }], - }, - rootNotFoundModule: null, - slotOverrides: { - sidebar: { - pageModule: { default: SlotPage }, - params: { slug: "post" }, - props: { label: "intercepted" }, - }, - }, - }); - - const html = ReactDOMServer.renderToStaticMarkup(element); - - expect(html).toContain('data-layout="root"'); - expect(html).toContain('data-layout="group"'); - expect(html).toContain('data-template="group"'); - expect(html).toContain('data-slot-layout="sidebar"'); - expect(html).toContain('data-slot-page="intercepted"'); - expect(html).toContain('data-page-segments=""'); - expect(html).toContain('data-segments="(marketing)|blog|post"'); - expect(html).toContain('data-segments="blog|post"'); - }); - it("builds a flat elements map with route, layout, template, page, and slot entries", () => { const elements = buildAppPageElements({ element: createElement(PageProbe), From 5395efc5028671e75d14822fedf215d0e5f83e51 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Thu, 2 Apr 2026 14:52:07 +1100 Subject: [PATCH 08/32] fix: normalize flat payload after use(), not before createFromReadableStream() returns a React thenable whose .then() returns undefined (not a Promise). Chaining .then(normalizeAppElements) broke SSR by assigning undefined to flightRoot. Fix: call use() on the raw thenable, then normalize synchronously after resolution. Also widen renderAppPageLifecycle element type to accept flat map payloads. --- packages/vinext/src/server/app-page-render.ts | 4 ++-- packages/vinext/src/server/app-ssr-entry.ts | 10 ++++------ 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/packages/vinext/src/server/app-page-render.ts b/packages/vinext/src/server/app-page-render.ts index 8591cae79..ed66bf72b 100644 --- a/packages/vinext/src/server/app-page-render.ts +++ b/packages/vinext/src/server/app-page-render.ts @@ -84,14 +84,14 @@ export type RenderAppPageLifecycleOptions = { ) => Promise; renderPageSpecialError: (specialError: AppPageSpecialError) => Promise; renderToReadableStream: ( - element: ReactNode, + element: ReactNode | Record, options: { onError: AppPageBoundaryOnError }, ) => ReadableStream; routeHasLocalBoundary: boolean; routePattern: string; runWithSuppressedHookWarning(probe: () => Promise): Promise; waitUntil?: (promise: Promise) => void; - element: ReactNode; + element: ReactNode | Record; }; function buildResponseTiming( diff --git a/packages/vinext/src/server/app-ssr-entry.ts b/packages/vinext/src/server/app-ssr-entry.ts index a9a37e0e9..dee0f2e35 100644 --- a/packages/vinext/src/server/app-ssr-entry.ts +++ b/packages/vinext/src/server/app-ssr-entry.ts @@ -19,7 +19,6 @@ import { createRscEmbedTransform, createTickBufferedTransform } from "./app-ssr- import { normalizeAppElements, readAppElementsMetadata, - type AppElements, type AppWireElements, } from "./app-elements.js"; import { ElementsContext, Slot } from "../shims/slot.js"; @@ -174,15 +173,14 @@ 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).then((elements) => - normalizeAppElements(elements), - ); + flightRoot = createFromReadableStream(ssrStream); } - const elements = use(flightRoot); + const wireElements = use(flightRoot); + const elements = normalizeAppElements(wireElements); const metadata = readAppElementsMetadata(elements); return createReactElement( ElementsContext.Provider, From 955f577b61d7641d0f6af4aeb103d9bb6b68acf9 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Thu, 2 Apr 2026 15:01:08 +1100 Subject: [PATCH 09/32] fix: produce flat RSC payload on all rendering paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SSR entry always expects a flat Record with __route and __rootLayout metadata from the RSC stream. Three paths were still producing bare ReactNode payloads: 1. renderAppPageBoundaryElementResponse only created the flat map for isRscRequest=true, but HTML requests also flow through RSC→SSR 2. buildPageElements "no default export" early return 3. Server action "Page not found" fallback All three now produce the flat keyed element map, fixing 17 test failures across 404/not-found, forbidden/unauthorized, error boundary, production build, rewrite, and encoded-slash paths. --- packages/vinext/src/entries/app-rsc-entry.ts | 14 ++++++++++++-- .../vinext/src/server/app-page-boundary-render.ts | 14 ++++++-------- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index 7b8227c8c..8e89a2f5b 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -885,7 +885,12 @@ function findIntercept(pathname) { async function buildPageElements(route, params, routePath, opts, searchParams) { const PageComponent = route.page?.default; if (!PageComponent) { - return createElement("div", null, "Page has no default export"); + const _noExportRouteId = "route:" + routePath; + return { + __route: _noExportRouteId, + __rootLayout: route.layouts?.length > 0 ? "/" : null, + [_noExportRouteId]: createElement("div", null, "Page has no default export"), + }; } // Resolve metadata and viewport from layouts and page. @@ -1711,7 +1716,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { url.searchParams, ); } else { - element = createElement("div", null, "Page not found"); + const _actionRouteId = "route:" + cleanPathname; + element = { + __route: _actionRouteId, + __rootLayout: null, + [_actionRouteId]: createElement("div", null, "Page not found"), + }; } const onRenderError = createRscOnErrorHandler( diff --git a/packages/vinext/src/server/app-page-boundary-render.ts b/packages/vinext/src/server/app-page-boundary-render.ts index 871ded9de..2982acb6d 100644 --- a/packages/vinext/src/server/app-page-boundary-render.ts +++ b/packages/vinext/src/server/app-page-boundary-render.ts @@ -255,14 +255,12 @@ async function renderAppPageBoundaryElementResponse { const pathname = new URL(options.requestUrl).pathname; - const payload = options.isRscRequest - ? createAppPageBoundaryRscPayload({ - element: options.element, - layoutModules: options.layoutModules, - pathname, - route: options.route, - }) - : options.element; + const payload = createAppPageBoundaryRscPayload({ + element: options.element, + layoutModules: options.layoutModules, + pathname, + route: options.route, + }); return renderAppPageBoundaryResponse({ async createHtmlResponse(rscStream, responseStatus) { From ce762397e6c1a8b79e9f4f7e6d48a4fe3efbb540 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Thu, 2 Apr 2026 15:13:20 +1100 Subject: [PATCH 10/32] test: update unit tests for flat RSC payload on all paths - Update renderElementToStream mock to extract the route element from the flat map before rendering to HTML (mirrors real SSR entry flow) - Update entry template snapshots for the buildPageElements changes --- .../entry-templates.test.ts.snap | 84 ++++++++++++++++--- tests/app-page-boundary-render.test.ts | 9 ++ 2 files changed, 81 insertions(+), 12 deletions(-) diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index e370e373b..3eaca0adc 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -651,7 +651,12 @@ function findIntercept(pathname) { async function buildPageElements(route, params, routePath, opts, searchParams) { const PageComponent = route.page?.default; if (!PageComponent) { - return createElement("div", null, "Page has no default export"); + const _noExportRouteId = "route:" + routePath; + return { + __route: _noExportRouteId, + __rootLayout: route.layouts?.length > 0 ? "/" : null, + [_noExportRouteId]: createElement("div", null, "Page has no default export"), + }; } // Resolve metadata and viewport from layouts and page. @@ -1448,7 +1453,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { url.searchParams, ); } else { - element = createElement("div", null, "Page not found"); + const _actionRouteId = "route:" + cleanPathname; + element = { + __route: _actionRouteId, + __rootLayout: null, + [_actionRouteId]: createElement("div", null, "Page not found"), + }; } const onRenderError = createRscOnErrorHandler( @@ -2679,7 +2689,12 @@ function findIntercept(pathname) { async function buildPageElements(route, params, routePath, opts, searchParams) { const PageComponent = route.page?.default; if (!PageComponent) { - return createElement("div", null, "Page has no default export"); + const _noExportRouteId = "route:" + routePath; + return { + __route: _noExportRouteId, + __rootLayout: route.layouts?.length > 0 ? "/" : null, + [_noExportRouteId]: createElement("div", null, "Page has no default export"), + }; } // Resolve metadata and viewport from layouts and page. @@ -3479,7 +3494,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { url.searchParams, ); } else { - element = createElement("div", null, "Page not found"); + const _actionRouteId = "route:" + cleanPathname; + element = { + __route: _actionRouteId, + __rootLayout: null, + [_actionRouteId]: createElement("div", null, "Page not found"), + }; } const onRenderError = createRscOnErrorHandler( @@ -4711,7 +4731,12 @@ function findIntercept(pathname) { async function buildPageElements(route, params, routePath, opts, searchParams) { const PageComponent = route.page?.default; if (!PageComponent) { - return createElement("div", null, "Page has no default export"); + const _noExportRouteId = "route:" + routePath; + return { + __route: _noExportRouteId, + __rootLayout: route.layouts?.length > 0 ? "/" : null, + [_noExportRouteId]: createElement("div", null, "Page has no default export"), + }; } // Resolve metadata and viewport from layouts and page. @@ -5508,7 +5533,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { url.searchParams, ); } else { - element = createElement("div", null, "Page not found"); + const _actionRouteId = "route:" + cleanPathname; + element = { + __route: _actionRouteId, + __rootLayout: null, + [_actionRouteId]: createElement("div", null, "Page not found"), + }; } const onRenderError = createRscOnErrorHandler( @@ -6769,7 +6799,12 @@ function findIntercept(pathname) { async function buildPageElements(route, params, routePath, opts, searchParams) { const PageComponent = route.page?.default; if (!PageComponent) { - return createElement("div", null, "Page has no default export"); + const _noExportRouteId = "route:" + routePath; + return { + __route: _noExportRouteId, + __rootLayout: route.layouts?.length > 0 ? "/" : null, + [_noExportRouteId]: createElement("div", null, "Page has no default export"), + }; } // Resolve metadata and viewport from layouts and page. @@ -7569,7 +7604,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { url.searchParams, ); } else { - element = createElement("div", null, "Page not found"); + const _actionRouteId = "route:" + cleanPathname; + element = { + __route: _actionRouteId, + __rootLayout: null, + [_actionRouteId]: createElement("div", null, "Page not found"), + }; } const onRenderError = createRscOnErrorHandler( @@ -8807,7 +8847,12 @@ function findIntercept(pathname) { async function buildPageElements(route, params, routePath, opts, searchParams) { const PageComponent = route.page?.default; if (!PageComponent) { - return createElement("div", null, "Page has no default export"); + const _noExportRouteId = "route:" + routePath; + return { + __route: _noExportRouteId, + __rootLayout: route.layouts?.length > 0 ? "/" : null, + [_noExportRouteId]: createElement("div", null, "Page has no default export"), + }; } // Resolve metadata and viewport from layouts and page. @@ -9604,7 +9649,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { url.searchParams, ); } else { - element = createElement("div", null, "Page not found"); + const _actionRouteId = "route:" + cleanPathname; + element = { + __route: _actionRouteId, + __rootLayout: null, + [_actionRouteId]: createElement("div", null, "Page not found"), + }; } const onRenderError = createRscOnErrorHandler( @@ -10835,7 +10885,12 @@ function findIntercept(pathname) { async function buildPageElements(route, params, routePath, opts, searchParams) { const PageComponent = route.page?.default; if (!PageComponent) { - return createElement("div", null, "Page has no default export"); + const _noExportRouteId = "route:" + routePath; + return { + __route: _noExportRouteId, + __rootLayout: route.layouts?.length > 0 ? "/" : null, + [_noExportRouteId]: createElement("div", null, "Page has no default export"), + }; } // Resolve metadata and viewport from layouts and page. @@ -11996,7 +12051,12 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { url.searchParams, ); } else { - element = createElement("div", null, "Page not found"); + const _actionRouteId = "route:" + cleanPathname; + element = { + __route: _actionRouteId, + __rootLayout: null, + [_actionRouteId]: createElement("div", null, "Page not found"), + }; } const onRenderError = createRscOnErrorHandler( diff --git a/tests/app-page-boundary-render.test.ts b/tests/app-page-boundary-render.test.ts index 060e0acf2..a1c83a35f 100644 --- a/tests/app-page-boundary-render.test.ts +++ b/tests/app-page-boundary-render.test.ts @@ -18,6 +18,15 @@ function createStreamFromMarkup(markup: string): 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)); From c7a03d5e136c98bd66f22865c4692f851491435c Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Thu, 2 Apr 2026 15:22:51 +1100 Subject: [PATCH 11/32] fix: wrap Flight thenable in Promise.resolve() before chaining .then() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createFromReadableStream() returns a React Flight thenable whose .then() returns undefined instead of a new Promise. The browser entry's normalizeAppElementsPromise chained .then() on this raw thenable, producing undefined — which crashed use() during hydration with "An unsupported type was passed to use(): undefined". Wrapping in Promise.resolve() first converts the Flight thenable into a real Promise, making .then() chains work correctly. The same fix was already applied to the SSR entry in 5395efc but was missed in the browser entry. --- packages/vinext/src/server/app-browser-entry.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index c2a39c6e0..73f04d47e 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -306,7 +306,10 @@ function NavigationCommitSignal({ } function normalizeAppElementsPromise(payload: Promise): Promise { - return payload.then((elements) => normalizeAppElements(elements)); + // 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({ From 7fead6950b61b9664fc66d940a1af66014c60546 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Thu, 2 Apr 2026 16:29:47 +1100 Subject: [PATCH 12/32] fix: eliminate Promise from ElementsContext to fix React 19 hydration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit React 19.2.4's use(Promise) during hydration triggers "async Client Component" because native Promises lack React's internal .status property (set only by Flight thenables). When use() encounters a Promise without .status, it suspends — which React interprets as the component being async, causing a fatal error. Fix: store resolved AppElements directly in ElementsContext and router state instead of Promise. The navigation async flow (createPendingNavigationCommit) awaits the Promise before dispatching, so React state never holds a Promise. - ElementsContext: Promise → AppElements - AppRouterState.elements: Promise → AppElements - mergeElementsPromise → mergeElements (sync object spread) - Slot: useContext only, no use(Promise) - SSR entry: pass resolved elements to context - dispatchBrowserTree: simplified, no async error handler Also fix flaky instrumentation E2E test that read the last error entry instead of finding by path. --- .../vinext/src/server/app-browser-entry.ts | 33 ++++------------ .../vinext/src/server/app-browser-state.ts | 10 ++--- packages/vinext/src/server/app-ssr-entry.ts | 2 +- packages/vinext/src/shims/slot.tsx | 38 +++++-------------- tests/e2e/app-router/instrumentation.spec.ts | 4 +- 5 files changed, 25 insertions(+), 62 deletions(-) diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index 73f04d47e..f30324870 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -322,7 +322,7 @@ function BrowserRoot({ const resolvedElements = use(initialElements); const initialMetadata = readAppElementsMetadata(resolvedElements); const [treeState, dispatchTreeState] = useReducer(routerReducer, { - elements: Promise.resolve(resolvedElements), + elements: resolvedElements, navigationSnapshot: initialNavigationSnapshot, renderId: 0, rootLayoutTreePath: initialMetadata.rootLayoutTreePath, @@ -368,32 +368,16 @@ function BrowserRoot({ } function dispatchBrowserTree( - elements: Promise, + elements: AppElements, navigationSnapshot: ClientNavigationRenderSnapshot, renderId: number, actionType: "navigate" | "replace", routeId: string, rootLayoutTreePath: string | null, useTransitionMode: boolean, - snapshotActivated = false, ): void { const dispatch = getBrowserRouterDispatch(); - // 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?.(); - }; - const applyAction = () => dispatch({ elements, @@ -404,13 +388,11 @@ function dispatchBrowserTree( type: actionType, }); - void elements.then(() => { - if (useTransitionMode) { - startTransition(applyAction); - } else { - applyAction(); - } - }, handleAsyncError); + if (useTransitionMode) { + startTransition(applyAction); + } else { + applyAction(); + } } async function renderNavigationPayload( @@ -454,7 +436,6 @@ async function renderNavigationPayload( pending.routeId, pending.rootLayoutTreePath, useTransition, - true, ); } catch (error) { // Clean up pending state and decrement counter on synchronous error. diff --git a/packages/vinext/src/server/app-browser-state.ts b/packages/vinext/src/server/app-browser-state.ts index 696ee76ed..f439b798c 100644 --- a/packages/vinext/src/server/app-browser-state.ts +++ b/packages/vinext/src/server/app-browser-state.ts @@ -1,9 +1,9 @@ -import { mergeElementsPromise } from "../shims/slot.js"; +import { mergeElements } from "../shims/slot.js"; import { readAppElementsMetadata, type AppElements } from "./app-elements.js"; import type { ClientNavigationRenderSnapshot } from "../shims/navigation.js"; export type AppRouterState = { - elements: Promise; + elements: AppElements; renderId: number; navigationSnapshot: ClientNavigationRenderSnapshot; rootLayoutTreePath: string | null; @@ -11,7 +11,7 @@ export type AppRouterState = { }; export type AppRouterAction = { - elements: Promise; + elements: AppElements; navigationSnapshot: ClientNavigationRenderSnapshot; renderId: number; rootLayoutTreePath: string | null; @@ -29,7 +29,7 @@ export function routerReducer(state: AppRouterState, action: AppRouterAction): A switch (action.type) { case "navigate": return { - elements: mergeElementsPromise(state.elements, action.elements), + elements: mergeElements(state.elements, action.elements), navigationSnapshot: action.navigationSnapshot, renderId: action.renderId, rootLayoutTreePath: action.rootLayoutTreePath, @@ -73,7 +73,7 @@ export async function createPendingNavigationCommit(options: { return { action: { - elements: Promise.resolve(elements), + elements, navigationSnapshot: options.navigationSnapshot, renderId: options.renderId ?? options.currentState.renderId + 1, rootLayoutTreePath: metadata.rootLayoutTreePath, diff --git a/packages/vinext/src/server/app-ssr-entry.ts b/packages/vinext/src/server/app-ssr-entry.ts index dee0f2e35..f0a50c88d 100644 --- a/packages/vinext/src/server/app-ssr-entry.ts +++ b/packages/vinext/src/server/app-ssr-entry.ts @@ -184,7 +184,7 @@ export async function handleSsr( const metadata = readAppElementsMetadata(elements); return createReactElement( ElementsContext.Provider, - { value: Promise.resolve(elements) }, + { value: elements }, createReactElement(Slot, { id: metadata.routeId }), ); } diff --git a/packages/vinext/src/shims/slot.tsx b/packages/vinext/src/shims/slot.tsx index d5c4c6365..a5b80ded1 100644 --- a/packages/vinext/src/shims/slot.tsx +++ b/packages/vinext/src/shims/slot.tsx @@ -4,15 +4,16 @@ import * as React from "react"; import { UNMATCHED_SLOT, type AppElements } from "../server/app-elements.js"; import { notFound } from "./navigation.js"; -const EMPTY_ELEMENTS_PROMISE = Promise.resolve({}); -const mergeCache = new WeakMap< - Promise, - WeakMap, Promise> ->(); +const EMPTY_ELEMENTS: AppElements = {}; export { UNMATCHED_SLOT }; -export const ElementsContext = React.createContext>(EMPTY_ELEMENTS_PROMISE); +/** + * 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); @@ -20,27 +21,8 @@ export const ParallelSlotsContext = React.createContext > | null>(null); -export function mergeElementsPromise( - prev: Promise, - next: Promise, -): Promise { - let nextCache = mergeCache.get(prev); - if (!nextCache) { - nextCache = new WeakMap(); - mergeCache.set(prev, nextCache); - } - - const cached = nextCache.get(next); - if (cached) { - return cached; - } - - const merged = Promise.all([prev, next]).then(([prevElements, nextElements]) => ({ - ...prevElements, - ...nextElements, - })); - nextCache.set(next, merged); - return merged; +export function mergeElements(prev: AppElements, next: AppElements): AppElements { + return { ...prev, ...next }; } export function Slot({ @@ -52,7 +34,7 @@ export function Slot({ children?: React.ReactNode; parallelSlots?: Readonly>; }) { - const elements = React.use(React.useContext(ElementsContext)); + const elements = React.useContext(ElementsContext); if (!(id in elements)) { return null; 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"); From 311b10a98a5a600ffb06faa8159acd966957ac31 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Thu, 2 Apr 2026 16:34:55 +1100 Subject: [PATCH 13/32] test: update slot and browser state tests for resolved ElementsContext MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove Promise wrappers from ElementsContext test values - mergeElementsPromise → mergeElements (sync) - Replace Suspense streaming test with direct render test - Remove unused createDeferred helper and Suspense import - Update browser state test assertions (no longer async) --- tests/app-browser-entry.test.ts | 22 +++---- tests/slot.test.ts | 109 +++++++------------------------- 2 files changed, 34 insertions(+), 97 deletions(-) diff --git a/tests/app-browser-entry.test.ts b/tests/app-browser-entry.test.ts index 32856e299..813b55b11 100644 --- a/tests/app-browser-entry.test.ts +++ b/tests/app-browser-entry.test.ts @@ -19,13 +19,11 @@ function createResolvedElements( rootLayoutTreePath: string | null, extraEntries: Record = {}, ) { - return Promise.resolve( - normalizeAppElements({ - [APP_ROUTE_KEY]: routeId, - [APP_ROOT_LAYOUT_KEY]: rootLayoutTreePath, - ...extraEntries, - }), - ); + return normalizeAppElements({ + [APP_ROUTE_KEY]: routeId, + [APP_ROOT_LAYOUT_KEY]: rootLayoutTreePath, + ...extraEntries, + }); } function createState(overrides: Partial = {}): AppRouterState { @@ -64,13 +62,13 @@ describe("app browser entry state helpers", () => { expect(nextState.routeId).toBe("route:/next"); expect(nextState.rootLayoutTreePath).toBe("/"); - await expect(nextState.elements).resolves.toMatchObject({ + expect(nextState.elements).toMatchObject({ "layout:/": expect.anything(), "page:/next": expect.anything(), }); }); - it("replaces elements on replace", async () => { + it("replaces elements on replace", () => { const nextElements = createResolvedElements("route:/next", "/", { "page:/next": React.createElement("main", null, "next"), }); @@ -85,7 +83,7 @@ describe("app browser entry state helpers", () => { }); expect(nextState.elements).toBe(nextElements); - await expect(nextState.elements).resolves.toMatchObject({ + expect(nextState.elements).toMatchObject({ "page:/next": expect.anything(), }); }); @@ -99,7 +97,7 @@ describe("app browser entry state helpers", () => { rootLayoutTreePath: "/(marketing)", }), dispatch: vi.fn(), - nextElements: createResolvedElements("route:/dashboard", "/(dashboard)"), + nextElements: Promise.resolve(createResolvedElements("route:/dashboard", "/(dashboard)")), onHardNavigate: assign, targetHref: "/dashboard", transition: (callback) => callback(), @@ -151,7 +149,7 @@ describe("app browser entry state helpers", () => { it("builds a merge commit for refresh and server-action payloads", async () => { const refreshCommit = await createPendingNavigationCommit({ currentState: createState(), - nextElements: createResolvedElements("route:/dashboard", "/"), + nextElements: Promise.resolve(createResolvedElements("route:/dashboard", "/")), navigationSnapshot: createState().navigationSnapshot, type: "navigate", }); diff --git a/tests/slot.test.ts b/tests/slot.test.ts index af6d13296..e2b4adeea 100644 --- a/tests/slot.test.ts +++ b/tests/slot.test.ts @@ -1,4 +1,4 @@ -import React, { Suspense } from "react"; +import React from "react"; import { renderToReadableStream } from "react-dom/server.edge"; import { describe, expect, it, vi } from "vite-plus/test"; @@ -6,11 +6,6 @@ vi.mock("next/navigation", () => ({ usePathname: () => "/", })); -type Deferred = { - promise: Promise; - resolve: (value: T) => void; -}; - function createContextProvider( context: React.Context, value: TValue, @@ -19,20 +14,6 @@ function createContextProvider( return React.createElement(context.Provider, { value }, child); } -function createDeferred(): Deferred { - let resolvePromise: ((value: T) => void) | undefined; - const promise = new Promise((resolve) => { - resolvePromise = resolve; - }); - if (!resolvePromise) { - throw new Error("Deferred promise resolver was not created"); - } - return { - promise, - resolve: resolvePromise, - }; -} - async function readStream(stream: ReadableStream): Promise { const reader = stream.getReader(); const decoder = new TextDecoder(); @@ -62,7 +43,7 @@ describe("slot primitives", () => { expect(typeof mod.Slot).toBe("function"); expect(typeof mod.Children).toBe("function"); expect(typeof mod.ParallelSlot).toBe("function"); - expect(typeof mod.mergeElementsPromise).toBe("function"); + expect(typeof mod.mergeElements).toBe("function"); expect(mod.ElementsContext).toBeDefined(); expect(mod.ChildrenContext).toBeDefined(); expect(mod.ParallelSlotsContext).toBeDefined(); @@ -101,7 +82,7 @@ describe("slot primitives", () => { const slotElement = createContextProvider( mod.ElementsContext, - Promise.resolve({ "layout:/": React.createElement(LayoutShell) }), + { "layout:/": React.createElement(LayoutShell) }, React.createElement( mod.Slot, { @@ -125,7 +106,7 @@ describe("slot primitives", () => { const html = await renderHtml( createContextProvider( mod.ElementsContext, - Promise.resolve({}), + {}, React.createElement(mod.Slot, { id: "slot:modal:/" }), ), ); @@ -138,7 +119,7 @@ describe("slot primitives", () => { const renderPromise = renderHtml( createContextProvider( mod.ElementsContext, - Promise.resolve({ "slot:modal:/": mod.UNMATCHED_SLOT }), + { "slot:modal:/": mod.UNMATCHED_SLOT }, React.createElement(mod.Slot, { id: "slot:modal:/" }), ), ); @@ -158,7 +139,7 @@ describe("slot primitives", () => { const stream = await renderToReadableStream( createContextProvider( mod.ElementsContext, - Promise.resolve({ "slot:modal:/": null }), + { "slot:modal:/": null }, React.createElement(mod.Slot, { id: "slot:modal:/" }), ), { @@ -191,18 +172,18 @@ describe("slot primitives", () => { expect(normalized["slot:modal:/"]).toBe(mod.UNMATCHED_SLOT); }); - it("mergeElementsPromise shallow-merges previous and next elements", async () => { - const { mergeElementsPromise } = await import("../packages/vinext/src/shims/slot.js"); + it("mergeElements shallow-merges previous and next elements", async () => { + const { mergeElements } = await import("../packages/vinext/src/shims/slot.js"); - const merged = await mergeElementsPromise( - Promise.resolve({ + const merged = mergeElements( + { "layout:/": React.createElement("div", null, "layout"), "slot:modal:/": React.createElement("div", null, "previous slot"), - }), - Promise.resolve({ + }, + { "page:/blog/hello": React.createElement("div", null, "page"), "slot:modal:/": React.createElement("div", null, "next slot"), - }), + }, ); expect(Object.keys(merged)).toEqual(["layout:/", "slot:modal:/", "page:/blog/hello"]); @@ -211,69 +192,27 @@ describe("slot primitives", () => { expect(merged["slot:modal:/"]).not.toBeNull(); }); - it("mergeElementsPromise caches by input promise pair", async () => { - const { mergeElementsPromise } = await import("../packages/vinext/src/shims/slot.js"); - - const previous = Promise.resolve({ "layout:/": React.createElement("div", null, "layout") }); - const next = Promise.resolve({ "page:/blog/hello": React.createElement("div", null, "page") }); - - const first = mergeElementsPromise(previous, next); - const second = mergeElementsPromise(previous, next); - const third = mergeElementsPromise(previous, Promise.resolve({})); - - expect(first).toBe(second); - expect(first).not.toBe(third); - }); - - it("Slot suspends on the elements promise and streams the Suspense fallback first", async () => { + it("Slot renders element from resolved context", async () => { const mod = await import("../packages/vinext/src/shims/slot.js"); - const deferred = createDeferred>>(); const stream = await renderToReadableStream( - React.createElement( - Suspense, - { fallback: React.createElement("p", null, "loading slot") }, - createContextProvider( - mod.ElementsContext, - deferred.promise, - React.createElement(mod.Slot, { id: "layout:/" }), - ), + createContextProvider( + mod.ElementsContext, + { "layout:/": React.createElement("div", null, "resolved slot") }, + React.createElement(mod.Slot, { id: "layout:/" }), ), ); const reader = stream.getReader(); const decoder = new TextDecoder(); - const firstChunkPromise = reader.read(); - const firstReadState = await Promise.race([ - firstChunkPromise.then(() => "resolved"), - Promise.resolve("pending"), - ]); - - expect(firstReadState).toBe("pending"); - - const resolvedPromise = new Promise((resolve) => { - setTimeout(() => { - deferred.resolve({ - "layout:/": React.createElement("div", null, "resolved slot"), - }); - resolve(); - }, 20); - }); - - const firstChunk = await firstChunkPromise; - const firstHtml = decoder.decode(firstChunk.value, { stream: true }); - await resolvedPromise; - - let rest = ""; + let html = ""; for (;;) { const { done, value } = await reader.read(); - if (done) { - break; - } - rest += decoder.decode(value, { stream: true }); + if (done) break; + html += decoder.decode(value, { stream: true }); } - rest += decoder.decode(); + html += decoder.decode(); - expect(firstHtml + rest).toContain("resolved slot"); - }, 10000); + expect(html).toContain("resolved slot"); + }); }); From 2b5f68cd56be3ef57da319eea572a847aefb9cdd Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Thu, 2 Apr 2026 16:38:55 +1100 Subject: [PATCH 14/32] ci: retrigger From 7554b2070587e3693e0b6cf8509a9bb614d7cce8 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Thu, 2 Apr 2026 17:09:36 +1100 Subject: [PATCH 15/32] fix: address code review findings (P1-P3) P1a: mergeElements preserves previous slot content when the new payload marks a parallel slot as unmatched. On soft navigation, unmatched slots keep their previous subtree instead of triggering notFound(). P1b: renderNavigationPayload now receives navId and checks for superseded navigations after its await. Stale payloads are discarded instead of being dispatched into the React tree. P2: The catch block in renderNavigationPayload only calls commitClientNavigationState() when activateNavigationSnapshot() was actually reached, preventing counter underflow. P3: The no-default-export fallback in buildPageElements now derives the root layout tree path from route.layoutTreePositions and route.routeSegments instead of hardcoding "/". --- packages/vinext/src/entries/app-rsc-entry.ts | 8 +- .../vinext/src/server/app-browser-entry.ts | 31 +++++-- .../src/server/app-page-route-wiring.tsx | 61 ++++++++++++-- .../src/server/app-render-dependency.tsx | 67 +++++++++++++++ packages/vinext/src/shims/slot.tsx | 13 ++- .../entry-templates.test.ts.snap | 48 +++++++++-- tests/app-render-dependency.test.ts | 84 +++++++++++++++++++ tests/slot.test.ts | 43 ++++++++++ 8 files changed, 335 insertions(+), 20 deletions(-) create mode 100644 packages/vinext/src/server/app-render-dependency.tsx create mode 100644 tests/app-render-dependency.test.ts diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index 8e89a2f5b..0e9faf7e8 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -886,9 +886,15 @@ async function buildPageElements(route, params, routePath, opts, searchParams) { const PageComponent = route.page?.default; if (!PageComponent) { const _noExportRouteId = "route:" + routePath; + let _noExportRootLayout = null; + if (route.layouts?.length > 0) { + const _tp = route.layoutTreePositions?.[0] ?? 0; + const _segs = route.routeSegments?.slice(0, _tp) ?? []; + _noExportRootLayout = _segs.length === 0 ? "/" : "/" + _segs.join("/"); + } return { __route: _noExportRouteId, - __rootLayout: route.layouts?.length > 0 ? "/" : null, + __rootLayout: _noExportRootLayout, [_noExportRouteId]: createElement("div", null, "Page has no default export"), }; } diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index f30324870..fe5c02003 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -399,6 +399,7 @@ async function renderNavigationPayload( payload: Promise, navigationSnapshot: ClientNavigationRenderSnapshot, targetHref: string, + navId: number, prePaintEffect: (() => void) | null = null, useTransition = true, actionType: "navigate" | "replace" = "navigate", @@ -408,8 +409,7 @@ async function renderNavigationPayload( pendingNavigationCommits.set(renderId, resolve); }); - // 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 { const currentState = getBrowserRouterState(); const pending = await createPendingNavigationCommit({ @@ -420,6 +420,16 @@ async function renderNavigationPayload( 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); @@ -428,6 +438,7 @@ async function renderNavigationPayload( queuePrePaintNavigationEffect(renderId, prePaintEffect); activateNavigationSnapshot(); + snapshotActivated = true; dispatchBrowserTree( pending.action.elements, navigationSnapshot, @@ -438,13 +449,18 @@ async function renderNavigationPayload( 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; @@ -719,6 +735,7 @@ async function main(): Promise { cachedPayload, cachedNavigationSnapshot, href, + navId, createNavigationCommitEffect(href, historyUpdateMode, cachedParams), isSameRoute, ); @@ -799,6 +816,7 @@ async function main(): Promise { rscPayload, navigationSnapshot, href, + navId, createNavigationCommitEffect(href, historyUpdateMode, navParams), isSameRoute, ); @@ -810,6 +828,9 @@ async function main(): Promise { // catch from double-decrementing navigationSnapshotActiveCount. _snapshotPending = false; } + // Don't cache the response if this navigation was superseded during + // renderNavigationPayload's await — the elements were never dispatched. + if (navId !== activeNavigationId) return; // Store the visited response only after renderNavigationPayload succeeds. // If we stored it before and renderNavigationPayload threw, a future // back/forward navigation could replay a snapshot from a navigation that diff --git a/packages/vinext/src/server/app-page-route-wiring.tsx b/packages/vinext/src/server/app-page-route-wiring.tsx index 421c8b717..40f2a69d6 100644 --- a/packages/vinext/src/server/app-page-route-wiring.tsx +++ b/packages/vinext/src/server/app-page-route-wiring.tsx @@ -10,6 +10,12 @@ 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; @@ -285,12 +291,37 @@ export function buildAppPageElements< const pageId = `page:${options.routePath}`; const layoutEntries = createAppPageLayoutEntries(options.route); const templateEntries = createAppPageTemplateEntries(options.route); + const templateEntriesByTreePosition = new Map( + templateEntries.map((entry) => [entry.treePosition, entry] as const), + ); + const layoutDependencies = layoutEntries.map(() => createAppRenderDependency()); + 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; + for (let index = 0; index < layoutEntries.length; index++) { + layoutDependenciesBefore[index] = [...pageDependencies]; + pageDependencies.push(layoutDependencies[index]); + slotDependenciesByLayoutIndex[index] = [...pageDependencies]; + + const templateEntry = templateEntriesByTreePosition.get(layoutEntries[index].treePosition); + if (!templateEntry) { + continue; + } + + const templateDependency = createAppRenderDependency(); + templateDependenciesById.set(templateEntry.id, templateDependency); + templateDependenciesBeforeById.set(templateEntry.id, [...pageDependencies]); + pageDependencies.push(templateDependency); + } + elements[APP_ROUTE_KEY] = routeId; elements[APP_ROOT_LAYOUT_KEY] = rootLayoutTreePath; - elements[pageId] = options.element; + elements[pageId] = renderAfterAppDependencies(options.element, pageDependencies); for (const templateEntry of templateEntries) { const templateComponent = getDefaultExport(templateEntry.templateModule); @@ -298,8 +329,19 @@ export function buildAppPageElements< continue; } const TemplateComponent = templateComponent; - elements[templateEntry.id] = ( - {} + const templateDependency = templateDependenciesById.get(templateEntry.id); + const templateElement = ( + + {templateDependency ? ( + renderWithAppDependencyBarrier(, templateDependency) + ) : ( + + )} + + ); + elements[templateEntry.id] = renderAfterAppDependencies( + templateElement, + templateDependenciesBeforeById.get(templateEntry.id) ?? [], ); } @@ -323,11 +365,15 @@ export function buildAppPageElements< } const LayoutComponent = layoutComponent; - elements[layoutEntry.id] = ( + const layoutElement = ( - + {renderWithAppDependencyBarrier(, layoutDependencies[index])} ); + elements[layoutEntry.id] = renderAfterAppDependencies( + layoutElement, + layoutDependenciesBefore[index] ?? [], + ); } for (const [slotName, slot] of Object.entries(options.route.slots ?? {})) { @@ -377,7 +423,10 @@ export function buildAppPageElements< slotElement = {slotElement}; } - elements[slotId] = slotElement; + elements[slotId] = renderAfterAppDependencies( + slotElement, + targetIndex >= 0 ? (slotDependenciesByLayoutIndex[targetIndex] ?? []) : [], + ); } let routeChildren: ReactNode = ( 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..fc3df918d --- /dev/null +++ b/packages/vinext/src/server/app-render-dependency.tsx @@ -0,0 +1,67 @@ +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; + } + + return ( + {children} + ); +} + +export function renderWithAppDependencyBarrier( + children: ReactNode, + dependency: AppRenderDependency, +): ReactNode { + return ( + <> + + {children} + + ); +} + +async function AwaitAppRenderDependencies({ + children, + dependencies, +}: { + children: ReactNode; + dependencies: readonly AppRenderDependency[]; +}) { + await Promise.all(dependencies.map((dependency) => dependency.promise)); + return children; +} + +function ReleaseAppRenderDependency({ dependency }: { dependency: AppRenderDependency }) { + dependency.release(); + return null; +} diff --git a/packages/vinext/src/shims/slot.tsx b/packages/vinext/src/shims/slot.tsx index a5b80ded1..8c8d738bb 100644 --- a/packages/vinext/src/shims/slot.tsx +++ b/packages/vinext/src/shims/slot.tsx @@ -1,7 +1,7 @@ "use client"; import * as React from "react"; -import { UNMATCHED_SLOT, type AppElements } from "../server/app-elements.js"; +import { UNMATCHED_SLOT, type AppElementValue, type AppElements } from "../server/app-elements.js"; import { notFound } from "./navigation.js"; const EMPTY_ELEMENTS: AppElements = {}; @@ -22,7 +22,16 @@ export const ParallelSlotsContext = React.createContext | null>(null); export function mergeElements(prev: AppElements, next: AppElements): AppElements { - return { ...prev, ...next }; + 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({ diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index 3eaca0adc..ade238e1a 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -652,9 +652,15 @@ async function buildPageElements(route, params, routePath, opts, searchParams) { const PageComponent = route.page?.default; if (!PageComponent) { const _noExportRouteId = "route:" + routePath; + let _noExportRootLayout = null; + if (route.layouts?.length > 0) { + const _tp = route.layoutTreePositions?.[0] ?? 0; + const _segs = route.routeSegments?.slice(0, _tp) ?? []; + _noExportRootLayout = _segs.length === 0 ? "/" : "/" + _segs.join("/"); + } return { __route: _noExportRouteId, - __rootLayout: route.layouts?.length > 0 ? "/" : null, + __rootLayout: _noExportRootLayout, [_noExportRouteId]: createElement("div", null, "Page has no default export"), }; } @@ -2690,9 +2696,15 @@ async function buildPageElements(route, params, routePath, opts, searchParams) { const PageComponent = route.page?.default; if (!PageComponent) { const _noExportRouteId = "route:" + routePath; + let _noExportRootLayout = null; + if (route.layouts?.length > 0) { + const _tp = route.layoutTreePositions?.[0] ?? 0; + const _segs = route.routeSegments?.slice(0, _tp) ?? []; + _noExportRootLayout = _segs.length === 0 ? "/" : "/" + _segs.join("/"); + } return { __route: _noExportRouteId, - __rootLayout: route.layouts?.length > 0 ? "/" : null, + __rootLayout: _noExportRootLayout, [_noExportRouteId]: createElement("div", null, "Page has no default export"), }; } @@ -4732,9 +4744,15 @@ async function buildPageElements(route, params, routePath, opts, searchParams) { const PageComponent = route.page?.default; if (!PageComponent) { const _noExportRouteId = "route:" + routePath; + let _noExportRootLayout = null; + if (route.layouts?.length > 0) { + const _tp = route.layoutTreePositions?.[0] ?? 0; + const _segs = route.routeSegments?.slice(0, _tp) ?? []; + _noExportRootLayout = _segs.length === 0 ? "/" : "/" + _segs.join("/"); + } return { __route: _noExportRouteId, - __rootLayout: route.layouts?.length > 0 ? "/" : null, + __rootLayout: _noExportRootLayout, [_noExportRouteId]: createElement("div", null, "Page has no default export"), }; } @@ -6800,9 +6818,15 @@ async function buildPageElements(route, params, routePath, opts, searchParams) { const PageComponent = route.page?.default; if (!PageComponent) { const _noExportRouteId = "route:" + routePath; + let _noExportRootLayout = null; + if (route.layouts?.length > 0) { + const _tp = route.layoutTreePositions?.[0] ?? 0; + const _segs = route.routeSegments?.slice(0, _tp) ?? []; + _noExportRootLayout = _segs.length === 0 ? "/" : "/" + _segs.join("/"); + } return { __route: _noExportRouteId, - __rootLayout: route.layouts?.length > 0 ? "/" : null, + __rootLayout: _noExportRootLayout, [_noExportRouteId]: createElement("div", null, "Page has no default export"), }; } @@ -8848,9 +8872,15 @@ async function buildPageElements(route, params, routePath, opts, searchParams) { const PageComponent = route.page?.default; if (!PageComponent) { const _noExportRouteId = "route:" + routePath; + let _noExportRootLayout = null; + if (route.layouts?.length > 0) { + const _tp = route.layoutTreePositions?.[0] ?? 0; + const _segs = route.routeSegments?.slice(0, _tp) ?? []; + _noExportRootLayout = _segs.length === 0 ? "/" : "/" + _segs.join("/"); + } return { __route: _noExportRouteId, - __rootLayout: route.layouts?.length > 0 ? "/" : null, + __rootLayout: _noExportRootLayout, [_noExportRouteId]: createElement("div", null, "Page has no default export"), }; } @@ -10886,9 +10916,15 @@ async function buildPageElements(route, params, routePath, opts, searchParams) { const PageComponent = route.page?.default; if (!PageComponent) { const _noExportRouteId = "route:" + routePath; + let _noExportRootLayout = null; + if (route.layouts?.length > 0) { + const _tp = route.layoutTreePositions?.[0] ?? 0; + const _segs = route.routeSegments?.slice(0, _tp) ?? []; + _noExportRootLayout = _segs.length === 0 ? "/" : "/" + _segs.join("/"); + } return { __route: _noExportRouteId, - __rootLayout: route.layouts?.length > 0 ? "/" : null, + __rootLayout: _noExportRootLayout, [_noExportRouteId]: createElement("div", null, "Page has no default export"), }; } diff --git a/tests/app-render-dependency.test.ts b/tests/app-render-dependency.test.ts new file mode 100644 index 000000000..0ebd90b22 --- /dev/null +++ b/tests/app-render-dependency.test.ts @@ -0,0 +1,84 @@ +import { createElement } from "react"; +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 renderFlight(model: unknown): Promise { + const { renderToReadableStream } = await import("@vitejs/plugin-rsc/rsc"); + const stream = renderToReadableStream(model, { + onError(error: unknown) { + throw error instanceof Error ? error : new Error(String(error)); + }, + }); + + return readStream(stream); +} + +describe("app render dependency helpers", () => { + it("documents that Flight can serialize 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 payload = { + layout: createElement(LocaleLayout), + page: createElement(LocalePage), + }; + + const body = await renderFlight(payload); + + 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 payload = { + layout: createElement(LocaleLayout), + page: renderAfterAppDependencies(createElement(LocalePage), [layoutDependency]), + }; + + const body = await renderFlight(payload); + + expect(body).toContain("page:de"); + expect(body).not.toContain("page:en"); + }); +}); diff --git a/tests/slot.test.ts b/tests/slot.test.ts index e2b4adeea..e3ced8088 100644 --- a/tests/slot.test.ts +++ b/tests/slot.test.ts @@ -1,6 +1,7 @@ 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: () => "/", @@ -192,6 +193,48 @@ describe("slot primitives", () => { 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"); From 1014aed13cb9f0085618e91324f8a1a838d33bdf Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Thu, 2 Apr 2026 17:17:51 +1100 Subject: [PATCH 16/32] fix: avoid serializing app render dependency wrappers --- .../src/server/app-render-dependency.tsx | 32 +++++++----------- tests/app-render-dependency.test.ts | 33 +++++++++---------- 2 files changed, 28 insertions(+), 37 deletions(-) diff --git a/packages/vinext/src/server/app-render-dependency.tsx b/packages/vinext/src/server/app-render-dependency.tsx index fc3df918d..c1b57a75e 100644 --- a/packages/vinext/src/server/app-render-dependency.tsx +++ b/packages/vinext/src/server/app-render-dependency.tsx @@ -33,35 +33,27 @@ export function renderAfterAppDependencies( return children; } - 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} ); } - -async function AwaitAppRenderDependencies({ - children, - dependencies, -}: { - children: ReactNode; - dependencies: readonly AppRenderDependency[]; -}) { - await Promise.all(dependencies.map((dependency) => dependency.promise)); - return children; -} - -function ReleaseAppRenderDependency({ dependency }: { dependency: AppRenderDependency }) { - dependency.release(); - return null; -} diff --git a/tests/app-render-dependency.test.ts b/tests/app-render-dependency.test.ts index 0ebd90b22..03f57da35 100644 --- a/tests/app-render-dependency.test.ts +++ b/tests/app-render-dependency.test.ts @@ -1,4 +1,5 @@ import { createElement } from "react"; +import { renderToReadableStream } from "react-dom/server.edge"; import { describe, expect, it } from "vite-plus/test"; import { createAppRenderDependency, @@ -22,19 +23,18 @@ async function readStream(stream: ReadableStream): Promise { return text + decoder.decode(); } -async function renderFlight(model: unknown): Promise { - const { renderToReadableStream } = await import("@vitejs/plugin-rsc/rsc"); - const stream = renderToReadableStream(model, { +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 Flight can serialize a sync sibling before an async sibling completes", async () => { + it("documents that React can render a sync sibling before an async sibling completes", async () => { let activeLocale = "en"; async function LocaleLayout() { @@ -47,12 +47,9 @@ describe("app render dependency helpers", () => { return createElement("p", null, `page:${activeLocale}`); } - const payload = { - layout: createElement(LocaleLayout), - page: createElement(LocalePage), - }; - - const body = await renderFlight(payload); + const body = await renderHtml( + createElement("div", null, createElement(LocaleLayout), createElement(LocalePage)), + ); expect(body).toContain("page:en"); }); @@ -71,12 +68,14 @@ describe("app render dependency helpers", () => { return createElement("p", null, `page:${activeLocale}`); } - const payload = { - layout: createElement(LocaleLayout), - page: renderAfterAppDependencies(createElement(LocalePage), [layoutDependency]), - }; - - const body = await renderFlight(payload); + const body = await renderHtml( + createElement( + "div", + null, + createElement(LocaleLayout), + renderAfterAppDependencies(createElement(LocalePage), [layoutDependency]), + ), + ); expect(body).toContain("page:de"); expect(body).not.toContain("page:en"); From 5e516bd2522055c61d56141c126f841eb25a91aa Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Thu, 2 Apr 2026 17:25:07 +1100 Subject: [PATCH 17/32] Fix flat payload dependency barriers --- .../src/server/app-page-route-wiring.tsx | 68 +++++--- .../src/server/app-render-dependency.tsx | 2 +- tests/app-page-route-wiring.test.ts | 145 +++++++++++++++++- 3 files changed, 194 insertions(+), 21 deletions(-) diff --git a/packages/vinext/src/server/app-page-route-wiring.tsx b/packages/vinext/src/server/app-page-route-wiring.tsx index 40f2a69d6..f662db3fd 100644 --- a/packages/vinext/src/server/app-page-route-wiring.tsx +++ b/packages/vinext/src/server/app-page-route-wiring.tsx @@ -291,10 +291,15 @@ export function buildAppPageElements< const pageId = `page:${options.routePath}`; const layoutEntries = createAppPageLayoutEntries(options.route); const templateEntries = createAppPageTemplateEntries(options.route); - const templateEntriesByTreePosition = new Map( - templateEntries.map((entry) => [entry.treePosition, entry] as const), - ); - const layoutDependencies = layoutEntries.map(() => createAppRenderDependency()); + const templateEntriesByTreePosition = new Map>(); + 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(); @@ -302,14 +307,28 @@ export function buildAppPageElements< 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]; + } - for (let index = 0; index < layoutEntries.length; index++) { - layoutDependenciesBefore[index] = [...pageDependencies]; - pageDependencies.push(layoutDependencies[index]); - slotDependenciesByLayoutIndex[index] = [...pageDependencies]; - - const templateEntry = templateEntriesByTreePosition.get(layoutEntries[index].treePosition); - if (!templateEntry) { + const templateEntry = templateEntriesByTreePosition.get(treePosition); + if (!templateEntry || !getDefaultExport(templateEntry.templateModule)) { continue; } @@ -330,13 +349,16 @@ export function buildAppPageElements< } const TemplateComponent = templateComponent; const templateDependency = templateDependenciesById.get(templateEntry.id); - const templateElement = ( - - {templateDependency ? ( - renderWithAppDependencyBarrier(, templateDependency) - ) : ( + const templateElement = templateDependency ? ( + renderWithAppDependencyBarrier( + - )} + , + templateDependency, + ) + ) : ( + + ); elements[templateEntry.id] = renderAfterAppDependencies( @@ -365,9 +387,17 @@ export function buildAppPageElements< } const LayoutComponent = layoutComponent; - const layoutElement = ( + const layoutDependency = layoutDependenciesByIndex.get(index); + const layoutElement = layoutDependency ? ( + renderWithAppDependencyBarrier( + + + , + layoutDependency, + ) + ) : ( - {renderWithAppDependencyBarrier(, layoutDependencies[index])} + ); elements[layoutEntry.id] = renderAfterAppDependencies( diff --git a/packages/vinext/src/server/app-render-dependency.tsx b/packages/vinext/src/server/app-render-dependency.tsx index c1b57a75e..09182280b 100644 --- a/packages/vinext/src/server/app-render-dependency.tsx +++ b/packages/vinext/src/server/app-render-dependency.tsx @@ -52,8 +52,8 @@ export function renderWithAppDependencyBarrier( return ( <> - {children} + ); } diff --git a/tests/app-page-route-wiring.test.ts b/tests/app-page-route-wiring.test.ts index c4ca42c2e..99286cfa9 100644 --- a/tests/app-page-route-wiring.test.ts +++ b/tests/app-page-route-wiring.test.ts @@ -1,4 +1,4 @@ -import { createElement, isValidElement, type ReactNode } from "react"; +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 { @@ -33,6 +33,52 @@ function readChildren(value: unknown): ReactNode { 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 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( @@ -75,6 +121,10 @@ function PageProbe() { 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( @@ -148,4 +198,97 @@ describe("app page route wiring helpers", () => { 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"); + }); }); From ee2fbddf9339768eefde07113a3164d7d84d514d Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Thu, 2 Apr 2026 17:38:43 +1100 Subject: [PATCH 18/32] Fix template-only route wrappers --- .../src/server/app-page-route-wiring.tsx | 30 +++++++---- tests/app-page-route-wiring.test.ts | 53 +++++++++++++++++++ 2 files changed, 73 insertions(+), 10 deletions(-) diff --git a/packages/vinext/src/server/app-page-route-wiring.tsx b/packages/vinext/src/server/app-page-route-wiring.tsx index f662db3fd..1909780a6 100644 --- a/packages/vinext/src/server/app-page-route-wiring.tsx +++ b/packages/vinext/src/server/app-page-route-wiring.tsx @@ -291,7 +291,11 @@ export function buildAppPageElements< const pageId = `page:${options.routePath}`; const layoutEntries = createAppPageLayoutEntries(options.route); const templateEntries = createAppPageTemplateEntries(options.route); + const layoutEntriesByTreePosition = new Map>(); const templateEntriesByTreePosition = new Map>(); + for (const layoutEntry of layoutEntries) { + layoutEntriesByTreePosition.set(layoutEntry.treePosition, layoutEntry); + } for (const templateEntry of templateEntries) { templateEntriesByTreePosition.set(templateEntry.treePosition, templateEntry); } @@ -489,14 +493,11 @@ export function buildAppPageElements< ); } - for (let index = layoutEntries.length - 1; index >= 0; index--) { - const layoutEntry = layoutEntries[index]; - let layoutChildren = routeChildren; - const templateEntry = templateEntries.find( - (entry) => entry.treePosition === layoutEntry.treePosition, - ); + for (let index = orderedTreePositions.length - 1; index >= 0; index--) { + const treePosition = orderedTreePositions[index]; + const templateEntry = templateEntriesByTreePosition.get(treePosition); if (templateEntry) { - layoutChildren = ( + routeChildren = ( - {layoutChildren} + {routeChildren} ); } + const layoutEntry = layoutEntriesByTreePosition.get(treePosition); + if (!layoutEntry) { + continue; + } + let layoutChildren = routeChildren; const layoutErrorComponent = getErrorBoundaryExport(layoutEntry.errorModule); if (layoutErrorComponent) { layoutChildren = ( @@ -538,7 +544,7 @@ export function buildAppPageElements< .filter(([, slot]) => { const targetIndex = slot.layoutIndex >= 0 ? slot.layoutIndex : layoutEntries.length - 1; - return targetIndex === index; + return targetIndex === layoutIndicesByTreePosition.get(treePosition); }) .map(([slotName]) => [slotName, []]), ), @@ -546,7 +552,11 @@ export function buildAppPageElements< > {layoutChildren} diff --git a/tests/app-page-route-wiring.test.ts b/tests/app-page-route-wiring.test.ts index 99286cfa9..8ca0844d8 100644 --- a/tests/app-page-route-wiring.test.ts +++ b/tests/app-page-route-wiring.test.ts @@ -1,6 +1,7 @@ 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, @@ -60,6 +61,17 @@ async function renderHtml(node: ReactNode): Promise { 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(() => { @@ -291,4 +303,45 @@ describe("app page route wiring helpers", () => { 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"); + }); }); From 9bf09a88ca50e4cd3cdf6bc8622db439be871931 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Thu, 2 Apr 2026 17:55:57 +1100 Subject: [PATCH 19/32] test: add E2E verification for layout persistence flat payload pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prove the flat keyed map architecture works end-to-end: - Layout state persists across sibling navigation (counter survives) - Template remounts on segment boundary change, persists within segment - Error boundary clears on navigate-away-and-back - Back/forward preserves layout state through history - Parallel slots persist on soft nav, show default.tsx on hard nav Zero production code changes — test fixtures and Playwright specs only. --- .../e2e/app-router/layout-persistence.spec.ts | 228 ++++++++++++++++++ .../app/components/layout-counter.tsx | 21 ++ .../app/components/template-counter.tsx | 22 ++ .../app-basic/app/dashboard/layout.tsx | 9 + .../app-basic/app/error-test/error.tsx | 5 + tests/fixtures/app-basic/app/page.tsx | 3 + tests/fixtures/app-basic/app/template.tsx | 3 + 7 files changed, 291 insertions(+) create mode 100644 tests/e2e/app-router/layout-persistence.spec.ts create mode 100644 tests/fixtures/app-basic/app/components/layout-counter.tsx create mode 100644 tests/fixtures/app-basic/app/components/template-counter.tsx 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/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/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}
); From 3a27ea078eb078ec67e7adbfaefc30dad826dce5 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Thu, 2 Apr 2026 19:15:42 +1100 Subject: [PATCH 20/32] feat: encode interception context in App Router payload IDs and caches --- packages/vinext/src/entries/app-rsc-entry.ts | 15 +- .../vinext/src/server/app-browser-entry.ts | 149 +++++++++++++++--- .../vinext/src/server/app-browser-state.ts | 7 + packages/vinext/src/server/app-elements.ts | 48 ++++++ .../src/server/app-page-boundary-render.ts | 11 +- .../src/server/app-page-route-wiring.tsx | 10 +- packages/vinext/src/shims/link.tsx | 19 ++- packages/vinext/src/shims/navigation.ts | 64 ++++++-- packages/vinext/src/shims/next-shims.d.ts | 6 +- tests/app-browser-entry.test.ts | 31 +++- tests/app-elements.test.ts | 44 ++++++ tests/app-router.test.ts | 12 +- tests/e2e/app-router/advanced.spec.ts | 90 ++++++++--- tests/fixtures/app-basic/app/feed/page.tsx | 18 ++- tests/fixtures/app-basic/app/gallery/page.tsx | 16 ++ tests/prefetch-cache.test.ts | 13 ++ 16 files changed, 478 insertions(+), 75 deletions(-) create mode 100644 tests/fixtures/app-basic/app/gallery/page.tsx diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index 0e9faf7e8..bbd1c691d 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -55,6 +55,7 @@ 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, @@ -378,6 +379,10 @@ 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, @@ -885,7 +890,8 @@ function findIntercept(pathname) { async function buildPageElements(route, params, routePath, opts, searchParams) { const PageComponent = route.page?.default; if (!PageComponent) { - const _noExportRouteId = "route:" + routePath; + const _interceptionContext = opts?.interceptionContext ?? null; + const _noExportRouteId = __createAppPayloadRouteId(routePath, _interceptionContext); let _noExportRootLayout = null; if (route.layouts?.length > 0) { const _tp = route.layoutTreePositions?.[0] ?? 0; @@ -893,6 +899,7 @@ async function buildPageElements(route, params, routePath, opts, searchParams) { _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"), @@ -1001,6 +1008,7 @@ async function buildPageElements(route, params, routePath, opts, searchParams) { matchedParams: params, resolvedMetadata, resolvedViewport, + interceptionContext: opts?.interceptionContext ?? null, routePath, rootNotFoundModule: ${rootNotFoundVar ? rootNotFoundVar : "null"}, route, @@ -1340,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 @@ -1722,8 +1731,9 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { url.searchParams, ); } else { - const _actionRouteId = "route:" + cleanPathname; + const _actionRouteId = __createAppPayloadRouteId(cleanPathname, null); element = { + [__APP_INTERCEPTION_CONTEXT_KEY]: null, __route: _actionRouteId, __rootLayout: null, [_actionRouteId]: createElement("div", null, "Page not found"), @@ -2177,6 +2187,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { setNavigationContext, toInterceptOpts(intercept) { return { + interceptionContext: interceptionContextHeader, interceptSlot: intercept.slotName, interceptPage: intercept.page, interceptParams: intercept.matchedParams, diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index fe5c02003..7787d1238 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -26,6 +26,7 @@ import { commitClientNavigationState, consumePrefetchResponse, createClientNavigationRenderSnapshot, + getCurrentInterceptionContext, getClientNavigationRenderContext, getPrefetchCache, getPrefetchedUrls, @@ -47,8 +48,10 @@ import { getVinextBrowserGlobal, } from "./app-browser-stream.js"; import { + createAppPayloadCacheKey, normalizeAppElements, readAppElementsMetadata, + resolveVisitedResponseInterceptionContext, type AppElements, type AppWireElements, } from "./app-elements.js"; @@ -82,6 +85,11 @@ type VisitedResponseCacheEntry = { const MAX_VISITED_RESPONSE_CACHE_SIZE = 50; const VISITED_RESPONSE_CACHE_TTL = 5 * 60_000; const MAX_TRAVERSAL_CACHE_TTL = 30 * 60_000; +const VINEXT_INTERCEPTION_CONTEXT_HISTORY_STATE_KEY = "__vinext_interceptionContext"; + +type HistoryStateRecord = { + [key: string]: unknown; +}; // These are plain module-level variables, unlike ClientNavigationState in // navigation.ts which uses Symbol.for to survive multiple Vite module instances. @@ -189,15 +197,21 @@ function createNavigationCommitEffect( href: string, historyUpdateMode: HistoryUpdateMode | undefined, params: Record, + interceptionContext: string | null, ): () => void { return () => { const targetHref = new URL(href, window.location.origin).href; stageClientParams(params); + const preserveExistingState = historyUpdateMode === "replace"; + const historyState = createHistoryStateWithInterceptionContext( + preserveExistingState ? window.history.state : null, + interceptionContext, + ); if (historyUpdateMode === "replace" && window.location.href !== targetHref) { - replaceHistoryStateWithoutNotify(null, "", href); + replaceHistoryStateWithoutNotify(historyState, "", href); } else if (historyUpdateMode === "push" && window.location.href !== targetHref) { - pushHistoryStateWithoutNotify(null, "", href); + pushHistoryStateWithoutNotify(historyState, "", href); } commitClientNavigationState(); @@ -216,9 +230,11 @@ function evictVisitedResponseCacheIfNeeded(): void { function getVisitedResponse( rscUrl: string, + interceptionContext: string | null, navigationKind: NavigationKind, ): VisitedResponseCacheEntry | null { - const cached = visitedResponseCache.get(rscUrl); + const cacheKey = createAppPayloadCacheKey(rscUrl, interceptionContext); + const cached = visitedResponseCache.get(cacheKey); if (!cached) { return null; } @@ -230,41 +246,98 @@ function getVisitedResponse( if (navigationKind === "traverse") { const createdAt = cached.expiresAt - VISITED_RESPONSE_CACHE_TTL; if (Date.now() - createdAt >= MAX_TRAVERSAL_CACHE_TTL) { - visitedResponseCache.delete(rscUrl); + visitedResponseCache.delete(cacheKey); return null; } // LRU: promote to most-recently-used (delete + re-insert moves to end of Map) - visitedResponseCache.delete(rscUrl); - visitedResponseCache.set(rscUrl, cached); + visitedResponseCache.delete(cacheKey); + visitedResponseCache.set(cacheKey, cached); return cached; } if (cached.expiresAt > Date.now()) { // LRU: promote to most-recently-used - visitedResponseCache.delete(rscUrl); - visitedResponseCache.set(rscUrl, cached); + visitedResponseCache.delete(cacheKey); + visitedResponseCache.set(cacheKey, cached); return cached; } - visitedResponseCache.delete(rscUrl); + visitedResponseCache.delete(cacheKey); return null; } function storeVisitedResponseSnapshot( rscUrl: string, + interceptionContext: string | null, snapshot: CachedRscResponse, params: Record, ): void { - visitedResponseCache.delete(rscUrl); + const cacheKey = createAppPayloadCacheKey(rscUrl, interceptionContext); + visitedResponseCache.delete(cacheKey); evictVisitedResponseCacheIfNeeded(); const now = Date.now(); - visitedResponseCache.set(rscUrl, { + visitedResponseCache.set(cacheKey, { params, expiresAt: now + VISITED_RESPONSE_CACHE_TTL, response: snapshot, }); } +function cloneHistoryState(state: unknown): HistoryStateRecord { + if (!state || typeof state !== "object") { + return {}; + } + + const nextState: HistoryStateRecord = {}; + for (const [key, value] of Object.entries(state)) { + nextState[key] = value; + } + return nextState; +} + +function createHistoryStateWithInterceptionContext( + state: unknown, + interceptionContext: string | null, +): HistoryStateRecord | null { + const nextState = cloneHistoryState(state); + + if (interceptionContext === null) { + delete nextState[VINEXT_INTERCEPTION_CONTEXT_HISTORY_STATE_KEY]; + } else { + nextState[VINEXT_INTERCEPTION_CONTEXT_HISTORY_STATE_KEY] = interceptionContext; + } + + return Object.keys(nextState).length > 0 ? nextState : null; +} + +function readHistoryStateInterceptionContext(state: unknown): string | null { + const value = cloneHistoryState(state)[VINEXT_INTERCEPTION_CONTEXT_HISTORY_STATE_KEY]; + return typeof value === "string" ? value : null; +} + +function getRequestInterceptionContext(navigationKind: NavigationKind): string | null { + switch (navigationKind) { + case "navigate": + return getCurrentInterceptionContext(); + case "traverse": + return readHistoryStateInterceptionContext(window.history.state); + case "refresh": + return null; + default: { + const _exhaustive: never = navigationKind; + throw new Error("[vinext] Unknown navigation kind: " + String(_exhaustive)); + } + } +} + +function createRscRequestHeaders(interceptionContext: string | null): Headers { + const headers = new Headers({ Accept: "text/x-component" }); + if (interceptionContext !== null) { + headers.set("X-Vinext-Interception-Context", interceptionContext); + } + return headers; +} + /** * Resolve all pending navigation commits with renderId <= the committed renderId. * Note: Map iteration handles concurrent deletion safely — entries are visited in @@ -323,6 +396,7 @@ function BrowserRoot({ const initialMetadata = readAppElementsMetadata(resolvedElements); const [treeState, dispatchTreeState] = useReducer(routerReducer, { elements: resolvedElements, + interceptionContext: initialMetadata.interceptionContext, navigationSnapshot: initialNavigationSnapshot, renderId: 0, rootLayoutTreePath: initialMetadata.rootLayoutTreePath, @@ -372,6 +446,7 @@ function dispatchBrowserTree( navigationSnapshot: ClientNavigationRenderSnapshot, renderId: number, actionType: "navigate" | "replace", + interceptionContext: string | null, routeId: string, rootLayoutTreePath: string | null, useTransitionMode: boolean, @@ -381,6 +456,7 @@ function dispatchBrowserTree( const applyAction = () => dispatch({ elements, + interceptionContext, navigationSnapshot, renderId, rootLayoutTreePath, @@ -400,7 +476,8 @@ async function renderNavigationPayload( navigationSnapshot: ClientNavigationRenderSnapshot, targetHref: string, navId: number, - prePaintEffect: (() => void) | null = null, + historyUpdateMode: HistoryUpdateMode | undefined, + params: Record, useTransition = true, actionType: "navigate" | "replace" = "navigate", ): Promise { @@ -436,7 +513,15 @@ async function renderNavigationPayload( return; } - queuePrePaintNavigationEffect(renderId, prePaintEffect); + queuePrePaintNavigationEffect( + renderId, + createNavigationCommitEffect( + targetHref, + historyUpdateMode, + params, + pending.interceptionContext, + ), + ); activateNavigationSnapshot(); snapshotActivated = true; dispatchBrowserTree( @@ -444,6 +529,7 @@ async function renderNavigationPayload( navigationSnapshot, renderId, actionType, + pending.interceptionContext, pending.routeId, pending.rootLayoutTreePath, useTransition, @@ -556,6 +642,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 }, @@ -620,6 +708,7 @@ function registerServerActionCallback(): void { navigationSnapshot, pending.action.renderId, "navigate", + pending.interceptionContext, pending.routeId, pending.rootLayoutTreePath, false, @@ -647,6 +736,7 @@ function registerServerActionCallback(): void { navigationSnapshot, pending.action.renderId, "navigate", + pending.interceptionContext, pending.routeId, pending.rootLayoutTreePath, false, @@ -696,6 +786,7 @@ async function main(): Promise { try { const url = new URL(href, window.location.origin); const rscUrl = toRscUrl(url.pathname + url.search); + const requestInterceptionContext = getRequestInterceptionContext(navigationKind); // Use startTransition for same-route navigations (searchParam changes) // so React keeps the old UI visible during the transition. For cross-route // navigations (different pathname), use synchronous updates — React's @@ -707,7 +798,7 @@ async function main(): Promise { const isSameRoute = stripBasePath(url.pathname, __basePath) === stripBasePath(window.location.pathname, __basePath); - const cachedRoute = getVisitedResponse(rscUrl, navigationKind); + 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 @@ -736,7 +827,8 @@ async function main(): Promise { cachedNavigationSnapshot, href, navId, - createNavigationCommitEffect(href, historyUpdateMode, cachedParams), + historyUpdateMode, + cachedParams, isSameRoute, ); } finally { @@ -750,7 +842,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; @@ -758,8 +850,9 @@ async function main(): Promise { } if (!navResponse) { + const requestHeaders = createRscRequestHeaders(requestInterceptionContext); navResponse = await fetch(rscUrl, { - headers: { Accept: "text/x-component" }, + headers: requestHeaders, credentials: "include", }); } @@ -771,7 +864,11 @@ async function main(): Promise { if (finalUrl.pathname !== requestedUrl.pathname) { const destinationPath = finalUrl.pathname.replace(/\.rsc$/, "") + finalUrl.search; - replaceHistoryStateWithoutNotify(null, "", destinationPath); + replaceHistoryStateWithoutNotify( + createHistoryStateWithInterceptionContext(null, requestInterceptionContext), + "", + destinationPath, + ); const navigate = window.__VINEXT_RSC_NAVIGATE__; if (!navigate) { @@ -817,7 +914,8 @@ async function main(): Promise { navigationSnapshot, href, navId, - createNavigationCommitEffect(href, historyUpdateMode, navParams), + historyUpdateMode, + navParams, isSameRoute, ); } finally { @@ -835,7 +933,17 @@ async function main(): Promise { // 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. @@ -899,6 +1007,7 @@ async function main(): Promise { navigationSnapshot, pending.action.renderId, "replace", + pending.interceptionContext, pending.routeId, pending.rootLayoutTreePath, false, diff --git a/packages/vinext/src/server/app-browser-state.ts b/packages/vinext/src/server/app-browser-state.ts index f439b798c..90ac10d9b 100644 --- a/packages/vinext/src/server/app-browser-state.ts +++ b/packages/vinext/src/server/app-browser-state.ts @@ -4,6 +4,7 @@ import type { ClientNavigationRenderSnapshot } from "../shims/navigation.js"; export type AppRouterState = { elements: AppElements; + interceptionContext: string | null; renderId: number; navigationSnapshot: ClientNavigationRenderSnapshot; rootLayoutTreePath: string | null; @@ -12,6 +13,7 @@ export type AppRouterState = { export type AppRouterAction = { elements: AppElements; + interceptionContext: string | null; navigationSnapshot: ClientNavigationRenderSnapshot; renderId: number; rootLayoutTreePath: string | null; @@ -21,6 +23,7 @@ export type AppRouterAction = { export type PendingNavigationCommit = { action: AppRouterAction; + interceptionContext: string | null; rootLayoutTreePath: string | null; routeId: string; }; @@ -30,6 +33,7 @@ export function routerReducer(state: AppRouterState, action: AppRouterAction): A case "navigate": return { elements: mergeElements(state.elements, action.elements), + interceptionContext: action.interceptionContext, navigationSnapshot: action.navigationSnapshot, renderId: action.renderId, rootLayoutTreePath: action.rootLayoutTreePath, @@ -38,6 +42,7 @@ export function routerReducer(state: AppRouterState, action: AppRouterAction): A case "replace": return { elements: action.elements, + interceptionContext: action.interceptionContext, navigationSnapshot: action.navigationSnapshot, renderId: action.renderId, rootLayoutTreePath: action.rootLayoutTreePath, @@ -74,12 +79,14 @@ export async function createPendingNavigationCommit(options: { return { action: { elements, + interceptionContext: metadata.interceptionContext, navigationSnapshot: options.navigationSnapshot, renderId: options.renderId ?? options.currentState.renderId + 1, rootLayoutTreePath: metadata.rootLayoutTreePath, routeId: metadata.routeId, type: options.type, }, + interceptionContext: metadata.interceptionContext, rootLayoutTreePath: metadata.rootLayoutTreePath, routeId: metadata.routeId, }; diff --git a/packages/vinext/src/server/app-elements.ts b/packages/vinext/src/server/app-elements.ts index f1e4a8930..33af07165 100644 --- a/packages/vinext/src/server/app-elements.ts +++ b/packages/vinext/src/server/app-elements.ts @@ -1,5 +1,8 @@ import type { ReactNode } from "react"; +const APP_INTERCEPTION_SEPARATOR = "\0"; + +export const APP_INTERCEPTION_CONTEXT_KEY = "__interceptionContext"; export const APP_ROUTE_KEY = "__route"; export const APP_ROOT_LAYOUT_KEY = "__rootLayout"; export const APP_UNMATCHED_SLOT_WIRE_VALUE = "__VINEXT_UNMATCHED_SLOT__"; @@ -13,10 +16,45 @@ export type AppElements = Readonly>; export type AppWireElements = Readonly>; export type AppElementsMetadata = { + interceptionContext: string | null; routeId: string; rootLayoutTreePath: string | null; }; +function appendInterceptionContext(identity: string, interceptionContext: string | null): string { + return interceptionContext === null + ? identity + : `${identity}${APP_INTERCEPTION_SEPARATOR}${interceptionContext}`; +} + +export function createAppPayloadRouteId( + routePath: string, + interceptionContext: string | null, +): string { + return appendInterceptionContext(`route:${routePath}`, interceptionContext); +} + +export function createAppPayloadPageId( + routePath: string, + interceptionContext: string | null, +): string { + return appendInterceptionContext(`page:${routePath}`, interceptionContext); +} + +export function createAppPayloadCacheKey( + rscUrl: string, + interceptionContext: string | null, +): string { + return appendInterceptionContext(rscUrl, interceptionContext); +} + +export function resolveVisitedResponseInterceptionContext( + requestInterceptionContext: string | null, + payloadInterceptionContext: string | null, +): string | null { + return payloadInterceptionContext ?? requestInterceptionContext; +} + export function normalizeAppElements(elements: AppWireElements): AppElements { let needsNormalization = false; for (const [key, value] of Object.entries(elements)) { @@ -45,12 +83,22 @@ export function readAppElementsMetadata(elements: AppElements): AppElementsMetad throw new Error("[vinext] Missing __route string in App Router payload"); } + const interceptionContext = elements[APP_INTERCEPTION_CONTEXT_KEY]; + if ( + interceptionContext !== undefined && + interceptionContext !== null && + typeof interceptionContext !== "string" + ) { + throw new Error("[vinext] Invalid __interceptionContext in App Router payload"); + } + const rootLayoutTreePath = elements[APP_ROOT_LAYOUT_KEY]; if (rootLayoutTreePath !== null && typeof rootLayoutTreePath !== "string") { throw new Error("[vinext] Invalid __rootLayout in App Router payload"); } return { + interceptionContext: interceptionContext ?? null, routeId, rootLayoutTreePath, }; diff --git a/packages/vinext/src/server/app-page-boundary-render.ts b/packages/vinext/src/server/app-page-boundary-render.ts index 2982acb6d..b0cabf4c6 100644 --- a/packages/vinext/src/server/app-page-boundary-render.ts +++ b/packages/vinext/src/server/app-page-boundary-render.ts @@ -24,7 +24,13 @@ import { renderAppPageHtmlResponse, type AppPageSsrHandler, } from "./app-page-stream.js"; -import { APP_ROOT_LAYOUT_KEY, APP_ROUTE_KEY, type AppElements } from "./app-elements.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 @@ -233,9 +239,10 @@ function resolveAppPageBoundaryRootLayoutTreePath function createAppPageBoundaryRscPayload( options: AppPageBoundaryRscPayloadOptions, ): AppElements { - const routeId = `route:${options.pathname}`; + const routeId = createAppPayloadRouteId(options.pathname, null); return { + [APP_INTERCEPTION_CONTEXT_KEY]: null, [APP_ROUTE_KEY]: routeId, [APP_ROOT_LAYOUT_KEY]: resolveAppPageBoundaryRootLayoutTreePath( options.route, diff --git a/packages/vinext/src/server/app-page-route-wiring.tsx b/packages/vinext/src/server/app-page-route-wiring.tsx index 1909780a6..084aff21c 100644 --- a/packages/vinext/src/server/app-page-route-wiring.tsx +++ b/packages/vinext/src/server/app-page-route-wiring.tsx @@ -1,8 +1,11 @@ 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"; @@ -101,6 +104,7 @@ export type BuildAppPageElementsOptions< TModule extends AppPageModule = AppPageModule, TErrorModule extends AppPageErrorModule = AppPageErrorModule, > = BuildAppPageRouteElementOptions & { + interceptionContext?: string | null; routePath: string; }; @@ -287,8 +291,9 @@ export function buildAppPageElements< TErrorModule extends AppPageErrorModule, >(options: BuildAppPageElementsOptions): AppElements { const elements: Record = {}; - const routeId = `route:${options.routePath}`; - const pageId = `page:${options.routePath}`; + 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>(); @@ -343,6 +348,7 @@ export function buildAppPageElements< } elements[APP_ROUTE_KEY] = routeId; + elements[APP_INTERCEPTION_CONTEXT_KEY] = interceptionContext; elements[APP_ROOT_LAYOUT_KEY] = rootLayoutTreePath; elements[pageId] = renderAfterAppDependencies(options.element, pageDependencies); 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..7f4c03cb1 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,14 @@ 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); +} + /** Get or create the shared in-memory RSC prefetch cache on window. */ export function getPrefetchCache(): Map { if (isServer) return new Map(); @@ -284,7 +293,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 +345,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 +358,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 +414,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 +431,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 +446,7 @@ export function prefetchRscResponse(rscUrl: string, fetchPromise: Promise= PREFETCH_CACHE_TTL) { @@ -1122,16 +1145,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/tests/app-browser-entry.test.ts b/tests/app-browser-entry.test.ts index 813b55b11..4fd635d20 100644 --- a/tests/app-browser-entry.test.ts +++ b/tests/app-browser-entry.test.ts @@ -1,6 +1,7 @@ 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, @@ -17,9 +18,11 @@ import { 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, @@ -31,6 +34,7 @@ function createState(overrides: Partial = {}): AppRouterState { elements: createResolvedElements("route:/initial", "/"), navigationSnapshot: createClientNavigationRenderSnapshot("https://example.com/initial", {}), renderId: 0, + interceptionContext: null, rootLayoutTreePath: "/", routeId: "route:/initial", ...overrides, @@ -39,10 +43,10 @@ function createState(overrides: Partial = {}): AppRouterState { describe("app browser entry state helpers", () => { it("merges elements on navigate", async () => { - const previousElements = createResolvedElements("route:/initial", "/", { + const previousElements = createResolvedElements("route:/initial", "/", null, { "layout:/": React.createElement("div", null, "layout"), }); - const nextElements = createResolvedElements("route:/next", "/", { + const nextElements = createResolvedElements("route:/next", "/", null, { "page:/next": React.createElement("main", null, "next"), }); @@ -52,6 +56,7 @@ describe("app browser entry state helpers", () => { }), { elements: nextElements, + interceptionContext: null, navigationSnapshot: createState().navigationSnapshot, renderId: 1, rootLayoutTreePath: "/", @@ -61,6 +66,7 @@ describe("app browser entry state helpers", () => { ); expect(nextState.routeId).toBe("route:/next"); + expect(nextState.interceptionContext).toBeNull(); expect(nextState.rootLayoutTreePath).toBe("/"); expect(nextState.elements).toMatchObject({ "layout:/": expect.anything(), @@ -69,12 +75,13 @@ describe("app browser entry state helpers", () => { }); it("replaces elements on replace", () => { - const nextElements = createResolvedElements("route:/next", "/", { + const nextElements = createResolvedElements("route:/next", "/", null, { "page:/next": React.createElement("main", null, "next"), }); const nextState = routerReducer(createState(), { elements: nextElements, + interceptionContext: null, navigationSnapshot: createState().navigationSnapshot, renderId: 1, rootLayoutTreePath: "/", @@ -83,11 +90,29 @@ describe("app browser entry state helpers", () => { }); expect(nextState.elements).toBe(nextElements); + expect(nextState.interceptionContext).toBeNull(); expect(nextState.elements).toMatchObject({ "page:/next": expect.anything(), }); }); + it("carries interception context through pending navigation commits", async () => { + const pending = await createPendingNavigationCommit({ + currentState: createState(), + nextElements: Promise.resolve( + createResolvedElements("route:/photos/42\0/feed", "/", "/feed", { + "page:/photos/42": React.createElement("main", null, "photo"), + }), + ), + navigationSnapshot: createState().navigationSnapshot, + type: "navigate", + }); + + expect(pending.routeId).toBe("route:/photos/42\0/feed"); + expect(pending.interceptionContext).toBe("/feed"); + expect(pending.action.interceptionContext).toBe("/feed"); + }); + it("hard navigates instead of merging when the root layout changes", async () => { const assign = vi.fn<(href: string) => void>(); diff --git a/tests/app-elements.test.ts b/tests/app-elements.test.ts index ceb0d5fe2..a96fef104 100644 --- a/tests/app-elements.test.ts +++ b/tests/app-elements.test.ts @@ -2,11 +2,15 @@ import React from "react"; import { describe, expect, it } from "vite-plus/test"; import { UNMATCHED_SLOT } from "../packages/vinext/src/shims/slot.js"; import { + APP_INTERCEPTION_CONTEXT_KEY, APP_ROOT_LAYOUT_KEY, APP_ROUTE_KEY, APP_UNMATCHED_SLOT_WIRE_VALUE, + createAppPayloadCacheKey, + createAppPayloadRouteId, normalizeAppElements, readAppElementsMetadata, + resolveVisitedResponseInterceptionContext, } from "../packages/vinext/src/server/app-elements.js"; describe("app elements payload helpers", () => { @@ -35,6 +39,7 @@ describe("app elements payload helpers", () => { 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"), @@ -42,9 +47,36 @@ describe("app elements payload helpers", () => { ); 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( @@ -65,4 +97,16 @@ describe("app elements payload helpers", () => { ), ).toThrow("[vinext] Invalid __rootLayout in App Router payload"); }); + + it("rejects payloads with an invalid __interceptionContext value", () => { + expect(() => + readAppElementsMetadata( + normalizeAppElements({ + [APP_INTERCEPTION_CONTEXT_KEY]: 123, + [APP_ROOT_LAYOUT_KEY]: "/", + [APP_ROUTE_KEY]: "route:/dashboard", + }), + ), + ).toThrow("[vinext] Invalid __interceptionContext in App Router payload"); + }); }); diff --git a/tests/app-router.test.ts b/tests/app-router.test.ts index dd0f4e1d5..2862b2417 100644 --- a/tests/app-router.test.ts +++ b/tests/app-router.test.ts @@ -445,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"); @@ -457,6 +460,13 @@ describe("App Router integration", () => { // It should also contain the feed page content (the source route) expect(rscPayload).toContain("Photo Feed"); expect(rscPayload).toContain("feed-page"); + expect(rscPayload).toContain("__interceptionContext"); + expect(rscPayload).toContain("/feed"); + const nul = String.fromCharCode(0); + expect( + rscPayload.includes("route:/photos/42\\u0000/feed") || + rscPayload.includes(`route:/photos/42${nul}/feed`), + ).toBe(true); }); it("returns Method Not Allowed for unsupported HTTP methods on route handlers", async () => { diff --git a/tests/e2e/app-router/advanced.spec.ts b/tests/e2e/app-router/advanced.spec.ts index 6b5837d38..b7853c329 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,76 @@ 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("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/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/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: { From e1f1be6cbc50e67d73ab964d85e4f3fb80470730 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Thu, 2 Apr 2026 19:21:54 +1100 Subject: [PATCH 21/32] test: update App Router entry snapshots for interception encoding --- .../entry-templates.test.ts.snap | 84 ++++++++++++++++--- 1 file changed, 72 insertions(+), 12 deletions(-) diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index ade238e1a..2bf25494e 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -77,6 +77,10 @@ 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, @@ -651,7 +655,8 @@ function findIntercept(pathname) { async function buildPageElements(route, params, routePath, opts, searchParams) { const PageComponent = route.page?.default; if (!PageComponent) { - const _noExportRouteId = "route:" + routePath; + const _interceptionContext = opts?.interceptionContext ?? null; + const _noExportRouteId = __createAppPayloadRouteId(routePath, _interceptionContext); let _noExportRootLayout = null; if (route.layouts?.length > 0) { const _tp = route.layoutTreePositions?.[0] ?? 0; @@ -659,6 +664,7 @@ async function buildPageElements(route, params, routePath, opts, searchParams) { _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"), @@ -767,6 +773,7 @@ async function buildPageElements(route, params, routePath, opts, searchParams) { matchedParams: params, resolvedMetadata, resolvedViewport, + interceptionContext: opts?.interceptionContext ?? null, routePath, rootNotFoundModule: null, route, @@ -1216,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 @@ -1459,8 +1467,9 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { url.searchParams, ); } else { - const _actionRouteId = "route:" + cleanPathname; + const _actionRouteId = __createAppPayloadRouteId(cleanPathname, null); element = { + [__APP_INTERCEPTION_CONTEXT_KEY]: null, __route: _actionRouteId, __rootLayout: null, [_actionRouteId]: createElement("div", null, "Page not found"), @@ -1884,6 +1893,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { setNavigationContext, toInterceptOpts(intercept) { return { + interceptionContext: interceptionContextHeader, interceptSlot: intercept.slotName, interceptPage: intercept.page, interceptParams: intercept.matchedParams, @@ -2121,6 +2131,10 @@ 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, @@ -2695,7 +2709,8 @@ function findIntercept(pathname) { async function buildPageElements(route, params, routePath, opts, searchParams) { const PageComponent = route.page?.default; if (!PageComponent) { - const _noExportRouteId = "route:" + routePath; + const _interceptionContext = opts?.interceptionContext ?? null; + const _noExportRouteId = __createAppPayloadRouteId(routePath, _interceptionContext); let _noExportRootLayout = null; if (route.layouts?.length > 0) { const _tp = route.layoutTreePositions?.[0] ?? 0; @@ -2703,6 +2718,7 @@ async function buildPageElements(route, params, routePath, opts, searchParams) { _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"), @@ -2811,6 +2827,7 @@ async function buildPageElements(route, params, routePath, opts, searchParams) { matchedParams: params, resolvedMetadata, resolvedViewport, + interceptionContext: opts?.interceptionContext ?? null, routePath, rootNotFoundModule: null, route, @@ -3263,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 @@ -3506,8 +3524,9 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { url.searchParams, ); } else { - const _actionRouteId = "route:" + cleanPathname; + const _actionRouteId = __createAppPayloadRouteId(cleanPathname, null); element = { + [__APP_INTERCEPTION_CONTEXT_KEY]: null, __route: _actionRouteId, __rootLayout: null, [_actionRouteId]: createElement("div", null, "Page not found"), @@ -3931,6 +3950,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { setNavigationContext, toInterceptOpts(intercept) { return { + interceptionContext: interceptionContextHeader, interceptSlot: intercept.slotName, interceptPage: intercept.page, interceptParams: intercept.matchedParams, @@ -4168,6 +4188,10 @@ 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, @@ -4743,7 +4767,8 @@ function findIntercept(pathname) { async function buildPageElements(route, params, routePath, opts, searchParams) { const PageComponent = route.page?.default; if (!PageComponent) { - const _noExportRouteId = "route:" + routePath; + const _interceptionContext = opts?.interceptionContext ?? null; + const _noExportRouteId = __createAppPayloadRouteId(routePath, _interceptionContext); let _noExportRootLayout = null; if (route.layouts?.length > 0) { const _tp = route.layoutTreePositions?.[0] ?? 0; @@ -4751,6 +4776,7 @@ async function buildPageElements(route, params, routePath, opts, searchParams) { _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"), @@ -4859,6 +4885,7 @@ async function buildPageElements(route, params, routePath, opts, searchParams) { matchedParams: params, resolvedMetadata, resolvedViewport, + interceptionContext: opts?.interceptionContext ?? null, routePath, rootNotFoundModule: null, route, @@ -5308,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 @@ -5551,8 +5579,9 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { url.searchParams, ); } else { - const _actionRouteId = "route:" + cleanPathname; + const _actionRouteId = __createAppPayloadRouteId(cleanPathname, null); element = { + [__APP_INTERCEPTION_CONTEXT_KEY]: null, __route: _actionRouteId, __rootLayout: null, [_actionRouteId]: createElement("div", null, "Page not found"), @@ -5976,6 +6005,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { setNavigationContext, toInterceptOpts(intercept) { return { + interceptionContext: interceptionContextHeader, interceptSlot: intercept.slotName, interceptPage: intercept.page, interceptParams: intercept.matchedParams, @@ -6213,6 +6243,10 @@ 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, @@ -6817,7 +6851,8 @@ function findIntercept(pathname) { async function buildPageElements(route, params, routePath, opts, searchParams) { const PageComponent = route.page?.default; if (!PageComponent) { - const _noExportRouteId = "route:" + routePath; + const _interceptionContext = opts?.interceptionContext ?? null; + const _noExportRouteId = __createAppPayloadRouteId(routePath, _interceptionContext); let _noExportRootLayout = null; if (route.layouts?.length > 0) { const _tp = route.layoutTreePositions?.[0] ?? 0; @@ -6825,6 +6860,7 @@ async function buildPageElements(route, params, routePath, opts, searchParams) { _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"), @@ -6933,6 +6969,7 @@ async function buildPageElements(route, params, routePath, opts, searchParams) { matchedParams: params, resolvedMetadata, resolvedViewport, + interceptionContext: opts?.interceptionContext ?? null, routePath, rootNotFoundModule: null, route, @@ -7385,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 @@ -7628,8 +7666,9 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { url.searchParams, ); } else { - const _actionRouteId = "route:" + cleanPathname; + const _actionRouteId = __createAppPayloadRouteId(cleanPathname, null); element = { + [__APP_INTERCEPTION_CONTEXT_KEY]: null, __route: _actionRouteId, __rootLayout: null, [_actionRouteId]: createElement("div", null, "Page not found"), @@ -8053,6 +8092,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { setNavigationContext, toInterceptOpts(intercept) { return { + interceptionContext: interceptionContextHeader, interceptSlot: intercept.slotName, interceptPage: intercept.page, interceptParams: intercept.matchedParams, @@ -8290,6 +8330,10 @@ 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, @@ -8871,7 +8915,8 @@ function findIntercept(pathname) { async function buildPageElements(route, params, routePath, opts, searchParams) { const PageComponent = route.page?.default; if (!PageComponent) { - const _noExportRouteId = "route:" + routePath; + const _interceptionContext = opts?.interceptionContext ?? null; + const _noExportRouteId = __createAppPayloadRouteId(routePath, _interceptionContext); let _noExportRootLayout = null; if (route.layouts?.length > 0) { const _tp = route.layoutTreePositions?.[0] ?? 0; @@ -8879,6 +8924,7 @@ async function buildPageElements(route, params, routePath, opts, searchParams) { _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"), @@ -8987,6 +9033,7 @@ async function buildPageElements(route, params, routePath, opts, searchParams) { matchedParams: params, resolvedMetadata, resolvedViewport, + interceptionContext: opts?.interceptionContext ?? null, routePath, rootNotFoundModule: null, route, @@ -9436,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 @@ -9679,8 +9727,9 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { url.searchParams, ); } else { - const _actionRouteId = "route:" + cleanPathname; + const _actionRouteId = __createAppPayloadRouteId(cleanPathname, null); element = { + [__APP_INTERCEPTION_CONTEXT_KEY]: null, __route: _actionRouteId, __rootLayout: null, [_actionRouteId]: createElement("div", null, "Page not found"), @@ -10104,6 +10153,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { setNavigationContext, toInterceptOpts(intercept) { return { + interceptionContext: interceptionContextHeader, interceptSlot: intercept.slotName, interceptPage: intercept.page, interceptParams: intercept.matchedParams, @@ -10341,6 +10391,10 @@ 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, @@ -10915,7 +10969,8 @@ function findIntercept(pathname) { async function buildPageElements(route, params, routePath, opts, searchParams) { const PageComponent = route.page?.default; if (!PageComponent) { - const _noExportRouteId = "route:" + routePath; + const _interceptionContext = opts?.interceptionContext ?? null; + const _noExportRouteId = __createAppPayloadRouteId(routePath, _interceptionContext); let _noExportRootLayout = null; if (route.layouts?.length > 0) { const _tp = route.layoutTreePositions?.[0] ?? 0; @@ -10923,6 +10978,7 @@ async function buildPageElements(route, params, routePath, opts, searchParams) { _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"), @@ -11031,6 +11087,7 @@ async function buildPageElements(route, params, routePath, opts, searchParams) { matchedParams: params, resolvedMetadata, resolvedViewport, + interceptionContext: opts?.interceptionContext ?? null, routePath, rootNotFoundModule: null, route, @@ -11709,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 @@ -12087,8 +12145,9 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { url.searchParams, ); } else { - const _actionRouteId = "route:" + cleanPathname; + const _actionRouteId = __createAppPayloadRouteId(cleanPathname, null); element = { + [__APP_INTERCEPTION_CONTEXT_KEY]: null, __route: _actionRouteId, __rootLayout: null, [_actionRouteId]: createElement("div", null, "Page not found"), @@ -12512,6 +12571,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { setNavigationContext, toInterceptOpts(intercept) { return { + interceptionContext: interceptionContextHeader, interceptSlot: intercept.slotName, interceptPage: intercept.page, interceptParams: intercept.matchedParams, From b9efbe805b161b70eae14f378ac226fc71ffb68f Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Fri, 3 Apr 2026 00:17:40 +1100 Subject: [PATCH 22/32] docs: refresh stale app browser entry comments --- packages/vinext/src/server/app-browser-entry.ts | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index 7787d1238..f255f8fad 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -136,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); } @@ -405,7 +403,7 @@ function BrowserRoot({ // 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 + // 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; @@ -683,14 +681,13 @@ function registerServerActionCallback(): void { { 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)) { const navigationSnapshot = createClientNavigationRenderSnapshot( window.location.href, From 7f22e74ba26955a4e80fcce849e889892b9e8a4e Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Fri, 3 Apr 2026 00:29:15 +1100 Subject: [PATCH 23/32] feat: track previousNextUrl for intercepted App Router entries --- packages/vinext/src/global.d.ts | 1 + .../vinext/src/server/app-browser-entry.ts | 126 +++++++++++------- .../vinext/src/server/app-browser-state.ts | 62 +++++++++ packages/vinext/src/shims/navigation.ts | 8 ++ tests/app-browser-entry.test.ts | 76 +++++++++++ tests/e2e/app-router/advanced.spec.ts | 47 +++++++ .../app/feed/@modal/(...)photos/[id]/page.tsx | 3 + .../(...)photos/[id]/refresh-button.tsx | 13 ++ 8 files changed, 285 insertions(+), 51 deletions(-) create mode 100644 tests/fixtures/app-basic/app/feed/@modal/(...)photos/[id]/refresh-button.tsx 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/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index f255f8fad..ebb717f42 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -26,6 +26,7 @@ import { commitClientNavigationState, consumePrefetchResponse, createClientNavigationRenderSnapshot, + getCurrentNextUrl, getCurrentInterceptionContext, getClientNavigationRenderContext, getPrefetchCache, @@ -56,7 +57,10 @@ import { type AppWireElements, } from "./app-elements.js"; import { + createHistoryStateWithPreviousNextUrl, createPendingNavigationCommit, + readHistoryStatePreviousNextUrl, + resolveInterceptionContextFromPreviousNextUrl, routerReducer, shouldHardNavigate, type AppRouterAction, @@ -85,11 +89,6 @@ type VisitedResponseCacheEntry = { const MAX_VISITED_RESPONSE_CACHE_SIZE = 50; const VISITED_RESPONSE_CACHE_TTL = 5 * 60_000; const MAX_TRAVERSAL_CACHE_TTL = 30 * 60_000; -const VINEXT_INTERCEPTION_CONTEXT_HISTORY_STATE_KEY = "__vinext_interceptionContext"; - -type HistoryStateRecord = { - [key: string]: unknown; -}; // These are plain module-level variables, unlike ClientNavigationState in // navigation.ts which uses Symbol.for to survive multiple Vite module instances. @@ -195,15 +194,15 @@ function createNavigationCommitEffect( href: string, historyUpdateMode: HistoryUpdateMode | undefined, params: Record, - interceptionContext: string | null, + previousNextUrl: string | null, ): () => void { return () => { const targetHref = new URL(href, window.location.origin).href; stageClientParams(params); const preserveExistingState = historyUpdateMode === "replace"; - const historyState = createHistoryStateWithInterceptionContext( + const historyState = createHistoryStateWithPreviousNextUrl( preserveExistingState ? window.history.state : null, - interceptionContext, + previousNextUrl, ); if (historyUpdateMode === "replace" && window.location.href !== targetHref) { @@ -281,46 +280,49 @@ function storeVisitedResponseSnapshot( }); } -function cloneHistoryState(state: unknown): HistoryStateRecord { - if (!state || typeof state !== "object") { - return {}; - } - - const nextState: HistoryStateRecord = {}; - for (const [key, value] of Object.entries(state)) { - nextState[key] = value; - } - return nextState; -} - -function createHistoryStateWithInterceptionContext( - state: unknown, - interceptionContext: string | null, -): HistoryStateRecord | null { - const nextState = cloneHistoryState(state); +type NavigationRequestState = { + interceptionContext: string | null; + previousNextUrl: string | null; +}; - if (interceptionContext === null) { - delete nextState[VINEXT_INTERCEPTION_CONTEXT_HISTORY_STATE_KEY]; - } else { - nextState[VINEXT_INTERCEPTION_CONTEXT_HISTORY_STATE_KEY] = interceptionContext; +function getRequestState( + navigationKind: NavigationKind, + previousNextUrlOverride?: string | null, +): NavigationRequestState { + if (previousNextUrlOverride !== undefined) { + return { + interceptionContext: resolveInterceptionContextFromPreviousNextUrl( + previousNextUrlOverride, + __basePath, + ), + previousNextUrl: previousNextUrlOverride, + }; } - return Object.keys(nextState).length > 0 ? nextState : null; -} - -function readHistoryStateInterceptionContext(state: unknown): string | null { - const value = cloneHistoryState(state)[VINEXT_INTERCEPTION_CONTEXT_HISTORY_STATE_KEY]; - return typeof value === "string" ? value : null; -} - -function getRequestInterceptionContext(navigationKind: NavigationKind): string | null { switch (navigationKind) { case "navigate": - return getCurrentInterceptionContext(); - case "traverse": - return readHistoryStateInterceptionContext(window.history.state); + return { + interceptionContext: getCurrentInterceptionContext(), + previousNextUrl: getCurrentNextUrl(), + }; + case "traverse": { + const previousNextUrl = readHistoryStatePreviousNextUrl(window.history.state); + return { + interceptionContext: resolveInterceptionContextFromPreviousNextUrl( + previousNextUrl, + __basePath, + ), + previousNextUrl, + }; + } case "refresh": - return null; + return { + interceptionContext: resolveInterceptionContextFromPreviousNextUrl( + getBrowserRouterState().previousNextUrl, + __basePath, + ), + previousNextUrl: getBrowserRouterState().previousNextUrl, + }; default: { const _exhaustive: never = navigationKind; throw new Error("[vinext] Unknown navigation kind: " + String(_exhaustive)); @@ -396,6 +398,7 @@ function BrowserRoot({ elements: resolvedElements, interceptionContext: initialMetadata.interceptionContext, navigationSnapshot: initialNavigationSnapshot, + previousNextUrl: null, renderId: 0, rootLayoutTreePath: initialMetadata.rootLayoutTreePath, routeId: initialMetadata.routeId, @@ -445,6 +448,7 @@ function dispatchBrowserTree( renderId: number, actionType: "navigate" | "replace", interceptionContext: string | null, + previousNextUrl: string | null, routeId: string, rootLayoutTreePath: string | null, useTransitionMode: boolean, @@ -456,6 +460,7 @@ function dispatchBrowserTree( elements, interceptionContext, navigationSnapshot, + previousNextUrl, renderId, rootLayoutTreePath, routeId, @@ -476,6 +481,7 @@ async function renderNavigationPayload( navId: number, historyUpdateMode: HistoryUpdateMode | undefined, params: Record, + previousNextUrl: string | null, useTransition = true, actionType: "navigate" | "replace" = "navigate", ): Promise { @@ -491,6 +497,7 @@ async function renderNavigationPayload( currentState, nextElements: payload, navigationSnapshot, + previousNextUrl, renderId, type: actionType, }); @@ -513,12 +520,7 @@ async function renderNavigationPayload( queuePrePaintNavigationEffect( renderId, - createNavigationCommitEffect( - targetHref, - historyUpdateMode, - params, - pending.interceptionContext, - ), + createNavigationCommitEffect(targetHref, historyUpdateMode, params, pending.previousNextUrl), ); activateNavigationSnapshot(); snapshotActivated = true; @@ -528,6 +530,7 @@ async function renderNavigationPayload( renderId, actionType, pending.interceptionContext, + pending.previousNextUrl, pending.routeId, pending.rootLayoutTreePath, useTransition, @@ -697,6 +700,7 @@ function registerServerActionCallback(): void { currentState: getBrowserRouterState(), nextElements: Promise.resolve(normalizeAppElements(result.root)), navigationSnapshot, + previousNextUrl: getBrowserRouterState().previousNextUrl, renderId: ++nextNavigationRenderId, type: "navigate", }); @@ -706,6 +710,7 @@ function registerServerActionCallback(): void { pending.action.renderId, "navigate", pending.interceptionContext, + pending.previousNextUrl, pending.routeId, pending.rootLayoutTreePath, false, @@ -725,6 +730,7 @@ function registerServerActionCallback(): void { currentState: getBrowserRouterState(), nextElements: Promise.resolve(normalizeAppElements(result)), navigationSnapshot, + previousNextUrl: getBrowserRouterState().previousNextUrl, renderId: ++nextNavigationRenderId, type: "navigate", }); @@ -734,6 +740,7 @@ function registerServerActionCallback(): void { pending.action.renderId, "navigate", pending.interceptionContext, + pending.previousNextUrl, pending.routeId, pending.rootLayoutTreePath, false, @@ -751,6 +758,11 @@ async function main(): Promise { window.location.href, latestClientParams, ); + replaceHistoryStateWithoutNotify( + createHistoryStateWithPreviousNextUrl(window.history.state, null), + "", + window.location.href, + ); window.__VINEXT_RSC_ROOT__ = hydrateRoot( document, @@ -767,6 +779,7 @@ async function main(): Promise { redirectDepth = 0, navigationKind: NavigationKind = "navigate", historyUpdateMode?: HistoryUpdateMode, + previousNextUrlOverride?: string | null, ): Promise { if (redirectDepth > 10) { console.error( @@ -783,7 +796,9 @@ async function main(): Promise { try { const url = new URL(href, window.location.origin); const rscUrl = toRscUrl(url.pathname + url.search); - const requestInterceptionContext = getRequestInterceptionContext(navigationKind); + 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 @@ -826,6 +841,7 @@ async function main(): Promise { navId, historyUpdateMode, cachedParams, + requestPreviousNextUrl, isSameRoute, ); } finally { @@ -862,7 +878,7 @@ async function main(): Promise { if (finalUrl.pathname !== requestedUrl.pathname) { const destinationPath = finalUrl.pathname.replace(/\.rsc$/, "") + finalUrl.search; replaceHistoryStateWithoutNotify( - createHistoryStateWithInterceptionContext(null, requestInterceptionContext), + createHistoryStateWithPreviousNextUrl(null, requestPreviousNextUrl), "", destinationPath, ); @@ -876,7 +892,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 = {}; @@ -913,6 +935,7 @@ async function main(): Promise { navId, historyUpdateMode, navParams, + requestPreviousNextUrl, isSameRoute, ); } finally { @@ -1005,6 +1028,7 @@ async function main(): Promise { pending.action.renderId, "replace", pending.interceptionContext, + pending.previousNextUrl, pending.routeId, pending.rootLayoutTreePath, false, diff --git a/packages/vinext/src/server/app-browser-state.ts b/packages/vinext/src/server/app-browser-state.ts index 90ac10d9b..adfe89600 100644 --- a/packages/vinext/src/server/app-browser-state.ts +++ b/packages/vinext/src/server/app-browser-state.ts @@ -1,10 +1,18 @@ import { mergeElements } from "../shims/slot.js"; +import { stripBasePath } from "../utils/base-path.js"; import { readAppElementsMetadata, type AppElements } 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; + previousNextUrl: string | null; renderId: number; navigationSnapshot: ClientNavigationRenderSnapshot; rootLayoutTreePath: string | null; @@ -15,6 +23,7 @@ export type AppRouterAction = { elements: AppElements; interceptionContext: string | null; navigationSnapshot: ClientNavigationRenderSnapshot; + previousNextUrl: string | null; renderId: number; rootLayoutTreePath: string | null; routeId: string; @@ -24,10 +33,55 @@ export type AppRouterAction = { 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": @@ -35,6 +89,7 @@ export function routerReducer(state: AppRouterState, action: AppRouterAction): A elements: mergeElements(state.elements, action.elements), interceptionContext: action.interceptionContext, navigationSnapshot: action.navigationSnapshot, + previousNextUrl: action.previousNextUrl, renderId: action.renderId, rootLayoutTreePath: action.rootLayoutTreePath, routeId: action.routeId, @@ -44,6 +99,7 @@ export function routerReducer(state: AppRouterState, action: AppRouterAction): A elements: action.elements, interceptionContext: action.interceptionContext, navigationSnapshot: action.navigationSnapshot, + previousNextUrl: action.previousNextUrl, renderId: action.renderId, rootLayoutTreePath: action.rootLayoutTreePath, routeId: action.routeId, @@ -70,23 +126,27 @@ 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, 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, }; @@ -99,6 +159,7 @@ export async function applyAppRouterStateUpdate(options: { nextElements: Promise; navigationSnapshot?: ClientNavigationRenderSnapshot; onHardNavigate: (href: string) => void; + previousNextUrl?: string | null; targetHref: string; transition: (callback: () => void) => void; type?: "navigate" | "replace"; @@ -107,6 +168,7 @@ export async function applyAppRouterStateUpdate(options: { currentState: options.currentState, nextElements: options.nextElements, navigationSnapshot: options.navigationSnapshot ?? options.currentState.navigationSnapshot, + previousNextUrl: options.previousNextUrl, type: options.type ?? "navigate", }); diff --git a/packages/vinext/src/shims/navigation.ts b/packages/vinext/src/shims/navigation.ts index 7f4c03cb1..61e31e484 100644 --- a/packages/vinext/src/shims/navigation.ts +++ b/packages/vinext/src/shims/navigation.ts @@ -282,6 +282,14 @@ export function getCurrentInterceptionContext(): string | 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(); diff --git a/tests/app-browser-entry.test.ts b/tests/app-browser-entry.test.ts index 4fd635d20..44c8c4721 100644 --- a/tests/app-browser-entry.test.ts +++ b/tests/app-browser-entry.test.ts @@ -10,7 +10,10 @@ import { 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"; @@ -35,6 +38,7 @@ function createState(overrides: Partial = {}): AppRouterState { navigationSnapshot: createClientNavigationRenderSnapshot("https://example.com/initial", {}), renderId: 0, interceptionContext: null, + previousNextUrl: null, rootLayoutTreePath: "/", routeId: "route:/initial", ...overrides, @@ -58,6 +62,7 @@ describe("app browser entry state helpers", () => { elements: nextElements, interceptionContext: null, navigationSnapshot: createState().navigationSnapshot, + previousNextUrl: null, renderId: 1, rootLayoutTreePath: "/", routeId: "route:/next", @@ -67,6 +72,7 @@ describe("app browser entry state helpers", () => { 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(), @@ -83,6 +89,7 @@ describe("app browser entry state helpers", () => { elements: nextElements, interceptionContext: null, navigationSnapshot: createState().navigationSnapshot, + previousNextUrl: null, renderId: 1, rootLayoutTreePath: "/", routeId: "route:/next", @@ -91,6 +98,7 @@ describe("app browser entry state helpers", () => { expect(nextState.elements).toBe(nextElements); expect(nextState.interceptionContext).toBeNull(); + expect(nextState.previousNextUrl).toBeNull(); expect(nextState.elements).toMatchObject({ "page:/next": expect.anything(), }); @@ -105,12 +113,15 @@ describe("app browser entry state helpers", () => { }), ), 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 () => { @@ -176,11 +187,76 @@ describe("app browser entry state helpers", () => { 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("stores previousNextUrl on navigate actions", () => { + const nextState = routerReducer(createState(), { + elements: createResolvedElements("route:/photos/42\0/feed", "/", "/feed"), + interceptionContext: "/feed", + 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/e2e/app-router/advanced.spec.ts b/tests/e2e/app-router/advanced.spec.ts index b7853c329..b257d772e 100644 --- a/tests/e2e/app-router/advanced.spec.ts +++ b/tests/e2e/app-router/advanced.spec.ts @@ -104,6 +104,53 @@ test.describe("Intercepting Routes", () => { 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, }) => { 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 ( + + ); +} From 2d41ed50e3b131316bcddf868faa71d5a0f594ce Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Fri, 3 Apr 2026 20:22:06 +1100 Subject: [PATCH 24/32] feat: add classifyLayoutSegmentConfig for layout segment config detection Reads `export const dynamic` and `export const revalidate` from layout source files to classify them as static or dynamic. Unlike page classification, positive revalidate values return null (ISR is a page concept), deferring to module graph analysis for layout skip decisions. --- packages/vinext/src/build/report.ts | 31 ++++++++++++++++++++++++ tests/build-report.test.ts | 37 +++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+) 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/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(); + }); +}); From d822a7d912a1e41242ad70cb2552f249cf5b6eef Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Fri, 3 Apr 2026 20:24:04 +1100 Subject: [PATCH 25/32] feat: add module graph layout classification (Layer 2) BFS traversal of each layout's dependency tree via Vite's module graph. If no transitive dynamic shim import (headers, cache, server) is found, the layout is provably static. Otherwise it needs a runtime probe. classifyAllRouteLayouts combines Layer 1 (segment config, from prior commit) with Layer 2 (module graph), deduplicating shared layouts. --- .../vinext/src/build/layout-classification.ts | 111 +++++++++ tests/layout-classification.test.ts | 232 ++++++++++++++++++ 2 files changed, 343 insertions(+) create mode 100644 packages/vinext/src/build/layout-classification.ts create mode 100644 tests/layout-classification.test.ts 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/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"); + }); +}); From a5511428e0f000c308396165ef5c2a9eef4276fd Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Fri, 3 Apr 2026 20:28:15 +1100 Subject: [PATCH 26/32] feat: per-layout dynamic detection in probe phase (Layer 3) Extends probeAppPageLayouts to return per-layout flags ("s"/"d") alongside the existing Response. Three paths per layout: - Build-time classified: pass flag through, still probe for errors - Needs probe: run with isolated dynamic scope, detect usage - No classification: original behavior (backward compat) probeAppPageBeforeRender propagates layoutFlags through the result. renderAppPageLifecycle updated to destructure the new return type. --- .../vinext/src/server/app-page-execution.ts | 76 +++++++++-- packages/vinext/src/server/app-page-probe.ts | 34 ++++- packages/vinext/src/server/app-page-render.ts | 6 +- tests/app-page-execution.test.ts | 127 +++++++++++++++++- tests/app-page-probe.test.ts | 98 ++++++++++++-- 5 files changed, 309 insertions(+), 32 deletions(-) diff --git a/packages/vinext/src/server/app-page-execution.ts b/packages/vinext/src/server/app-page-execution.ts index 806d7214b..32e59c457 100644 --- a/packages/vinext/src/server/app-page-execution.ts +++ b/packages/vinext/src/server/app-page-execution.ts @@ -19,11 +19,27 @@ export type BuildAppPageSpecialErrorResponseOptions = { specialError: AppPageSpecialError; }; +export type LayoutFlags = Readonly>; + +export type ProbeAppPageLayoutsResult = { + response: Response | null; + layoutFlags: LayoutFlags; +}; + export type ProbeAppPageLayoutsOptions = { layoutCount: number; onLayoutError: (error: unknown, layoutIndex: number) => Promise; probeLayoutAt: (layoutIndex: number) => unknown; runWithSuppressedHookWarning(probe: () => Promise): Promise; + + /** 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 ProbeAppPageComponentOptions = { @@ -98,24 +114,62 @@ export async function buildAppPageSpecialErrorResponse( export async function probeAppPageLayouts( options: ProbeAppPageLayoutsOptions, -): Promise { - return options.runWithSuppressedHookWarning(async () => { +): Promise { + const layoutFlags: Record = {}; + const hasClassification = !!(options.getLayoutId && options.runWithIsolatedDynamicScope); + + 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 = options.buildTimeClassifications?.get(layoutIndex); + + if (hasClassification && buildTimeResult) { + // Build-time classified (Layer 1 or Layer 2): skip dynamic isolation, + // but still probe for special errors (redirects, not-found). + layoutFlags[options.getLayoutId!(layoutIndex)] = buildTimeResult === "static" ? "s" : "d"; + const errorResponse = await probeLayoutForErrors(options, layoutIndex); + if (errorResponse) return errorResponse; + continue; + } + + if (hasClassification) { + // Layer 3: probe with isolated dynamic scope to detect per-layout + // dynamic API usage (headers(), cookies(), connection(), etc.) + try { + const { dynamicDetected } = await options.runWithIsolatedDynamicScope!(() => + options.probeLayoutAt(layoutIndex), + ); + layoutFlags[options.getLayoutId!(layoutIndex)] = dynamicDetected ? "d" : "s"; + } catch (error) { + 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..d896b540b 100644 --- a/packages/vinext/src/server/app-page-probe.ts +++ b/packages/vinext/src/server/app-page-probe.ts @@ -2,8 +2,14 @@ import { probeAppPageComponent, probeAppPageLayouts, type AppPageSpecialError, + type LayoutFlags, } from "./app-page-execution.js"; +export type ProbeAppPageBeforeRenderResult = { + response: Response | null; + layoutFlags: LayoutFlags; +}; + export type ProbeAppPageBeforeRenderOptions = { hasLoadingBoundary: boolean; layoutCount: number; @@ -16,15 +22,26 @@ export type ProbeAppPageBeforeRenderOptions = { renderPageSpecialError: (specialError: AppPageSpecialError) => Promise; resolveSpecialError: (error: unknown) => AppPageSpecialError | null; runWithSuppressedHookWarning(probe: () => Promise): Promise; + + /** 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 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 +55,21 @@ export async function probeAppPageBeforeRender( runWithSuppressedHookWarning(probe) { return options.runWithSuppressedHookWarning(probe); }, + buildTimeClassifications: options.buildTimeClassifications, + getLayoutId: options.getLayoutId, + runWithIsolatedDynamicScope: options.runWithIsolatedDynamicScope, }); - 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 +87,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 ed66bf72b..fb63b3d51 100644 --- a/packages/vinext/src/server/app-page-render.ts +++ b/packages/vinext/src/server/app-page-render.ts @@ -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/tests/app-page-execution.test.ts b/tests/app-page-execution.test.ts index f1548f057..9574cf0c4 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,127 @@ 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(); + }, + buildTimeClassifications: new Map([ + [0, "static"], + [2, "dynamic"], + ]), + getLayoutId(layoutIndex) { + return ["layout:/", "layout:/blog", "layout:/blog/post"][layoutIndex]; + }, + runWithIsolatedDynamicScope(fn) { + // Simulate: layout 1 triggers dynamic usage, others don't + 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(); + }, + 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("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(); + }, + 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..07a503d2d 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,92 @@ 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(); + }, + 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(); + }, + 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 +230,7 @@ describe("app page probe helpers", () => { }, }); - expect(response).toBeNull(); + expect(result.response).toBeNull(); expect(renderPageSpecialError).not.toHaveBeenCalled(); }); }); From bef79ecff38129d07523113071f71766da8e6efd Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Fri, 3 Apr 2026 20:35:13 +1100 Subject: [PATCH 27/32] feat: add __layoutFlags payload metadata and thread through router state Adds APP_LAYOUT_FLAGS_KEY to the RSC payload metadata, carrying per-layout static/dynamic flags ("s"/"d"). readAppElementsMetadata now parses layoutFlags with a type predicate guard. AppRouterState and AppRouterAction carry layoutFlags. Navigate merges flags (preserving previously-seen layouts), replace replaces them. All dispatchBrowserTree call sites updated to pass layoutFlags. --- .../vinext/src/server/app-browser-entry.ts | 8 ++++ .../vinext/src/server/app-browser-state.ts | 7 ++- packages/vinext/src/server/app-elements.ts | 29 ++++++++++- tests/app-browser-entry.test.ts | 48 +++++++++++++++++++ tests/app-elements.test.ts | 29 +++++++++++ 5 files changed, 119 insertions(+), 2 deletions(-) diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index ebb717f42..1c1635ba0 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -55,6 +55,7 @@ import { resolveVisitedResponseInterceptionContext, type AppElements, type AppWireElements, + type LayoutFlags, } from "./app-elements.js"; import { createHistoryStateWithPreviousNextUrl, @@ -397,6 +398,7 @@ function BrowserRoot({ const [treeState, dispatchTreeState] = useReducer(routerReducer, { elements: resolvedElements, interceptionContext: initialMetadata.interceptionContext, + layoutFlags: initialMetadata.layoutFlags, navigationSnapshot: initialNavigationSnapshot, previousNextUrl: null, renderId: 0, @@ -448,6 +450,7 @@ function dispatchBrowserTree( renderId: number, actionType: "navigate" | "replace", interceptionContext: string | null, + layoutFlags: LayoutFlags, previousNextUrl: string | null, routeId: string, rootLayoutTreePath: string | null, @@ -459,6 +462,7 @@ function dispatchBrowserTree( dispatch({ elements, interceptionContext, + layoutFlags, navigationSnapshot, previousNextUrl, renderId, @@ -530,6 +534,7 @@ async function renderNavigationPayload( renderId, actionType, pending.interceptionContext, + pending.action.layoutFlags, pending.previousNextUrl, pending.routeId, pending.rootLayoutTreePath, @@ -710,6 +715,7 @@ function registerServerActionCallback(): void { pending.action.renderId, "navigate", pending.interceptionContext, + pending.action.layoutFlags, pending.previousNextUrl, pending.routeId, pending.rootLayoutTreePath, @@ -740,6 +746,7 @@ function registerServerActionCallback(): void { pending.action.renderId, "navigate", pending.interceptionContext, + pending.action.layoutFlags, pending.previousNextUrl, pending.routeId, pending.rootLayoutTreePath, @@ -1028,6 +1035,7 @@ async function main(): Promise { pending.action.renderId, "replace", pending.interceptionContext, + pending.action.layoutFlags, pending.previousNextUrl, pending.routeId, pending.rootLayoutTreePath, diff --git a/packages/vinext/src/server/app-browser-state.ts b/packages/vinext/src/server/app-browser-state.ts index adfe89600..fdf140676 100644 --- a/packages/vinext/src/server/app-browser-state.ts +++ b/packages/vinext/src/server/app-browser-state.ts @@ -1,6 +1,6 @@ import { mergeElements } from "../shims/slot.js"; import { stripBasePath } from "../utils/base-path.js"; -import { readAppElementsMetadata, type AppElements } from "./app-elements.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"; @@ -12,6 +12,7 @@ type HistoryStateRecord = { export type AppRouterState = { elements: AppElements; interceptionContext: string | null; + layoutFlags: LayoutFlags; previousNextUrl: string | null; renderId: number; navigationSnapshot: ClientNavigationRenderSnapshot; @@ -22,6 +23,7 @@ export type AppRouterState = { export type AppRouterAction = { elements: AppElements; interceptionContext: string | null; + layoutFlags: LayoutFlags; navigationSnapshot: ClientNavigationRenderSnapshot; previousNextUrl: string | null; renderId: number; @@ -88,6 +90,7 @@ export function routerReducer(state: AppRouterState, action: AppRouterAction): A return { elements: mergeElements(state.elements, action.elements), interceptionContext: action.interceptionContext, + layoutFlags: { ...state.layoutFlags, ...action.layoutFlags }, navigationSnapshot: action.navigationSnapshot, previousNextUrl: action.previousNextUrl, renderId: action.renderId, @@ -98,6 +101,7 @@ export function routerReducer(state: AppRouterState, action: AppRouterAction): A return { elements: action.elements, interceptionContext: action.interceptionContext, + layoutFlags: action.layoutFlags, navigationSnapshot: action.navigationSnapshot, previousNextUrl: action.previousNextUrl, renderId: action.renderId, @@ -138,6 +142,7 @@ export async function createPendingNavigationCommit(options: { action: { elements, interceptionContext: metadata.interceptionContext, + layoutFlags: metadata.layoutFlags, navigationSnapshot: options.navigationSnapshot, previousNextUrl, renderId: options.renderId ?? options.currentState.renderId + 1, diff --git a/packages/vinext/src/server/app-elements.ts b/packages/vinext/src/server/app-elements.ts index 33af07165..5109f7016 100644 --- a/packages/vinext/src/server/app-elements.ts +++ b/packages/vinext/src/server/app-elements.ts @@ -3,6 +3,7 @@ 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__"; @@ -15,8 +16,11 @@ 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; }; @@ -77,7 +81,27 @@ export function normalizeAppElements(elements: AppWireElements): AppElements { return normalized; } -export function readAppElementsMetadata(elements: AppElements): AppElementsMetadata { +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"); @@ -97,8 +121,11 @@ export function readAppElementsMetadata(elements: AppElements): AppElementsMetad 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/tests/app-browser-entry.test.ts b/tests/app-browser-entry.test.ts index 44c8c4721..15931f4fa 100644 --- a/tests/app-browser-entry.test.ts +++ b/tests/app-browser-entry.test.ts @@ -35,6 +35,7 @@ function createResolvedElements( function createState(overrides: Partial = {}): AppRouterState { return { elements: createResolvedElements("route:/initial", "/"), + layoutFlags: {}, navigationSnapshot: createClientNavigationRenderSnapshot("https://example.com/initial", {}), renderId: 0, interceptionContext: null, @@ -61,6 +62,7 @@ describe("app browser entry state helpers", () => { { elements: nextElements, interceptionContext: null, + layoutFlags: {}, navigationSnapshot: createState().navigationSnapshot, previousNextUrl: null, renderId: 1, @@ -88,6 +90,7 @@ describe("app browser entry state helpers", () => { const nextState = routerReducer(createState(), { elements: nextElements, interceptionContext: null, + layoutFlags: {}, navigationSnapshot: createState().navigationSnapshot, previousNextUrl: null, renderId: 1, @@ -197,10 +200,55 @@ describe("app browser entry state helpers", () => { 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, diff --git a/tests/app-elements.test.ts b/tests/app-elements.test.ts index a96fef104..0cf968f1b 100644 --- a/tests/app-elements.test.ts +++ b/tests/app-elements.test.ts @@ -3,6 +3,7 @@ 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, @@ -109,4 +110,32 @@ describe("app elements payload helpers", () => { ), ).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({}); + }); }); From 36344ec4fdb9e06b50d6307c8f1ae8a3038b9e95 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Fri, 3 Apr 2026 20:44:25 +1100 Subject: [PATCH 28/32] refactor: group classification options into single LayoutClassificationOptions type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three optional fields (buildTimeClassifications, getLayoutId, runWithIsolatedDynamicScope) had an all-or-nothing invariant enforced only at runtime. Grouping them into a single optional `classification` object makes the constraint type-safe — you either provide the full classification context or nothing. Also deduplicates the LayoutFlags type: canonical definition lives in app-elements.ts, re-exported from app-page-execution.ts. --- .../vinext/src/server/app-page-execution.ts | 40 ++++++----- packages/vinext/src/server/app-page-probe.ts | 16 ++--- tests/app-page-execution.test.ts | 69 ++++++++++--------- tests/app-page-probe.test.ts | 32 +++++---- 4 files changed, 81 insertions(+), 76 deletions(-) diff --git a/packages/vinext/src/server/app-page-execution.ts b/packages/vinext/src/server/app-page-execution.ts index 32e59c457..5b440842b 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,27 +23,27 @@ export type BuildAppPageSpecialErrorResponseOptions = { specialError: AppPageSpecialError; }; -export type LayoutFlags = Readonly>; - 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; - - /** 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 }>; + /** When provided, enables per-layout static/dynamic classification. */ + classification?: LayoutClassificationOptions | null; }; export type ProbeAppPageComponentOptions = { @@ -116,29 +120,29 @@ export async function probeAppPageLayouts( options: ProbeAppPageLayoutsOptions, ): Promise { const layoutFlags: Record = {}; - const hasClassification = !!(options.getLayoutId && options.runWithIsolatedDynamicScope); + const cls = options.classification ?? null; const response = await options.runWithSuppressedHookWarning(async () => { for (let layoutIndex = options.layoutCount - 1; layoutIndex >= 0; layoutIndex--) { - const buildTimeResult = options.buildTimeClassifications?.get(layoutIndex); + const buildTimeResult = cls?.buildTimeClassifications?.get(layoutIndex); - if (hasClassification && buildTimeResult) { + if (cls && buildTimeResult) { // Build-time classified (Layer 1 or Layer 2): skip dynamic isolation, // but still probe for special errors (redirects, not-found). - layoutFlags[options.getLayoutId!(layoutIndex)] = buildTimeResult === "static" ? "s" : "d"; + layoutFlags[cls.getLayoutId(layoutIndex)] = buildTimeResult === "static" ? "s" : "d"; const errorResponse = await probeLayoutForErrors(options, layoutIndex); if (errorResponse) return errorResponse; continue; } - if (hasClassification) { + if (cls) { // Layer 3: probe with isolated dynamic scope to detect per-layout // dynamic API usage (headers(), cookies(), connection(), etc.) try { - const { dynamicDetected } = await options.runWithIsolatedDynamicScope!(() => + const { dynamicDetected } = await cls.runWithIsolatedDynamicScope(() => options.probeLayoutAt(layoutIndex), ); - layoutFlags[options.getLayoutId!(layoutIndex)] = dynamicDetected ? "d" : "s"; + layoutFlags[cls.getLayoutId(layoutIndex)] = dynamicDetected ? "d" : "s"; } catch (error) { const errorResponse = await options.onLayoutError(error, layoutIndex); if (errorResponse) return errorResponse; diff --git a/packages/vinext/src/server/app-page-probe.ts b/packages/vinext/src/server/app-page-probe.ts index d896b540b..443bff36a 100644 --- a/packages/vinext/src/server/app-page-probe.ts +++ b/packages/vinext/src/server/app-page-probe.ts @@ -2,6 +2,7 @@ import { probeAppPageComponent, probeAppPageLayouts, type AppPageSpecialError, + type LayoutClassificationOptions, type LayoutFlags, } from "./app-page-execution.js"; @@ -22,15 +23,8 @@ export type ProbeAppPageBeforeRenderOptions = { renderPageSpecialError: (specialError: AppPageSpecialError) => Promise; resolveSpecialError: (error: unknown) => AppPageSpecialError | null; runWithSuppressedHookWarning(probe: () => Promise): Promise; - - /** 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 }>; + /** When provided, enables per-layout static/dynamic classification. */ + classification?: LayoutClassificationOptions | null; }; export async function probeAppPageBeforeRender( @@ -55,9 +49,7 @@ export async function probeAppPageBeforeRender( runWithSuppressedHookWarning(probe) { return options.runWithSuppressedHookWarning(probe); }, - buildTimeClassifications: options.buildTimeClassifications, - getLayoutId: options.getLayoutId, - runWithIsolatedDynamicScope: options.runWithIsolatedDynamicScope, + classification: options.classification, }); layoutFlags = layoutProbeResult.layoutFlags; diff --git a/tests/app-page-execution.test.ts b/tests/app-page-execution.test.ts index 9574cf0c4..c6fc14abc 100644 --- a/tests/app-page-execution.test.ts +++ b/tests/app-page-execution.test.ts @@ -166,16 +166,17 @@ describe("app page execution helpers", () => { runWithSuppressedHookWarning(probe) { return probe(); }, - buildTimeClassifications: new Map([ - [0, "static"], - [2, "dynamic"], - ]), - getLayoutId(layoutIndex) { - return ["layout:/", "layout:/blog", "layout:/blog/post"][layoutIndex]; - }, - runWithIsolatedDynamicScope(fn) { - // Simulate: layout 1 triggers dynamic usage, others don't - return Promise.resolve({ result: fn(), dynamicDetected: false }); + 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 }); + }, }, }); @@ -202,18 +203,20 @@ describe("app page execution helpers", () => { runWithSuppressedHookWarning(probe) { return probe(); }, - 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, - }); + 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, + }); + }, }, }); @@ -255,16 +258,18 @@ describe("app page execution helpers", () => { runWithSuppressedHookWarning(probe) { return probe(); }, - buildTimeClassifications: new Map([ - [0, "static"], - [1, "dynamic"], - ]), - getLayoutId(layoutIndex) { - return ["layout:/", "layout:/admin"][layoutIndex]; - }, - runWithIsolatedDynamicScope(fn) { - probeCalls++; - return Promise.resolve({ result: fn(), dynamicDetected: false }); + 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 }); + }, }, }); diff --git a/tests/app-page-probe.test.ts b/tests/app-page-probe.test.ts index 07a503d2d..f506e06bb 100644 --- a/tests/app-page-probe.test.ts +++ b/tests/app-page-probe.test.ts @@ -147,15 +147,17 @@ describe("app page probe helpers", () => { runWithSuppressedHookWarning(probe) { return probe(); }, - buildTimeClassifications: new Map([ - [0, "static"], - [1, "dynamic"], - ]), - getLayoutId(layoutIndex) { - return ["layout:/", "layout:/admin"][layoutIndex]; - }, - runWithIsolatedDynamicScope(fn) { - return Promise.resolve({ result: fn(), dynamicDetected: false }); + classification: { + buildTimeClassifications: new Map([ + [0, "static"], + [1, "dynamic"], + ]), + getLayoutId(layoutIndex) { + return ["layout:/", "layout:/admin"][layoutIndex]; + }, + runWithIsolatedDynamicScope(fn) { + return Promise.resolve({ result: fn(), dynamicDetected: false }); + }, }, }); @@ -191,11 +193,13 @@ describe("app page probe helpers", () => { runWithSuppressedHookWarning(probe) { return probe(); }, - getLayoutId(layoutIndex) { - return ["layout:/", "layout:/admin"][layoutIndex]; - }, - runWithIsolatedDynamicScope(fn) { - return Promise.resolve({ result: fn(), dynamicDetected: false }); + classification: { + getLayoutId(layoutIndex) { + return ["layout:/", "layout:/admin"][layoutIndex]; + }, + runWithIsolatedDynamicScope(fn) { + return Promise.resolve({ result: fn(), dynamicDetected: false }); + }, }, }); From a212cf5950ff1db70ec8a0039ab22de5c3d4b46a Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Fri, 3 Apr 2026 20:54:29 +1100 Subject: [PATCH 29/32] fix: default to dynamic flag when layout probe throws non-special error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When runWithIsolatedDynamicScope throws and the error is non-special (onLayoutError returns null), the layout was silently omitted from layoutFlags. Now conservatively defaults to "d" — if probing failed, the layout cannot be proven static. --- .../vinext/src/server/app-page-execution.ts | 2 ++ tests/app-page-execution.test.ts | 32 +++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/packages/vinext/src/server/app-page-execution.ts b/packages/vinext/src/server/app-page-execution.ts index 5b440842b..b55b981e0 100644 --- a/packages/vinext/src/server/app-page-execution.ts +++ b/packages/vinext/src/server/app-page-execution.ts @@ -144,6 +144,8 @@ export async function probeAppPageLayouts( ); 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; } diff --git a/tests/app-page-execution.test.ts b/tests/app-page-execution.test.ts index c6fc14abc..0305d9b9b 100644 --- a/tests/app-page-execution.test.ts +++ b/tests/app-page-execution.test.ts @@ -245,6 +245,38 @@ describe("app page execution helpers", () => { 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({ From c5464ba80caf549919b0617c24058706e126d899 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Fri, 3 Apr 2026 21:52:05 +1100 Subject: [PATCH 30/32] feat: add X-Vinext-Router-Skip header constants, parser, and builder Pure functions for the skip header optimization: - parseSkipHeader: parses comma-separated layout IDs, filters to layout:* only - buildSkipHeaderValue: builds header value from static layout flags - X_VINEXT_ROUTER_SKIP_HEADER: header name constant Defense-in-depth: parser rejects non-layout entries at the parse boundary. --- packages/vinext/src/server/app-elements.ts | 22 ++++++++ tests/app-elements.test.ts | 65 ++++++++++++++++++++++ 2 files changed, 87 insertions(+) diff --git a/packages/vinext/src/server/app-elements.ts b/packages/vinext/src/server/app-elements.ts index 5109f7016..8364113a0 100644 --- a/packages/vinext/src/server/app-elements.ts +++ b/packages/vinext/src/server/app-elements.ts @@ -81,6 +81,28 @@ export function normalizeAppElements(elements: AppWireElements): AppElements { return normalized; } +export const X_VINEXT_ROUTER_SKIP_HEADER = "X-Vinext-Router-Skip"; + +export function parseSkipHeader(header: string | null): ReadonlySet { + if (!header) return new Set(); + const ids = new Set(); + for (const part of header.split(",")) { + const trimmed = part.trim(); + if (trimmed.startsWith("layout:")) { + ids.add(trimmed); + } + } + return ids; +} + +export function buildSkipHeaderValue(layoutFlags: LayoutFlags): string | null { + const staticIds: string[] = []; + for (const [id, flag] of Object.entries(layoutFlags)) { + if (flag === "s") staticIds.push(id); + } + return staticIds.length > 0 ? staticIds.join(",") : null; +} + function isLayoutFlagsRecord(value: unknown): value is LayoutFlags { if (!value || typeof value !== "object" || Array.isArray(value)) return false; for (const v of Object.values(value)) { diff --git a/tests/app-elements.test.ts b/tests/app-elements.test.ts index 0cf968f1b..bc9975b29 100644 --- a/tests/app-elements.test.ts +++ b/tests/app-elements.test.ts @@ -7,9 +7,11 @@ import { APP_ROOT_LAYOUT_KEY, APP_ROUTE_KEY, APP_UNMATCHED_SLOT_WIRE_VALUE, + buildSkipHeaderValue, createAppPayloadCacheKey, createAppPayloadRouteId, normalizeAppElements, + parseSkipHeader, readAppElementsMetadata, resolveVisitedResponseInterceptionContext, } from "../packages/vinext/src/server/app-elements.js"; @@ -139,3 +141,66 @@ describe("app elements payload helpers", () => { expect(metadata.layoutFlags).toEqual({}); }); }); + +describe("parseSkipHeader", () => { + it("returns empty set for null header", () => { + expect(parseSkipHeader(null)).toEqual(new Set()); + }); + + it("returns empty set for empty string", () => { + expect(parseSkipHeader("")).toEqual(new Set()); + }); + + it("parses a single layout ID", () => { + expect(parseSkipHeader("layout:/")).toEqual(new Set(["layout:/"])); + }); + + it("parses comma-separated layout IDs", () => { + const result = parseSkipHeader("layout:/,layout:/blog,layout:/blog/posts"); + expect(result).toEqual(new Set(["layout:/", "layout:/blog", "layout:/blog/posts"])); + }); + + it("trims whitespace around entries", () => { + const result = parseSkipHeader(" layout:/ , layout:/blog "); + expect(result).toEqual(new Set(["layout:/", "layout:/blog"])); + }); + + it("filters out non-layout entries", () => { + const result = parseSkipHeader("layout:/,page:/blog,template:/,route:/api,slot:modal"); + expect(result).toEqual(new Set(["layout:/"])); + }); + + it("handles mixed layout and non-layout entries", () => { + const result = parseSkipHeader("layout:/,garbage,layout:/blog,page:/x"); + expect(result).toEqual(new Set(["layout:/", "layout:/blog"])); + }); +}); + +describe("buildSkipHeaderValue", () => { + it("returns null for empty flags", () => { + expect(buildSkipHeaderValue({})).toBeNull(); + }); + + it("returns null when all layouts are dynamic", () => { + expect(buildSkipHeaderValue({ "layout:/": "d", "layout:/blog": "d" })).toBeNull(); + }); + + it("includes only static layout IDs", () => { + const value = buildSkipHeaderValue({ "layout:/": "s", "layout:/blog": "d" }); + expect(value).toBe("layout:/"); + }); + + it("returns comma-separated IDs when multiple are static", () => { + const value = buildSkipHeaderValue({ + "layout:/": "s", + "layout:/blog": "s", + "layout:/blog/posts": "d", + }); + expect(value).toBe("layout:/,layout:/blog"); + }); + + it("returns all IDs when all are static", () => { + const value = buildSkipHeaderValue({ "layout:/": "s", "layout:/blog": "s" }); + expect(value).toBe("layout:/,layout:/blog"); + }); +}); From c5bad0b02230f5166876d3d052b6bc47022cf305 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Fri, 3 Apr 2026 21:52:14 +1100 Subject: [PATCH 31/32] feat: wire classification and __layoutFlags injection into render lifecycle - Add classification and requestedSkipLayoutIds to RenderAppPageLifecycleOptions - Thread classification to probeAppPageBeforeRender for per-layout dynamic detection - Inject __layoutFlags into elements record after probe, before RSC serialization - Skip filtering: omit static layouts the client has cached (RSC requests only) - Server validates every skip against its own classification (defense-in-depth) --- packages/vinext/src/server/app-page-render.ts | 31 ++ tests/app-page-render.test.ts | 372 ++++++++++++++++++ 2 files changed, 403 insertions(+) diff --git a/packages/vinext/src/server/app-page-render.ts b/packages/vinext/src/server/app-page-render.ts index fb63b3d51..488c8be0f 100644 --- a/packages/vinext/src/server/app-page-render.ts +++ b/packages/vinext/src/server/app-page-render.ts @@ -1,5 +1,6 @@ import type { ReactNode } from "react"; import type { CachedAppPageValue } from "../shims/cache.js"; +import { APP_LAYOUT_FLAGS_KEY } from "./app-elements.js"; import { finalizeAppPageHtmlCacheResponse, scheduleAppPageRscCacheWrite, @@ -10,6 +11,7 @@ import { teeAppPageRscStreamForCapture, type AppPageFontPreload, type AppPageSpecialError, + type LayoutClassificationOptions, } from "./app-page-execution.js"; import { probeAppPageBeforeRender } from "./app-page-probe.js"; import { @@ -92,6 +94,8 @@ export type RenderAppPageLifecycleOptions = { runWithSuppressedHookWarning(probe: () => Promise): Promise; waitUntil?: (promise: Promise) => void; element: ReactNode | Record; + classification?: LayoutClassificationOptions | null; + requestedSkipLayoutIds?: ReadonlySet; }; function buildResponseTiming( @@ -135,11 +139,38 @@ export async function renderAppPageLifecycle( runWithSuppressedHookWarning(probe) { return options.runWithSuppressedHookWarning(probe); }, + classification: options.classification, }); if (preRenderResult.response) { return preRenderResult.response; } + const layoutFlags = preRenderResult.layoutFlags; + + // Inject layout flags into the elements record so the client can read them + // via readAppElementsMetadata. Only applies when element is a plain record + // (App Router elements object), not a frozen React element. + const isElementsRecord = + typeof options.element === "object" && + options.element !== null && + !Array.isArray(options.element) && + Object.isExtensible(options.element); + + if (isElementsRecord) { + (options.element as Record)[APP_LAYOUT_FLAGS_KEY] = layoutFlags; + + // Skip filtering: omit static layouts the client already has cached. + // Only applies to RSC navigation requests — SSR/initial loads always render everything. + if (options.isRscRequest && options.requestedSkipLayoutIds) { + const elementsRecord = options.element as Record; + for (const id of options.requestedSkipLayoutIds) { + if (id in elementsRecord && layoutFlags[id] === "s") { + delete elementsRecord[id]; + } + } + } + } + const compileEnd = options.isProduction ? undefined : performance.now(); const baseOnError = options.createRscOnErrorHandler(options.cleanPathname, options.routePattern); const rscErrorTracker = createAppPageRscErrorTracker(baseOnError); diff --git a/tests/app-page-render.test.ts b/tests/app-page-render.test.ts index 2e64b626a..50bdce347 100644 --- a/tests/app-page-render.test.ts +++ b/tests/app-page-render.test.ts @@ -1,5 +1,8 @@ +import type { ReactNode } from "react"; import { describe, expect, it, vi } from "vite-plus/test"; import React from "react"; +import { APP_LAYOUT_FLAGS_KEY } from "../packages/vinext/src/server/app-elements.js"; +import type { LayoutClassificationOptions } from "../packages/vinext/src/server/app-page-execution.js"; import { renderAppPageLifecycle } from "../packages/vinext/src/server/app-page-render.js"; function createStream(chunks: string[]): ReadableStream { @@ -299,3 +302,372 @@ describe("app page render lifecycle", () => { ); }); }); + +describe("layoutFlags injection into RSC payload", () => { + function createRscOptions(overrides: { + element?: Record; + layoutCount?: number; + probeLayoutAt?: (index: number) => unknown; + classification?: LayoutClassificationOptions | null; + requestedSkipLayoutIds?: ReadonlySet; + }) { + let capturedElement: unknown = null; + + const options = { + cleanPathname: "/test", + clearRequestContext: vi.fn(), + consumeDynamicUsage: vi.fn(() => false), + createRscOnErrorHandler: () => () => {}, + getDraftModeCookieHeader: () => null, + getFontLinks: () => [], + getFontPreloads: () => [], + getFontStyles: () => [], + getNavigationContext: () => null, + getPageTags: () => [], + getRequestCacheLife: () => null, + handlerStart: 0, + hasLoadingBoundary: false, + isDynamicError: false, + isForceDynamic: false, + isForceStatic: false, + isProduction: true, + isRscRequest: true, + isrHtmlKey: (p: string) => `html:${p}`, + isrRscKey: (p: string) => `rsc:${p}`, + isrSet: vi.fn().mockResolvedValue(undefined), + layoutCount: overrides.layoutCount ?? 0, + loadSsrHandler: vi.fn(), + middlewareContext: { headers: null, status: null }, + params: {}, + probeLayoutAt: overrides.probeLayoutAt ?? (() => null), + probePage: () => null, + revalidateSeconds: null, + renderErrorBoundaryResponse: async () => null, + renderLayoutSpecialError: async () => new Response("error", { status: 500 }), + renderPageSpecialError: async () => new Response("error", { status: 500 }), + renderToReadableStream(el: unknown) { + capturedElement = el; + return createStream(["flight-data"]); + }, + routeHasLocalBoundary: false, + routePattern: "/test", + runWithSuppressedHookWarning: (probe: () => Promise) => probe(), + element: overrides.element ?? { "page:/test": "test-page" }, + classification: overrides.classification, + requestedSkipLayoutIds: overrides.requestedSkipLayoutIds, + }; + + return { options, getCapturedElement: () => capturedElement as Record }; + } + + it("injects __layoutFlags with 's' when classification detects a static layout", async () => { + const { options, getCapturedElement } = createRscOptions({ + element: { "layout:/": "root-layout", "page:/test": "test-page" }, + layoutCount: 1, + probeLayoutAt: () => null, + classification: { + getLayoutId: () => "layout:/", + buildTimeClassifications: null, + async runWithIsolatedDynamicScope(fn) { + const result = await fn(); + return { result, dynamicDetected: false }; + }, + }, + }); + + await renderAppPageLifecycle(options); + expect(getCapturedElement()[APP_LAYOUT_FLAGS_KEY]).toEqual({ "layout:/": "s" }); + }); + + it("injects __layoutFlags with 'd' for dynamic layouts", async () => { + const { options, getCapturedElement } = createRscOptions({ + element: { "layout:/": "root-layout", "page:/test": "test-page" }, + layoutCount: 1, + probeLayoutAt: () => null, + classification: { + getLayoutId: () => "layout:/", + buildTimeClassifications: null, + async runWithIsolatedDynamicScope(fn) { + const result = await fn(); + return { result, dynamicDetected: true }; + }, + }, + }); + + await renderAppPageLifecycle(options); + expect(getCapturedElement()[APP_LAYOUT_FLAGS_KEY]).toEqual({ "layout:/": "d" }); + }); + + it("injects empty __layoutFlags when classification is not provided (backward compat)", async () => { + const { options, getCapturedElement } = createRscOptions({ + element: { "layout:/": "root-layout", "page:/test": "test-page" }, + layoutCount: 1, + probeLayoutAt: () => null, + }); + + await renderAppPageLifecycle(options); + expect(getCapturedElement()[APP_LAYOUT_FLAGS_KEY]).toEqual({}); + }); + + it("injects __layoutFlags for multiple independently classified layouts", async () => { + let callCount = 0; + const { options, getCapturedElement } = createRscOptions({ + element: { + "layout:/": "root-layout", + "layout:/blog": "blog-layout", + "page:/blog/post": "post-page", + }, + layoutCount: 2, + probeLayoutAt: () => null, + classification: { + getLayoutId: (index: number) => (index === 0 ? "layout:/" : "layout:/blog"), + buildTimeClassifications: null, + async runWithIsolatedDynamicScope(fn) { + callCount++; + const result = await fn(); + // probeAppPageLayouts iterates from layoutCount-1 down to 0: + // call 1 → layout index 1 (blog) → dynamic + // call 2 → layout index 0 (root) → static + return { result, dynamicDetected: callCount === 1 }; + }, + }, + }); + + await renderAppPageLifecycle(options); + expect(getCapturedElement()[APP_LAYOUT_FLAGS_KEY]).toEqual({ + "layout:/": "s", + "layout:/blog": "d", + }); + }); + + it("__layoutFlags includes flags for ALL layouts even when some are skipped", async () => { + const { options, getCapturedElement } = createRscOptions({ + element: { + "layout:/": "root-layout", + "layout:/blog": "blog-layout", + "page:/blog/post": "post-page", + }, + layoutCount: 2, + probeLayoutAt: () => null, + classification: { + getLayoutId: (index: number) => (index === 0 ? "layout:/" : "layout:/blog"), + buildTimeClassifications: null, + async runWithIsolatedDynamicScope(fn) { + const result = await fn(); + return { result, dynamicDetected: false }; + }, + }, + requestedSkipLayoutIds: new Set(["layout:/"]), + }); + + await renderAppPageLifecycle(options); + // layoutFlags must include ALL layout flags, even for skipped layouts + expect(getCapturedElement()[APP_LAYOUT_FLAGS_KEY]).toEqual({ + "layout:/": "s", + "layout:/blog": "s", + }); + }); +}); + +describe("skip header filtering", () => { + function createRscOptions(overrides: { + element?: Record; + layoutCount?: number; + probeLayoutAt?: (index: number) => unknown; + classification?: LayoutClassificationOptions | null; + requestedSkipLayoutIds?: ReadonlySet; + isRscRequest?: boolean; + }) { + let capturedElement: unknown = null; + + const options = { + cleanPathname: "/test", + clearRequestContext: vi.fn(), + consumeDynamicUsage: vi.fn(() => false), + createRscOnErrorHandler: () => () => {}, + getDraftModeCookieHeader: () => null, + getFontLinks: () => [], + getFontPreloads: () => [], + getFontStyles: () => [], + getNavigationContext: () => null, + getPageTags: () => [], + getRequestCacheLife: () => null, + handlerStart: 0, + hasLoadingBoundary: false, + isDynamicError: false, + isForceDynamic: false, + isForceStatic: false, + isProduction: true, + isRscRequest: overrides.isRscRequest ?? true, + isrHtmlKey: (p: string) => `html:${p}`, + isrRscKey: (p: string) => `rsc:${p}`, + isrSet: vi.fn().mockResolvedValue(undefined), + layoutCount: overrides.layoutCount ?? 0, + loadSsrHandler: vi.fn(async () => ({ + async handleSsr() { + return createStream(["page"]); + }, + })), + middlewareContext: { headers: null, status: null }, + params: {}, + probeLayoutAt: overrides.probeLayoutAt ?? (() => null), + probePage: () => null, + revalidateSeconds: null, + renderErrorBoundaryResponse: async () => null, + renderLayoutSpecialError: async () => new Response("error", { status: 500 }), + renderPageSpecialError: async () => new Response("error", { status: 500 }), + renderToReadableStream(el: unknown) { + capturedElement = el; + return createStream(["flight-data"]); + }, + routeHasLocalBoundary: false, + routePattern: "/test", + runWithSuppressedHookWarning: (probe: () => Promise) => probe(), + element: overrides.element ?? { "page:/test": "test-page" }, + classification: overrides.classification, + requestedSkipLayoutIds: overrides.requestedSkipLayoutIds, + }; + + return { options, getCapturedElement: () => capturedElement as Record }; + } + + function staticClassification(layoutIdMap: Record): LayoutClassificationOptions { + return { + getLayoutId: (index: number) => layoutIdMap[index], + buildTimeClassifications: null, + async runWithIsolatedDynamicScope(fn) { + const result = await fn(); + return { result, dynamicDetected: false }; + }, + }; + } + + it("omits skipped static layouts from elements on RSC requests", async () => { + const { options, getCapturedElement } = createRscOptions({ + element: { + "layout:/": "root-layout", + "layout:/blog": "blog-layout", + "page:/blog/post": "post-page", + }, + layoutCount: 2, + probeLayoutAt: () => null, + classification: staticClassification({ 0: "layout:/", 1: "layout:/blog" }), + requestedSkipLayoutIds: new Set(["layout:/"]), + }); + + await renderAppPageLifecycle(options); + const captured = getCapturedElement(); + expect(captured["layout:/"]).toBeUndefined(); + expect(captured["layout:/blog"]).toBe("blog-layout"); + expect(captured["page:/blog/post"]).toBe("post-page"); + }); + + it("never skips dynamic layouts even if in skip set", async () => { + const { options, getCapturedElement } = createRscOptions({ + element: { + "layout:/": "root-layout", + "page:/test": "test-page", + }, + layoutCount: 1, + probeLayoutAt: () => null, + classification: { + getLayoutId: () => "layout:/", + buildTimeClassifications: null, + async runWithIsolatedDynamicScope(fn) { + const result = await fn(); + return { result, dynamicDetected: true }; // dynamic + }, + }, + requestedSkipLayoutIds: new Set(["layout:/"]), + }); + + await renderAppPageLifecycle(options); + const captured = getCapturedElement(); + // Dynamic layout must NOT be deleted even though client requested skip + expect(captured["layout:/"]).toBe("root-layout"); + }); + + it("always preserves metadata keys (__route, __rootLayout, __interceptionContext, __layoutFlags)", async () => { + const { options, getCapturedElement } = createRscOptions({ + element: { + __route: "route:/blog", + __rootLayout: "/", + __interceptionContext: null, + "layout:/": "root-layout", + "page:/blog": "blog-page", + }, + layoutCount: 1, + probeLayoutAt: () => null, + classification: staticClassification({ 0: "layout:/" }), + requestedSkipLayoutIds: new Set(["layout:/"]), + }); + + await renderAppPageLifecycle(options); + const captured = getCapturedElement(); + expect(captured.__route).toBe("route:/blog"); + expect(captured.__rootLayout).toBe("/"); + expect(captured.__interceptionContext).toBeNull(); + expect(captured[APP_LAYOUT_FLAGS_KEY]).toBeDefined(); + }); + + it("always preserves non-layout entries (page, template, slot, route)", async () => { + const { options, getCapturedElement } = createRscOptions({ + element: { + "layout:/": "root-layout", + "page:/test": "test-page", + "template:/test": "test-template", + "route:/test": "test-route", + "slot:modal:/": "modal-slot", + }, + layoutCount: 1, + probeLayoutAt: () => null, + classification: staticClassification({ 0: "layout:/" }), + requestedSkipLayoutIds: new Set(["layout:/"]), + }); + + await renderAppPageLifecycle(options); + const captured = getCapturedElement(); + expect(captured["page:/test"]).toBe("test-page"); + expect(captured["template:/test"]).toBe("test-template"); + expect(captured["route:/test"]).toBe("test-route"); + expect(captured["slot:modal:/"]).toBe("modal-slot"); + }); + + it("renders all layouts when skip set is empty", async () => { + const { options, getCapturedElement } = createRscOptions({ + element: { + "layout:/": "root-layout", + "layout:/blog": "blog-layout", + "page:/blog/post": "post-page", + }, + layoutCount: 2, + probeLayoutAt: () => null, + classification: staticClassification({ 0: "layout:/", 1: "layout:/blog" }), + requestedSkipLayoutIds: new Set(), + }); + + await renderAppPageLifecycle(options); + const captured = getCapturedElement(); + expect(captured["layout:/"]).toBe("root-layout"); + expect(captured["layout:/blog"]).toBe("blog-layout"); + }); + + it("does not filter layouts on non-RSC requests (SSR/initial load)", async () => { + const { options, getCapturedElement } = createRscOptions({ + element: { + "layout:/": "root-layout", + "page:/test": "test-page", + }, + layoutCount: 1, + probeLayoutAt: () => null, + classification: staticClassification({ 0: "layout:/" }), + requestedSkipLayoutIds: new Set(["layout:/"]), + isRscRequest: false, + }); + + await renderAppPageLifecycle(options); + const captured = getCapturedElement(); + // SSR path: layout must NOT be filtered + expect(captured["layout:/"]).toBe("root-layout"); + }); +}); From d1fe67be4e0a51f772e3ce28f49f47eaea1aa97a Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Fri, 3 Apr 2026 21:52:28 +1100 Subject: [PATCH 32/32] feat: send X-Vinext-Router-Skip header on client navigation + wire entry Client side: - createRscRequestHeaders now accepts layoutFlags and sends skip header - Only sent on navigation fetches (not SSR, HMR, server actions, or prefetches) Generated entry: - Wire classification with getLayoutId, runWithIsolatedDynamicScope - Parse skip header from incoming RSC requests, pass to renderAppPageLifecycle --- packages/vinext/src/entries/app-rsc-entry.ts | 24 +++++++++++++++++++ .../vinext/src/server/app-browser-entry.ts | 16 +++++++++++-- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index bbd1c691d..d808582fa 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -382,9 +382,12 @@ import { import { APP_INTERCEPTION_CONTEXT_KEY as __APP_INTERCEPTION_CONTEXT_KEY, createAppPayloadRouteId as __createAppPayloadRouteId, + parseSkipHeader as __parseSkipHeader, + X_VINEXT_ROUTER_SKIP_HEADER as __X_VINEXT_ROUTER_SKIP_HEADER, } from ${JSON.stringify(appElementsPath)}; import { buildAppPageElements as __buildAppPageElements, + createAppPageTreePath as __createAppPageTreePath, resolveAppPageChildSegments as __resolveAppPageChildSegments, } from ${JSON.stringify(appPageRouteWiringPath)}; import { @@ -1348,6 +1351,9 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } const isRscRequest = pathname.endsWith(".rsc") || request.headers.get("accept")?.includes("text/x-component"); + const __skipLayoutIds = isRscRequest + ? __parseSkipHeader(request.headers.get(__X_VINEXT_ROUTER_SKIP_HEADER)) + : new Set(); const interceptionContextHeader = request.headers.get("X-Vinext-Interception-Context"); let cleanPathname = pathname.replace(/\\.rsc$/, ""); @@ -2279,7 +2285,25 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { probePage() { return PageComponent({ params }); }, + classification: { + getLayoutId(index) { + const tp = route.layoutTreePositions?.[index] ?? 0; + return "layout:" + __createAppPageTreePath(route.routeSegments, tp); + }, + buildTimeClassifications: null, // Future: embed from Vite build plugin codegen + async runWithIsolatedDynamicScope(fn) { + const priorDynamic = consumeDynamicUsage(); + try { + const result = await fn(); + const dynamicDetected = consumeDynamicUsage(); + return { result, dynamicDetected }; + } finally { + if (priorDynamic) markDynamicUsage(); + } + }, + }, revalidateSeconds, + requestedSkipLayoutIds: __skipLayoutIds, renderErrorBoundaryResponse(renderErr) { return renderErrorBoundaryPage(route, renderErr, isRscRequest, request, params); }, diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index 1c1635ba0..1ef4c6f8e 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -49,10 +49,12 @@ import { getVinextBrowserGlobal, } from "./app-browser-stream.js"; import { + buildSkipHeaderValue, createAppPayloadCacheKey, normalizeAppElements, readAppElementsMetadata, resolveVisitedResponseInterceptionContext, + X_VINEXT_ROUTER_SKIP_HEADER, type AppElements, type AppWireElements, type LayoutFlags, @@ -331,11 +333,18 @@ function getRequestState( } } -function createRscRequestHeaders(interceptionContext: string | null): Headers { +function createRscRequestHeaders( + interceptionContext: string | null, + layoutFlags: LayoutFlags, +): Headers { const headers = new Headers({ Accept: "text/x-component" }); if (interceptionContext !== null) { headers.set("X-Vinext-Interception-Context", interceptionContext); } + const skipValue = buildSkipHeaderValue(layoutFlags); + if (skipValue !== null) { + headers.set(X_VINEXT_ROUTER_SKIP_HEADER, skipValue); + } return headers; } @@ -870,7 +879,10 @@ async function main(): Promise { } if (!navResponse) { - const requestHeaders = createRscRequestHeaders(requestInterceptionContext); + const requestHeaders = createRscRequestHeaders( + requestInterceptionContext, + getBrowserRouterState().layoutFlags, + ); navResponse = await fetch(rscUrl, { headers: requestHeaders, credentials: "include",