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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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 f8e22769e2f156e958458e227cbd4be08b7d4a2e Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Thu, 2 Apr 2026 21:59:46 +1100 Subject: [PATCH 19/25] chore: trigger CI review From c346485a71a3465f14163ff63c62b18a6ca9adb8 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Thu, 2 Apr 2026 22:29:41 +1100 Subject: [PATCH 20/25] fix: skip Slot wrapping for layout entries without a default export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a layout entry exists but has no default export, the element is never written to the flat map. The unconditional Slot wrapping would return null (id not in elements), silently dropping the entire route subtree below that layout level. Guard the Slot wrapping with a check for the layout component. When absent, pass layoutChildren through directly — preserving the LayoutSegmentProvider and error/not-found boundaries while skipping the Slot indirection. --- .../src/server/app-page-route-wiring.tsx | 26 +++++++++------ tests/app-page-route-wiring.test.ts | 32 +++++++++++++++++++ 2 files changed, 48 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 1909780a6..14b59d499 100644 --- a/packages/vinext/src/server/app-page-route-wiring.tsx +++ b/packages/vinext/src/server/app-page-route-wiring.tsx @@ -531,6 +531,8 @@ export function buildAppPageElements< ); } + const layoutHasElement = getDefaultExport(layoutEntry.layoutModule) !== null; + routeChildren = ( - - {layoutChildren} - + {layoutHasElement ? ( + + {layoutChildren} + + ) : ( + layoutChildren + )} ); } diff --git a/tests/app-page-route-wiring.test.ts b/tests/app-page-route-wiring.test.ts index 8ca0844d8..6ee46798c 100644 --- a/tests/app-page-route-wiring.test.ts +++ b/tests/app-page-route-wiring.test.ts @@ -253,6 +253,38 @@ describe("app page route wiring helpers", () => { expect(body).toContain("Page content"); }); + it("preserves route subtree when a layout entry has no default export", 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, null], + layoutTreePositions: [0, 1], + layouts: [{ default: RootLayout }, null], + loading: null, + notFound: null, + notFounds: [null, null], + routeSegments: ["dashboard"], + slots: null, + templateTreePositions: [], + templates: [], + }, + routePath: "/dashboard", + rootNotFoundModule: null, + }); + + const body = await renderRouteEntry(elements, "route:/dashboard"); + + expect(body).toContain('data-layout="root"'); + expect(body).toContain("Page content"); + }); + it("waits for template-only segments before serializing the page entry", async () => { let activeLocale = "en"; From 9b9a6d5c50c7ea3a6107b0e6e2350bafacd5f546 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sat, 4 Apr 2026 14:24:52 +1100 Subject: [PATCH 21/25] fix: restore merged app router entry behavior --- packages/vinext/src/entries/app-rsc-entry.ts | 71 +++- .../entry-templates.test.ts.snap | 387 ++++++++++++++++-- 2 files changed, 430 insertions(+), 28 deletions(-) diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index 0e9faf7e8..cbdc830f7 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -60,6 +60,7 @@ const appPageRouteWiringPath = resolveEntryPath( import.meta.url, ); const appPageRenderPath = resolveEntryPath("../server/app-page-render.js", import.meta.url); +const appPageResponsePath = resolveEntryPath("../server/app-page-response.js", import.meta.url); const appPageRequestPath = resolveEntryPath("../server/app-page-request.js", import.meta.url); const appRouteHandlerResponsePath = resolveEntryPath( "../server/app-route-handler-response.js", @@ -97,6 +98,8 @@ export type AppRouterConfig = { * `virtual:vinext-server-entry` when this flag is set. */ hasPagesDir?: boolean; + /** Exact public/ file routes, using normalized leading-slash pathnames. */ + publicFiles?: string[]; }; /** @@ -126,6 +129,7 @@ export function generateRscEntry( const bodySizeLimit = config?.bodySizeLimit ?? 1 * 1024 * 1024; const i18nConfig = config?.i18n ?? null; const hasPagesDir = config?.hasPagesDir ?? false; + const publicFiles = config?.publicFiles ?? []; // Build import map for all page and layout files const imports: string[] = []; const importMap: Map = new Map(); @@ -385,6 +389,9 @@ import { import { renderAppPageLifecycle as __renderAppPageLifecycle, } from ${JSON.stringify(appPageRenderPath)}; +import { + mergeMiddlewareResponseHeaders as __mergeMiddlewareResponseHeaders, +} from ${JSON.stringify(appPageResponsePath)}; import { buildAppPageElement as __buildAppPageElement, resolveAppPageIntercept as __resolveAppPageIntercept, @@ -816,6 +823,21 @@ function matchRoute(url) { return _trieMatch(_routeTrie, urlParts); } +function __createStaticFileSignal(pathname, _mwCtx) { + const headers = new Headers({ + "x-vinext-static-file": encodeURIComponent(pathname), + }); + if (_mwCtx.headers) { + for (const [key, value] of _mwCtx.headers) { + headers.append(key, value); + } + } + return new Response(null, { + status: _mwCtx.status ?? 200, + headers, + }); +} + // matchPattern is kept for findIntercept (linear scan over small interceptLookup array). function matchPattern(urlParts, patternParts) { const params = Object.create(null); @@ -1024,6 +1046,7 @@ const __i18nConfig = ${JSON.stringify(i18nConfig)}; const __configRedirects = ${JSON.stringify(redirects)}; const __configRewrites = ${JSON.stringify(rewrites)}; const __configHeaders = ${JSON.stringify(headers)}; +const __publicFiles = new Set(${JSON.stringify(publicFiles)}); const __allowedOrigins = ${JSON.stringify(allowedOrigins)}; ${generateDevOriginCheckCode(config?.allowedDevOrigins)} @@ -1234,6 +1257,9 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { ${ bp ? ` + if (!hasBasePath(pathname, __basePath) && !pathname.startsWith("/__vinext/")) { + return new Response("Not Found", { status: 404 }); + } // Strip basePath prefix pathname = stripBasePath(pathname, __basePath); ` @@ -1591,6 +1617,18 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } } + // Serve public/ files as filesystem routes after middleware and before + // afterFiles/fallback rewrites, matching Next.js routing semantics. + if ( + (request.method === "GET" || request.method === "HEAD") && + !pathname.endsWith(".rsc") && + __publicFiles.has(cleanPathname) + ) { + setHeadersContext(null); + setNavigationContext(null); + return __createStaticFileSignal(cleanPathname, _mwCtx); + } + // Set navigation context for Server Components. // Note: Headers context is already set by runWithRequestContext in the handler wrapper. setNavigationContext({ @@ -1695,6 +1733,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { "x-action-redirect-type": actionRedirect.type, "x-action-redirect-status": String(actionRedirect.status), }); + __mergeMiddlewareResponseHeaders(redirectHeaders, _mwCtx.headers); for (const cookie of actionPendingCookies) { redirectHeaders.append("Set-Cookie", cookie); } @@ -1748,8 +1787,15 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const actionPendingCookies = getAndClearPendingCookies(); const actionDraftCookie = getDraftModeCookieHeader(); - const actionHeaders = { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }; - const actionResponse = new Response(rscStream, { headers: actionHeaders }); + const actionHeaders = new Headers({ + "Content-Type": "text/x-component; charset=utf-8", + "Vary": "RSC, Accept", + }); + __mergeMiddlewareResponseHeaders(actionHeaders, _mwCtx.headers); + const actionResponse = new Response(rscStream, { + status: _mwCtx.status ?? 200, + headers: actionHeaders, + }); if (actionPendingCookies.length > 0 || actionDraftCookie) { for (const cookie of actionPendingCookies) { actionResponse.headers.append("Set-Cookie", cookie); @@ -2169,8 +2215,14 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // by the client, and async server components that run during consumption need the // context to still be live. The AsyncLocalStorage scope from runWithRequestContext // handles cleanup naturally when all async continuations complete. + const interceptHeaders = new Headers({ + "Content-Type": "text/x-component; charset=utf-8", + "Vary": "RSC, Accept", + }); + __mergeMiddlewareResponseHeaders(interceptHeaders, _mwCtx.headers); return new Response(interceptStream, { - headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }, + status: _mwCtx.status ?? 200, + headers: interceptHeaders, }); }, searchParams: url.searchParams, @@ -2266,7 +2318,18 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { return LayoutComp({ params: _asyncLayoutParams, children: null }); }, probePage() { - return PageComponent({ params }); + const _probeSearchObj = {}; + url.searchParams.forEach(function(v, k) { + if (k in _probeSearchObj) { + _probeSearchObj[k] = Array.isArray(_probeSearchObj[k]) + ? _probeSearchObj[k].concat(v) + : [_probeSearchObj[k], v]; + } else { + _probeSearchObj[k] = v; + } + }); + const _asyncSearchParams = makeThenableParams(_probeSearchObj); + return PageComponent({ params: _asyncLayoutParams, searchParams: _asyncSearchParams }); }, revalidateSeconds, renderErrorBoundaryResponse(renderErr) { diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index ade238e1a..9b321f849 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -84,6 +84,9 @@ import { import { renderAppPageLifecycle as __renderAppPageLifecycle, } from "/packages/vinext/src/server/app-page-render.js"; +import { + mergeMiddlewareResponseHeaders as __mergeMiddlewareResponseHeaders, +} from "/packages/vinext/src/server/app-page-response.js"; import { buildAppPageElement as __buildAppPageElement, resolveAppPageIntercept as __resolveAppPageIntercept, @@ -582,6 +585,21 @@ function matchRoute(url) { return _trieMatch(_routeTrie, urlParts); } +function __createStaticFileSignal(pathname, _mwCtx) { + const headers = new Headers({ + "x-vinext-static-file": encodeURIComponent(pathname), + }); + if (_mwCtx.headers) { + for (const [key, value] of _mwCtx.headers) { + headers.append(key, value); + } + } + return new Response(null, { + status: _mwCtx.status ?? 200, + headers, + }); +} + // matchPattern is kept for findIntercept (linear scan over small interceptLookup array). function matchPattern(urlParts, patternParts) { const params = Object.create(null); @@ -790,6 +808,7 @@ const __i18nConfig = null; const __configRedirects = []; const __configRewrites = {"beforeFiles":[],"afterFiles":[],"fallback":[]}; const __configHeaders = []; +const __publicFiles = new Set([]); const __allowedOrigins = []; @@ -1328,6 +1347,18 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } } + // Serve public/ files as filesystem routes after middleware and before + // afterFiles/fallback rewrites, matching Next.js routing semantics. + if ( + (request.method === "GET" || request.method === "HEAD") && + !pathname.endsWith(".rsc") && + __publicFiles.has(cleanPathname) + ) { + setHeadersContext(null); + setNavigationContext(null); + return __createStaticFileSignal(cleanPathname, _mwCtx); + } + // Set navigation context for Server Components. // Note: Headers context is already set by runWithRequestContext in the handler wrapper. setNavigationContext({ @@ -1432,6 +1463,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { "x-action-redirect-type": actionRedirect.type, "x-action-redirect-status": String(actionRedirect.status), }); + __mergeMiddlewareResponseHeaders(redirectHeaders, _mwCtx.headers); for (const cookie of actionPendingCookies) { redirectHeaders.append("Set-Cookie", cookie); } @@ -1485,8 +1517,15 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const actionPendingCookies = getAndClearPendingCookies(); const actionDraftCookie = getDraftModeCookieHeader(); - const actionHeaders = { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }; - const actionResponse = new Response(rscStream, { headers: actionHeaders }); + const actionHeaders = new Headers({ + "Content-Type": "text/x-component; charset=utf-8", + "Vary": "RSC, Accept", + }); + __mergeMiddlewareResponseHeaders(actionHeaders, _mwCtx.headers); + const actionResponse = new Response(rscStream, { + status: _mwCtx.status ?? 200, + headers: actionHeaders, + }); if (actionPendingCookies.length > 0 || actionDraftCookie) { for (const cookie of actionPendingCookies) { actionResponse.headers.append("Set-Cookie", cookie); @@ -1876,8 +1915,14 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // by the client, and async server components that run during consumption need the // context to still be live. The AsyncLocalStorage scope from runWithRequestContext // handles cleanup naturally when all async continuations complete. + const interceptHeaders = new Headers({ + "Content-Type": "text/x-component; charset=utf-8", + "Vary": "RSC, Accept", + }); + __mergeMiddlewareResponseHeaders(interceptHeaders, _mwCtx.headers); return new Response(interceptStream, { - headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }, + status: _mwCtx.status ?? 200, + headers: interceptHeaders, }); }, searchParams: url.searchParams, @@ -1973,7 +2018,18 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { return LayoutComp({ params: _asyncLayoutParams, children: null }); }, probePage() { - return PageComponent({ params }); + const _probeSearchObj = {}; + url.searchParams.forEach(function(v, k) { + if (k in _probeSearchObj) { + _probeSearchObj[k] = Array.isArray(_probeSearchObj[k]) + ? _probeSearchObj[k].concat(v) + : [_probeSearchObj[k], v]; + } else { + _probeSearchObj[k] = v; + } + }); + const _asyncSearchParams = makeThenableParams(_probeSearchObj); + return PageComponent({ params: _asyncLayoutParams, searchParams: _asyncSearchParams }); }, revalidateSeconds, renderErrorBoundaryResponse(renderErr) { @@ -2128,6 +2184,9 @@ import { import { renderAppPageLifecycle as __renderAppPageLifecycle, } from "/packages/vinext/src/server/app-page-render.js"; +import { + mergeMiddlewareResponseHeaders as __mergeMiddlewareResponseHeaders, +} from "/packages/vinext/src/server/app-page-response.js"; import { buildAppPageElement as __buildAppPageElement, resolveAppPageIntercept as __resolveAppPageIntercept, @@ -2626,6 +2685,21 @@ function matchRoute(url) { return _trieMatch(_routeTrie, urlParts); } +function __createStaticFileSignal(pathname, _mwCtx) { + const headers = new Headers({ + "x-vinext-static-file": encodeURIComponent(pathname), + }); + if (_mwCtx.headers) { + for (const [key, value] of _mwCtx.headers) { + headers.append(key, value); + } + } + return new Response(null, { + status: _mwCtx.status ?? 200, + headers, + }); +} + // matchPattern is kept for findIntercept (linear scan over small interceptLookup array). function matchPattern(urlParts, patternParts) { const params = Object.create(null); @@ -2834,6 +2908,7 @@ const __i18nConfig = null; const __configRedirects = [{"source":"/old","destination":"/new","permanent":true}]; const __configRewrites = {"beforeFiles":[{"source":"/api/:path*","destination":"/backend/:path*"}],"afterFiles":[],"fallback":[]}; const __configHeaders = [{"source":"/api/:path*","headers":[{"key":"X-Custom","value":"test"}]}]; +const __publicFiles = new Set([]); const __allowedOrigins = ["https://example.com"]; @@ -3200,6 +3275,9 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { let pathname = __normalizePath(decodedUrlPathname); + if (!hasBasePath(pathname, __basePath) && !pathname.startsWith("/__vinext/")) { + return new Response("Not Found", { status: 404 }); + } // Strip basePath prefix pathname = stripBasePath(pathname, __basePath); @@ -3375,6 +3453,18 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } } + // Serve public/ files as filesystem routes after middleware and before + // afterFiles/fallback rewrites, matching Next.js routing semantics. + if ( + (request.method === "GET" || request.method === "HEAD") && + !pathname.endsWith(".rsc") && + __publicFiles.has(cleanPathname) + ) { + setHeadersContext(null); + setNavigationContext(null); + return __createStaticFileSignal(cleanPathname, _mwCtx); + } + // Set navigation context for Server Components. // Note: Headers context is already set by runWithRequestContext in the handler wrapper. setNavigationContext({ @@ -3479,6 +3569,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { "x-action-redirect-type": actionRedirect.type, "x-action-redirect-status": String(actionRedirect.status), }); + __mergeMiddlewareResponseHeaders(redirectHeaders, _mwCtx.headers); for (const cookie of actionPendingCookies) { redirectHeaders.append("Set-Cookie", cookie); } @@ -3532,8 +3623,15 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const actionPendingCookies = getAndClearPendingCookies(); const actionDraftCookie = getDraftModeCookieHeader(); - const actionHeaders = { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }; - const actionResponse = new Response(rscStream, { headers: actionHeaders }); + const actionHeaders = new Headers({ + "Content-Type": "text/x-component; charset=utf-8", + "Vary": "RSC, Accept", + }); + __mergeMiddlewareResponseHeaders(actionHeaders, _mwCtx.headers); + const actionResponse = new Response(rscStream, { + status: _mwCtx.status ?? 200, + headers: actionHeaders, + }); if (actionPendingCookies.length > 0 || actionDraftCookie) { for (const cookie of actionPendingCookies) { actionResponse.headers.append("Set-Cookie", cookie); @@ -3923,8 +4021,14 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // by the client, and async server components that run during consumption need the // context to still be live. The AsyncLocalStorage scope from runWithRequestContext // handles cleanup naturally when all async continuations complete. + const interceptHeaders = new Headers({ + "Content-Type": "text/x-component; charset=utf-8", + "Vary": "RSC, Accept", + }); + __mergeMiddlewareResponseHeaders(interceptHeaders, _mwCtx.headers); return new Response(interceptStream, { - headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }, + status: _mwCtx.status ?? 200, + headers: interceptHeaders, }); }, searchParams: url.searchParams, @@ -4020,7 +4124,18 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { return LayoutComp({ params: _asyncLayoutParams, children: null }); }, probePage() { - return PageComponent({ params }); + const _probeSearchObj = {}; + url.searchParams.forEach(function(v, k) { + if (k in _probeSearchObj) { + _probeSearchObj[k] = Array.isArray(_probeSearchObj[k]) + ? _probeSearchObj[k].concat(v) + : [_probeSearchObj[k], v]; + } else { + _probeSearchObj[k] = v; + } + }); + const _asyncSearchParams = makeThenableParams(_probeSearchObj); + return PageComponent({ params: _asyncLayoutParams, searchParams: _asyncSearchParams }); }, revalidateSeconds, renderErrorBoundaryResponse(renderErr) { @@ -4175,6 +4290,9 @@ import { import { renderAppPageLifecycle as __renderAppPageLifecycle, } from "/packages/vinext/src/server/app-page-render.js"; +import { + mergeMiddlewareResponseHeaders as __mergeMiddlewareResponseHeaders, +} from "/packages/vinext/src/server/app-page-response.js"; import { buildAppPageElement as __buildAppPageElement, resolveAppPageIntercept as __resolveAppPageIntercept, @@ -4674,6 +4792,21 @@ function matchRoute(url) { return _trieMatch(_routeTrie, urlParts); } +function __createStaticFileSignal(pathname, _mwCtx) { + const headers = new Headers({ + "x-vinext-static-file": encodeURIComponent(pathname), + }); + if (_mwCtx.headers) { + for (const [key, value] of _mwCtx.headers) { + headers.append(key, value); + } + } + return new Response(null, { + status: _mwCtx.status ?? 200, + headers, + }); +} + // matchPattern is kept for findIntercept (linear scan over small interceptLookup array). function matchPattern(urlParts, patternParts) { const params = Object.create(null); @@ -4882,6 +5015,7 @@ const __i18nConfig = null; const __configRedirects = []; const __configRewrites = {"beforeFiles":[],"afterFiles":[],"fallback":[]}; const __configHeaders = []; +const __publicFiles = new Set([]); const __allowedOrigins = []; @@ -5420,6 +5554,18 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } } + // Serve public/ files as filesystem routes after middleware and before + // afterFiles/fallback rewrites, matching Next.js routing semantics. + if ( + (request.method === "GET" || request.method === "HEAD") && + !pathname.endsWith(".rsc") && + __publicFiles.has(cleanPathname) + ) { + setHeadersContext(null); + setNavigationContext(null); + return __createStaticFileSignal(cleanPathname, _mwCtx); + } + // Set navigation context for Server Components. // Note: Headers context is already set by runWithRequestContext in the handler wrapper. setNavigationContext({ @@ -5524,6 +5670,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { "x-action-redirect-type": actionRedirect.type, "x-action-redirect-status": String(actionRedirect.status), }); + __mergeMiddlewareResponseHeaders(redirectHeaders, _mwCtx.headers); for (const cookie of actionPendingCookies) { redirectHeaders.append("Set-Cookie", cookie); } @@ -5577,8 +5724,15 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const actionPendingCookies = getAndClearPendingCookies(); const actionDraftCookie = getDraftModeCookieHeader(); - const actionHeaders = { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }; - const actionResponse = new Response(rscStream, { headers: actionHeaders }); + const actionHeaders = new Headers({ + "Content-Type": "text/x-component; charset=utf-8", + "Vary": "RSC, Accept", + }); + __mergeMiddlewareResponseHeaders(actionHeaders, _mwCtx.headers); + const actionResponse = new Response(rscStream, { + status: _mwCtx.status ?? 200, + headers: actionHeaders, + }); if (actionPendingCookies.length > 0 || actionDraftCookie) { for (const cookie of actionPendingCookies) { actionResponse.headers.append("Set-Cookie", cookie); @@ -5968,8 +6122,14 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // by the client, and async server components that run during consumption need the // context to still be live. The AsyncLocalStorage scope from runWithRequestContext // handles cleanup naturally when all async continuations complete. + const interceptHeaders = new Headers({ + "Content-Type": "text/x-component; charset=utf-8", + "Vary": "RSC, Accept", + }); + __mergeMiddlewareResponseHeaders(interceptHeaders, _mwCtx.headers); return new Response(interceptStream, { - headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }, + status: _mwCtx.status ?? 200, + headers: interceptHeaders, }); }, searchParams: url.searchParams, @@ -6065,7 +6225,18 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { return LayoutComp({ params: _asyncLayoutParams, children: null }); }, probePage() { - return PageComponent({ params }); + const _probeSearchObj = {}; + url.searchParams.forEach(function(v, k) { + if (k in _probeSearchObj) { + _probeSearchObj[k] = Array.isArray(_probeSearchObj[k]) + ? _probeSearchObj[k].concat(v) + : [_probeSearchObj[k], v]; + } else { + _probeSearchObj[k] = v; + } + }); + const _asyncSearchParams = makeThenableParams(_probeSearchObj); + return PageComponent({ params: _asyncLayoutParams, searchParams: _asyncSearchParams }); }, revalidateSeconds, renderErrorBoundaryResponse(renderErr) { @@ -6220,6 +6391,9 @@ import { import { renderAppPageLifecycle as __renderAppPageLifecycle, } from "/packages/vinext/src/server/app-page-render.js"; +import { + mergeMiddlewareResponseHeaders as __mergeMiddlewareResponseHeaders, +} from "/packages/vinext/src/server/app-page-response.js"; import { buildAppPageElement as __buildAppPageElement, resolveAppPageIntercept as __resolveAppPageIntercept, @@ -6748,6 +6922,21 @@ function matchRoute(url) { return _trieMatch(_routeTrie, urlParts); } +function __createStaticFileSignal(pathname, _mwCtx) { + const headers = new Headers({ + "x-vinext-static-file": encodeURIComponent(pathname), + }); + if (_mwCtx.headers) { + for (const [key, value] of _mwCtx.headers) { + headers.append(key, value); + } + } + return new Response(null, { + status: _mwCtx.status ?? 200, + headers, + }); +} + // matchPattern is kept for findIntercept (linear scan over small interceptLookup array). function matchPattern(urlParts, patternParts) { const params = Object.create(null); @@ -6956,6 +7145,7 @@ const __i18nConfig = null; const __configRedirects = []; const __configRewrites = {"beforeFiles":[],"afterFiles":[],"fallback":[]}; const __configHeaders = []; +const __publicFiles = new Set([]); const __allowedOrigins = []; @@ -7497,6 +7687,18 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } } + // Serve public/ files as filesystem routes after middleware and before + // afterFiles/fallback rewrites, matching Next.js routing semantics. + if ( + (request.method === "GET" || request.method === "HEAD") && + !pathname.endsWith(".rsc") && + __publicFiles.has(cleanPathname) + ) { + setHeadersContext(null); + setNavigationContext(null); + return __createStaticFileSignal(cleanPathname, _mwCtx); + } + // Set navigation context for Server Components. // Note: Headers context is already set by runWithRequestContext in the handler wrapper. setNavigationContext({ @@ -7601,6 +7803,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { "x-action-redirect-type": actionRedirect.type, "x-action-redirect-status": String(actionRedirect.status), }); + __mergeMiddlewareResponseHeaders(redirectHeaders, _mwCtx.headers); for (const cookie of actionPendingCookies) { redirectHeaders.append("Set-Cookie", cookie); } @@ -7654,8 +7857,15 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const actionPendingCookies = getAndClearPendingCookies(); const actionDraftCookie = getDraftModeCookieHeader(); - const actionHeaders = { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }; - const actionResponse = new Response(rscStream, { headers: actionHeaders }); + const actionHeaders = new Headers({ + "Content-Type": "text/x-component; charset=utf-8", + "Vary": "RSC, Accept", + }); + __mergeMiddlewareResponseHeaders(actionHeaders, _mwCtx.headers); + const actionResponse = new Response(rscStream, { + status: _mwCtx.status ?? 200, + headers: actionHeaders, + }); if (actionPendingCookies.length > 0 || actionDraftCookie) { for (const cookie of actionPendingCookies) { actionResponse.headers.append("Set-Cookie", cookie); @@ -8045,8 +8255,14 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // by the client, and async server components that run during consumption need the // context to still be live. The AsyncLocalStorage scope from runWithRequestContext // handles cleanup naturally when all async continuations complete. + const interceptHeaders = new Headers({ + "Content-Type": "text/x-component; charset=utf-8", + "Vary": "RSC, Accept", + }); + __mergeMiddlewareResponseHeaders(interceptHeaders, _mwCtx.headers); return new Response(interceptStream, { - headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }, + status: _mwCtx.status ?? 200, + headers: interceptHeaders, }); }, searchParams: url.searchParams, @@ -8142,7 +8358,18 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { return LayoutComp({ params: _asyncLayoutParams, children: null }); }, probePage() { - return PageComponent({ params }); + const _probeSearchObj = {}; + url.searchParams.forEach(function(v, k) { + if (k in _probeSearchObj) { + _probeSearchObj[k] = Array.isArray(_probeSearchObj[k]) + ? _probeSearchObj[k].concat(v) + : [_probeSearchObj[k], v]; + } else { + _probeSearchObj[k] = v; + } + }); + const _asyncSearchParams = makeThenableParams(_probeSearchObj); + return PageComponent({ params: _asyncLayoutParams, searchParams: _asyncSearchParams }); }, revalidateSeconds, renderErrorBoundaryResponse(renderErr) { @@ -8297,6 +8524,9 @@ import { import { renderAppPageLifecycle as __renderAppPageLifecycle, } from "/packages/vinext/src/server/app-page-render.js"; +import { + mergeMiddlewareResponseHeaders as __mergeMiddlewareResponseHeaders, +} from "/packages/vinext/src/server/app-page-response.js"; import { buildAppPageElement as __buildAppPageElement, resolveAppPageIntercept as __resolveAppPageIntercept, @@ -8802,6 +9032,21 @@ function matchRoute(url) { return _trieMatch(_routeTrie, urlParts); } +function __createStaticFileSignal(pathname, _mwCtx) { + const headers = new Headers({ + "x-vinext-static-file": encodeURIComponent(pathname), + }); + if (_mwCtx.headers) { + for (const [key, value] of _mwCtx.headers) { + headers.append(key, value); + } + } + return new Response(null, { + status: _mwCtx.status ?? 200, + headers, + }); +} + // matchPattern is kept for findIntercept (linear scan over small interceptLookup array). function matchPattern(urlParts, patternParts) { const params = Object.create(null); @@ -9010,6 +9255,7 @@ const __i18nConfig = null; const __configRedirects = []; const __configRewrites = {"beforeFiles":[],"afterFiles":[],"fallback":[]}; const __configHeaders = []; +const __publicFiles = new Set([]); const __allowedOrigins = []; @@ -9548,6 +9794,18 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } } + // Serve public/ files as filesystem routes after middleware and before + // afterFiles/fallback rewrites, matching Next.js routing semantics. + if ( + (request.method === "GET" || request.method === "HEAD") && + !pathname.endsWith(".rsc") && + __publicFiles.has(cleanPathname) + ) { + setHeadersContext(null); + setNavigationContext(null); + return __createStaticFileSignal(cleanPathname, _mwCtx); + } + // Set navigation context for Server Components. // Note: Headers context is already set by runWithRequestContext in the handler wrapper. setNavigationContext({ @@ -9652,6 +9910,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { "x-action-redirect-type": actionRedirect.type, "x-action-redirect-status": String(actionRedirect.status), }); + __mergeMiddlewareResponseHeaders(redirectHeaders, _mwCtx.headers); for (const cookie of actionPendingCookies) { redirectHeaders.append("Set-Cookie", cookie); } @@ -9705,8 +9964,15 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const actionPendingCookies = getAndClearPendingCookies(); const actionDraftCookie = getDraftModeCookieHeader(); - const actionHeaders = { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }; - const actionResponse = new Response(rscStream, { headers: actionHeaders }); + const actionHeaders = new Headers({ + "Content-Type": "text/x-component; charset=utf-8", + "Vary": "RSC, Accept", + }); + __mergeMiddlewareResponseHeaders(actionHeaders, _mwCtx.headers); + const actionResponse = new Response(rscStream, { + status: _mwCtx.status ?? 200, + headers: actionHeaders, + }); if (actionPendingCookies.length > 0 || actionDraftCookie) { for (const cookie of actionPendingCookies) { actionResponse.headers.append("Set-Cookie", cookie); @@ -10096,8 +10362,14 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // by the client, and async server components that run during consumption need the // context to still be live. The AsyncLocalStorage scope from runWithRequestContext // handles cleanup naturally when all async continuations complete. + const interceptHeaders = new Headers({ + "Content-Type": "text/x-component; charset=utf-8", + "Vary": "RSC, Accept", + }); + __mergeMiddlewareResponseHeaders(interceptHeaders, _mwCtx.headers); return new Response(interceptStream, { - headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }, + status: _mwCtx.status ?? 200, + headers: interceptHeaders, }); }, searchParams: url.searchParams, @@ -10193,7 +10465,18 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { return LayoutComp({ params: _asyncLayoutParams, children: null }); }, probePage() { - return PageComponent({ params }); + const _probeSearchObj = {}; + url.searchParams.forEach(function(v, k) { + if (k in _probeSearchObj) { + _probeSearchObj[k] = Array.isArray(_probeSearchObj[k]) + ? _probeSearchObj[k].concat(v) + : [_probeSearchObj[k], v]; + } else { + _probeSearchObj[k] = v; + } + }); + const _asyncSearchParams = makeThenableParams(_probeSearchObj); + return PageComponent({ params: _asyncLayoutParams, searchParams: _asyncSearchParams }); }, revalidateSeconds, renderErrorBoundaryResponse(renderErr) { @@ -10348,6 +10631,9 @@ import { import { renderAppPageLifecycle as __renderAppPageLifecycle, } from "/packages/vinext/src/server/app-page-render.js"; +import { + mergeMiddlewareResponseHeaders as __mergeMiddlewareResponseHeaders, +} from "/packages/vinext/src/server/app-page-response.js"; import { buildAppPageElement as __buildAppPageElement, resolveAppPageIntercept as __resolveAppPageIntercept, @@ -10846,6 +11132,21 @@ function matchRoute(url) { return _trieMatch(_routeTrie, urlParts); } +function __createStaticFileSignal(pathname, _mwCtx) { + const headers = new Headers({ + "x-vinext-static-file": encodeURIComponent(pathname), + }); + if (_mwCtx.headers) { + for (const [key, value] of _mwCtx.headers) { + headers.append(key, value); + } + } + return new Response(null, { + status: _mwCtx.status ?? 200, + headers, + }); +} + // matchPattern is kept for findIntercept (linear scan over small interceptLookup array). function matchPattern(urlParts, patternParts) { const params = Object.create(null); @@ -11283,6 +11584,7 @@ const __i18nConfig = null; const __configRedirects = []; const __configRewrites = {"beforeFiles":[],"afterFiles":[],"fallback":[]}; const __configHeaders = []; +const __publicFiles = new Set([]); const __allowedOrigins = []; @@ -11956,6 +12258,18 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } } + // Serve public/ files as filesystem routes after middleware and before + // afterFiles/fallback rewrites, matching Next.js routing semantics. + if ( + (request.method === "GET" || request.method === "HEAD") && + !pathname.endsWith(".rsc") && + __publicFiles.has(cleanPathname) + ) { + setHeadersContext(null); + setNavigationContext(null); + return __createStaticFileSignal(cleanPathname, _mwCtx); + } + // Set navigation context for Server Components. // Note: Headers context is already set by runWithRequestContext in the handler wrapper. setNavigationContext({ @@ -12060,6 +12374,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { "x-action-redirect-type": actionRedirect.type, "x-action-redirect-status": String(actionRedirect.status), }); + __mergeMiddlewareResponseHeaders(redirectHeaders, _mwCtx.headers); for (const cookie of actionPendingCookies) { redirectHeaders.append("Set-Cookie", cookie); } @@ -12113,8 +12428,15 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const actionPendingCookies = getAndClearPendingCookies(); const actionDraftCookie = getDraftModeCookieHeader(); - const actionHeaders = { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }; - const actionResponse = new Response(rscStream, { headers: actionHeaders }); + const actionHeaders = new Headers({ + "Content-Type": "text/x-component; charset=utf-8", + "Vary": "RSC, Accept", + }); + __mergeMiddlewareResponseHeaders(actionHeaders, _mwCtx.headers); + const actionResponse = new Response(rscStream, { + status: _mwCtx.status ?? 200, + headers: actionHeaders, + }); if (actionPendingCookies.length > 0 || actionDraftCookie) { for (const cookie of actionPendingCookies) { actionResponse.headers.append("Set-Cookie", cookie); @@ -12504,8 +12826,14 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // by the client, and async server components that run during consumption need the // context to still be live. The AsyncLocalStorage scope from runWithRequestContext // handles cleanup naturally when all async continuations complete. + const interceptHeaders = new Headers({ + "Content-Type": "text/x-component; charset=utf-8", + "Vary": "RSC, Accept", + }); + __mergeMiddlewareResponseHeaders(interceptHeaders, _mwCtx.headers); return new Response(interceptStream, { - headers: { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }, + status: _mwCtx.status ?? 200, + headers: interceptHeaders, }); }, searchParams: url.searchParams, @@ -12601,7 +12929,18 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { return LayoutComp({ params: _asyncLayoutParams, children: null }); }, probePage() { - return PageComponent({ params }); + const _probeSearchObj = {}; + url.searchParams.forEach(function(v, k) { + if (k in _probeSearchObj) { + _probeSearchObj[k] = Array.isArray(_probeSearchObj[k]) + ? _probeSearchObj[k].concat(v) + : [_probeSearchObj[k], v]; + } else { + _probeSearchObj[k] = v; + } + }); + const _asyncSearchParams = makeThenableParams(_probeSearchObj); + return PageComponent({ params: _asyncLayoutParams, searchParams: _asyncSearchParams }); }, revalidateSeconds, renderErrorBoundaryResponse(renderErr) { From f4949b4069d63665690a31d31d12b65e43617709 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sun, 5 Apr 2026 00:19:43 +1100 Subject: [PATCH 22/25] fix: address app router review regressions --- packages/vinext/src/entries/app-rsc-entry.ts | 6 +- .../vinext/src/server/app-browser-entry.ts | 107 ++++++++++++------ .../vinext/src/server/app-browser-state.ts | 53 ++++----- packages/vinext/src/server/app-elements.ts | 2 +- .../src/server/app-render-dependency.tsx | 4 + .../entry-templates.test.ts.snap | 36 +++--- tests/app-browser-entry.test.ts | 85 +++++++++----- tests/app-elements.test.ts | 2 +- tests/app-router.test.ts | 7 ++ 9 files changed, 183 insertions(+), 119 deletions(-) diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index cbdc830f7..e5c3c1539 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -1729,11 +1729,11 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const redirectHeaders = new Headers({ "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept", - "x-action-redirect": actionRedirect.url, - "x-action-redirect-type": actionRedirect.type, - "x-action-redirect-status": String(actionRedirect.status), }); __mergeMiddlewareResponseHeaders(redirectHeaders, _mwCtx.headers); + redirectHeaders.set("x-action-redirect", actionRedirect.url); + redirectHeaders.set("x-action-redirect-type", actionRedirect.type); + redirectHeaders.set("x-action-redirect-status", String(actionRedirect.status)); for (const cookie of actionPendingCookies) { redirectHeaders.append("Set-Cookie", cookie); } diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index fe5c02003..8c75b6b14 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -54,8 +54,8 @@ import { } from "./app-elements.js"; import { createPendingNavigationCommit, + resolvePendingNavigationCommitDisposition, routerReducer, - shouldHardNavigate, type AppRouterAction, type AppRouterState, } from "./app-browser-state.js"; @@ -330,19 +330,24 @@ 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 - // window between React committing a new state and the effect firing. + // server actions, HMR) always read the current state. 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. + // Publish the stable ref object and dispatch during layout commit. This keeps + // the module-level escape hatches aligned with React's committed tree without + // performing module writes during render. useLayoutEffect(() => { dispatchBrowserRouterAction = dispatchTreeState; + browserRouterStateRef = stateRef; + return () => { + if (dispatchBrowserRouterAction === dispatchTreeState) { + dispatchBrowserRouterAction = null; + } + if (browserRouterStateRef === stateRef) { + browserRouterStateRef = null; + } + }; }, [dispatchTreeState]); const committedTree = createElement( @@ -420,17 +425,21 @@ 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 disposition = resolvePendingNavigationCommitDisposition({ + activeNavigationId, + currentRootLayoutTreePath: currentState.rootLayoutTreePath, + nextRootLayoutTreePath: pending.rootLayoutTreePath, + startedNavigationId: navId, + }); + + if (disposition === "skip") { const resolve = pendingNavigationCommits.get(renderId); pendingNavigationCommits.delete(renderId); resolve?.(); return; } - if (shouldHardNavigate(currentState.rootLayoutTreePath, pending.rootLayoutTreePath)) { + if (disposition === "hard-navigate") { pendingNavigationCommits.delete(renderId); window.location.assign(targetHref); return; @@ -608,22 +617,36 @@ function registerServerActionCallback(): void { window.location.href, latestClientParams, ); + const currentState = getBrowserRouterState(); + const startedNavigationId = activeNavigationId; const pending = await createPendingNavigationCommit({ - currentState: getBrowserRouterState(), + currentState, 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, - ); + const disposition = resolvePendingNavigationCommitDisposition({ + activeNavigationId, + currentRootLayoutTreePath: currentState.rootLayoutTreePath, + nextRootLayoutTreePath: pending.rootLayoutTreePath, + startedNavigationId, + }); + if (disposition === "hard-navigate") { + window.location.assign(window.location.href); + return undefined; + } + if (disposition === "dispatch") { + dispatchBrowserTree( + pending.action.elements, + navigationSnapshot, + pending.action.renderId, + "navigate", + pending.routeId, + pending.rootLayoutTreePath, + false, + ); + } if (result.returnValue) { if (!result.returnValue.ok) throw result.returnValue.data; return result.returnValue.data; @@ -635,23 +658,37 @@ function registerServerActionCallback(): void { window.location.href, latestClientParams, ); + const currentState = getBrowserRouterState(); + const startedNavigationId = activeNavigationId; const pending = await createPendingNavigationCommit({ - currentState: getBrowserRouterState(), + currentState, 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; + const disposition = resolvePendingNavigationCommitDisposition({ + activeNavigationId, + currentRootLayoutTreePath: currentState.rootLayoutTreePath, + nextRootLayoutTreePath: pending.rootLayoutTreePath, + startedNavigationId, + }); + if (disposition === "hard-navigate") { + window.location.assign(window.location.href); + return undefined; + } + if (disposition === "dispatch") { + dispatchBrowserTree( + pending.action.elements, + navigationSnapshot, + pending.action.renderId, + "navigate", + pending.routeId, + pending.rootLayoutTreePath, + false, + ); + } + return undefined; }); } diff --git a/packages/vinext/src/server/app-browser-state.ts b/packages/vinext/src/server/app-browser-state.ts index f439b798c..953dd24b7 100644 --- a/packages/vinext/src/server/app-browser-state.ts +++ b/packages/vinext/src/server/app-browser-state.ts @@ -25,6 +25,8 @@ export type PendingNavigationCommit = { routeId: string; }; +export type PendingNavigationCommitDisposition = "dispatch" | "hard-navigate" | "skip"; + export function routerReducer(state: AppRouterState, action: AppRouterAction): AppRouterState { switch (action.type) { case "navigate": @@ -54,6 +56,9 @@ export function shouldHardNavigate( currentRootLayoutTreePath: string | null, nextRootLayoutTreePath: string | null, ): boolean { + // `null` means the payload could not identify an enclosing root layout + // boundary. Treat that as soft-navigation compatible so fallback payloads + // do not force a hard reload purely because metadata is absent. return ( currentRootLayoutTreePath !== null && nextRootLayoutTreePath !== null && @@ -61,6 +66,23 @@ export function shouldHardNavigate( ); } +export function resolvePendingNavigationCommitDisposition(options: { + activeNavigationId: number; + currentRootLayoutTreePath: string | null; + nextRootLayoutTreePath: string | null; + startedNavigationId: number; +}): PendingNavigationCommitDisposition { + if (options.startedNavigationId !== options.activeNavigationId) { + return "skip"; + } + + if (shouldHardNavigate(options.currentRootLayoutTreePath, options.nextRootLayoutTreePath)) { + return "hard-navigate"; + } + + return "dispatch"; +} + export async function createPendingNavigationCommit(options: { currentState: AppRouterState; nextElements: Promise; @@ -84,34 +106,3 @@ export async function createPendingNavigationCommit(options: { routeId: metadata.routeId, }; } - -export async function applyAppRouterStateUpdate(options: { - commit: () => void; - currentState: AppRouterState; - dispatch: (action: AppRouterAction) => void; - nextElements: Promise; - navigationSnapshot?: ClientNavigationRenderSnapshot; - onHardNavigate: (href: string) => void; - targetHref: string; - transition: (callback: () => void) => void; - type?: "navigate" | "replace"; -}): Promise<{ type: "dispatched" | "hard-navigate" }> { - const pending = await createPendingNavigationCommit({ - currentState: options.currentState, - nextElements: options.nextElements, - navigationSnapshot: options.navigationSnapshot ?? options.currentState.navigationSnapshot, - type: options.type ?? "navigate", - }); - - if (shouldHardNavigate(options.currentState.rootLayoutTreePath, pending.rootLayoutTreePath)) { - options.onHardNavigate(options.targetHref); - return { type: "hard-navigate" }; - } - - options.transition(() => { - options.commit(); - options.dispatch(pending.action); - }); - - return { type: "dispatched" }; -} diff --git a/packages/vinext/src/server/app-elements.ts b/packages/vinext/src/server/app-elements.ts index f1e4a8930..0ec994fe3 100644 --- a/packages/vinext/src/server/app-elements.ts +++ b/packages/vinext/src/server/app-elements.ts @@ -47,7 +47,7 @@ export function readAppElementsMetadata(elements: AppElements): AppElementsMetad const rootLayoutTreePath = elements[APP_ROOT_LAYOUT_KEY]; if (rootLayoutTreePath !== null && typeof rootLayoutTreePath !== "string") { - throw new Error("[vinext] Invalid __rootLayout in App Router payload"); + throw new Error("[vinext] Missing or invalid __rootLayout in App Router payload"); } return { diff --git a/packages/vinext/src/server/app-render-dependency.tsx b/packages/vinext/src/server/app-render-dependency.tsx index 09182280b..45f82c15a 100644 --- a/packages/vinext/src/server/app-render-dependency.tsx +++ b/packages/vinext/src/server/app-render-dependency.tsx @@ -46,6 +46,10 @@ export function renderWithAppDependencyBarrier( dependency: AppRenderDependency, ): ReactNode { function ReleaseAppRenderDependency() { + // This render-time release is intentional. The dependency barrier is only + // used inside the RSC render graph, where producing this leaf means the + // owning entry has reached the serialization point that downstream entries + // are allowed to observe. dependency.release(); return null; } diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index 9b321f849..aa59625d3 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -1459,11 +1459,11 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const redirectHeaders = new Headers({ "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept", - "x-action-redirect": actionRedirect.url, - "x-action-redirect-type": actionRedirect.type, - "x-action-redirect-status": String(actionRedirect.status), }); __mergeMiddlewareResponseHeaders(redirectHeaders, _mwCtx.headers); + redirectHeaders.set("x-action-redirect", actionRedirect.url); + redirectHeaders.set("x-action-redirect-type", actionRedirect.type); + redirectHeaders.set("x-action-redirect-status", String(actionRedirect.status)); for (const cookie of actionPendingCookies) { redirectHeaders.append("Set-Cookie", cookie); } @@ -3565,11 +3565,11 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const redirectHeaders = new Headers({ "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept", - "x-action-redirect": actionRedirect.url, - "x-action-redirect-type": actionRedirect.type, - "x-action-redirect-status": String(actionRedirect.status), }); __mergeMiddlewareResponseHeaders(redirectHeaders, _mwCtx.headers); + redirectHeaders.set("x-action-redirect", actionRedirect.url); + redirectHeaders.set("x-action-redirect-type", actionRedirect.type); + redirectHeaders.set("x-action-redirect-status", String(actionRedirect.status)); for (const cookie of actionPendingCookies) { redirectHeaders.append("Set-Cookie", cookie); } @@ -5666,11 +5666,11 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const redirectHeaders = new Headers({ "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept", - "x-action-redirect": actionRedirect.url, - "x-action-redirect-type": actionRedirect.type, - "x-action-redirect-status": String(actionRedirect.status), }); __mergeMiddlewareResponseHeaders(redirectHeaders, _mwCtx.headers); + redirectHeaders.set("x-action-redirect", actionRedirect.url); + redirectHeaders.set("x-action-redirect-type", actionRedirect.type); + redirectHeaders.set("x-action-redirect-status", String(actionRedirect.status)); for (const cookie of actionPendingCookies) { redirectHeaders.append("Set-Cookie", cookie); } @@ -7799,11 +7799,11 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const redirectHeaders = new Headers({ "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept", - "x-action-redirect": actionRedirect.url, - "x-action-redirect-type": actionRedirect.type, - "x-action-redirect-status": String(actionRedirect.status), }); __mergeMiddlewareResponseHeaders(redirectHeaders, _mwCtx.headers); + redirectHeaders.set("x-action-redirect", actionRedirect.url); + redirectHeaders.set("x-action-redirect-type", actionRedirect.type); + redirectHeaders.set("x-action-redirect-status", String(actionRedirect.status)); for (const cookie of actionPendingCookies) { redirectHeaders.append("Set-Cookie", cookie); } @@ -9906,11 +9906,11 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const redirectHeaders = new Headers({ "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept", - "x-action-redirect": actionRedirect.url, - "x-action-redirect-type": actionRedirect.type, - "x-action-redirect-status": String(actionRedirect.status), }); __mergeMiddlewareResponseHeaders(redirectHeaders, _mwCtx.headers); + redirectHeaders.set("x-action-redirect", actionRedirect.url); + redirectHeaders.set("x-action-redirect-type", actionRedirect.type); + redirectHeaders.set("x-action-redirect-status", String(actionRedirect.status)); for (const cookie of actionPendingCookies) { redirectHeaders.append("Set-Cookie", cookie); } @@ -12370,11 +12370,11 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const redirectHeaders = new Headers({ "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept", - "x-action-redirect": actionRedirect.url, - "x-action-redirect-type": actionRedirect.type, - "x-action-redirect-status": String(actionRedirect.status), }); __mergeMiddlewareResponseHeaders(redirectHeaders, _mwCtx.headers); + redirectHeaders.set("x-action-redirect", actionRedirect.url); + redirectHeaders.set("x-action-redirect-type", actionRedirect.type); + redirectHeaders.set("x-action-redirect-status", String(actionRedirect.status)); for (const cookie of actionPendingCookies) { redirectHeaders.append("Set-Cookie", cookie); } diff --git a/tests/app-browser-entry.test.ts b/tests/app-browser-entry.test.ts index 813b55b11..b038d8afd 100644 --- a/tests/app-browser-entry.test.ts +++ b/tests/app-browser-entry.test.ts @@ -1,5 +1,5 @@ import React from "react"; -import { describe, expect, it, vi } from "vite-plus/test"; +import { describe, expect, it } from "vite-plus/test"; import { APP_ROOT_LAYOUT_KEY, APP_ROUTE_KEY, @@ -8,9 +8,10 @@ import { } from "../packages/vinext/src/server/app-elements.js"; import { createClientNavigationRenderSnapshot } from "../packages/vinext/src/shims/navigation.js"; import { - applyAppRouterStateUpdate, createPendingNavigationCommit, routerReducer, + resolvePendingNavigationCommitDisposition, + shouldHardNavigate, type AppRouterState, } from "../packages/vinext/src/server/app-browser-state.js"; @@ -89,44 +90,43 @@ describe("app browser entry state helpers", () => { }); 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(), + const currentState = createState({ + rootLayoutTreePath: "/(marketing)", + }); + const pending = await createPendingNavigationCommit({ + currentState, nextElements: Promise.resolve(createResolvedElements("route:/dashboard", "/(dashboard)")), - onHardNavigate: assign, - targetHref: "/dashboard", - transition: (callback) => callback(), + navigationSnapshot: currentState.navigationSnapshot, + type: "navigate", }); - expect(result).toEqual({ type: "hard-navigate" }); - expect(assign).toHaveBeenCalledWith("/dashboard"); + expect( + resolvePendingNavigationCommitDisposition({ + activeNavigationId: 3, + currentRootLayoutTreePath: currentState.rootLayoutTreePath, + nextRootLayoutTreePath: pending.rootLayoutTreePath, + startedNavigationId: 3, + }), + ).toBe("hard-navigate"); }); - it("defers commit side effects until the payload has resolved and dispatched", async () => { + it("defers commit classification until the payload has resolved", 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, + let resolved = false; + const pending = createPendingNavigationCommit({ currentState: createState(), - dispatch, nextElements, - onHardNavigate: vi.fn(), - targetHref: "/dashboard", - transition: (callback) => callback(), + navigationSnapshot: createState().navigationSnapshot, + type: "navigate", + }).then((result) => { + resolved = true; + return result; }); - expect(dispatch).not.toHaveBeenCalled(); - expect(commit).not.toHaveBeenCalled(); + expect(resolved).toBe(false); if (!resolveElements) { throw new Error("Expected deferred elements resolver"); @@ -140,10 +140,29 @@ describe("app browser entry state helpers", () => { }), ); - await pending; + const result = await pending; - expect(dispatch).toHaveBeenCalledOnce(); - expect(commit).toHaveBeenCalledOnce(); + expect(resolved).toBe(true); + expect(result.routeId).toBe("route:/dashboard"); + }); + + it("skips a pending commit when a newer navigation has become active", async () => { + const currentState = createState(); + const pending = await createPendingNavigationCommit({ + currentState, + nextElements: Promise.resolve(createResolvedElements("route:/dashboard", "/")), + navigationSnapshot: currentState.navigationSnapshot, + type: "navigate", + }); + + expect( + resolvePendingNavigationCommitDisposition({ + activeNavigationId: 5, + currentRootLayoutTreePath: currentState.rootLayoutTreePath, + nextRootLayoutTreePath: pending.rootLayoutTreePath, + startedNavigationId: 4, + }), + ).toBe("skip"); }); it("builds a merge commit for refresh and server-action payloads", async () => { @@ -158,4 +177,10 @@ describe("app browser entry state helpers", () => { expect(refreshCommit.routeId).toBe("route:/dashboard"); expect(refreshCommit.rootLayoutTreePath).toBe("/"); }); + + it("treats null root-layout identities as soft-navigation compatible", () => { + expect(shouldHardNavigate(null, null)).toBe(false); + expect(shouldHardNavigate(null, "/")).toBe(false); + expect(shouldHardNavigate("/", null)).toBe(false); + }); }); diff --git a/tests/app-elements.test.ts b/tests/app-elements.test.ts index ceb0d5fe2..5e2c168ae 100644 --- a/tests/app-elements.test.ts +++ b/tests/app-elements.test.ts @@ -63,6 +63,6 @@ describe("app elements payload helpers", () => { [APP_ROUTE_KEY]: "route:/dashboard", }), ), - ).toThrow("[vinext] Invalid __rootLayout in App Router payload"); + ).toThrow("[vinext] Missing or invalid __rootLayout in App Router payload"); }); }); diff --git a/tests/app-router.test.ts b/tests/app-router.test.ts index 1bb3fc975..9c891f656 100644 --- a/tests/app-router.test.ts +++ b/tests/app-router.test.ts @@ -4082,5 +4082,12 @@ describe("generateRscEntry ISR code generation", () => { const redirectEnd = code.indexOf('return new Response(""', redirectStart); const redirectBody = code.slice(redirectStart, redirectEnd); expect(redirectBody).toContain("mergeMiddlewareResponseHeaders"); + // Framework-owned redirect headers must be written after the middleware merge + // so middleware cannot clobber the target URL or redirect type. + const mergeIndex = redirectBody.indexOf("__mergeMiddlewareResponseHeaders"); + const redirectHeaderIndex = redirectBody.indexOf('redirectHeaders.set("x-action-redirect"'); + expect(mergeIndex).toBeGreaterThan(-1); + expect(redirectHeaderIndex).toBeGreaterThan(-1); + expect(mergeIndex).toBeLessThan(redirectHeaderIndex); }); }); From a220f3d2d4ace9db83d54e2cfb8a6e6de28b733e Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sun, 5 Apr 2026 23:04:39 +1000 Subject: [PATCH 23/25] Fix app-page-request intercept tests --- tests/app-page-request.test.ts | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/tests/app-page-request.test.ts b/tests/app-page-request.test.ts index 91d7b0ae1..1c714372b 100644 --- a/tests/app-page-request.test.ts +++ b/tests/app-page-request.test.ts @@ -64,8 +64,8 @@ describe("app page request helpers", () => { const setNavigationContext = vi.fn(); const buildPageElementMock = vi.fn(async () => ({ type: "intercept-element" })); const renderInterceptResponse = vi.fn(async () => new Response("intercepted")); - const currentRoute = { pattern: "/photos/[id]" }; - const sourceRoute = { pattern: "/feed" }; + const currentRoute = { params: ["id"], pattern: "/photos/[id]" }; + const sourceRoute = { params: [], pattern: "/feed" }; const result = await resolveAppPageIntercept({ buildPageElement: buildPageElementMock, @@ -79,16 +79,13 @@ describe("app page request helpers", () => { sourceRouteIndex: 0, }; }, - getRoutePattern(route) { - return route.pattern; + getRouteParamNames(route) { + return route.params; }, getSourceRoute() { return sourceRoute; }, isRscRequest: true, - matchSourceRouteParams() { - return {}; - }, renderInterceptResponse, searchParams: new URLSearchParams("from=feed"), setNavigationContext, @@ -122,7 +119,7 @@ describe("app page request helpers", () => { }); it("returns intercept opts when the source route is the current route", async () => { - const currentRoute = { pattern: "/photos/[id]" }; + const currentRoute = { params: ["id"], pattern: "/photos/[id]" }; const result = await resolveAppPageIntercept({ async buildPageElement() { @@ -138,16 +135,13 @@ describe("app page request helpers", () => { sourceRouteIndex: 0, }; }, - getRoutePattern(route) { - return route.pattern; + getRouteParamNames(route) { + return route.params; }, getSourceRoute() { return currentRoute; }, isRscRequest: true, - matchSourceRouteParams() { - return null; - }, async renderInterceptResponse() { throw new Error("should not render a separate intercept response"); }, From 9959097a317eaf5f09cb0d8605b12fed3d929c61 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Mon, 6 Apr 2026 13:47:27 +1000 Subject: [PATCH 24/25] Address PR 2c review follow-ups --- packages/vinext/src/server/app-browser-entry.ts | 9 ++++++++- packages/vinext/src/server/app-elements.ts | 5 ++++- tests/app-elements.test.ts | 12 +++++++++++- tests/app-page-route-wiring.test.ts | 14 ++------------ 4 files changed, 25 insertions(+), 15 deletions(-) diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index 8c75b6b14..40873b3a4 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -336,7 +336,9 @@ function BrowserRoot({ // Publish the stable ref object and dispatch during layout commit. This keeps // the module-level escape hatches aligned with React's committed tree without - // performing module writes during render. + // performing module writes during render. __VINEXT_RSC_NAVIGATE__ is assigned + // after hydrateRoot() returns; by then this layout effect has already run for + // the hydration commit, so getBrowserRouterState() never observes a null ref. useLayoutEffect(() => { dispatchBrowserRouterAction = dispatchTreeState; browserRouterStateRef = stateRef; @@ -632,6 +634,11 @@ function registerServerActionCallback(): void { nextRootLayoutTreePath: pending.rootLayoutTreePath, startedNavigationId, }); + // Known limitation: if a same-URL navigation fully commits while this + // server action is awaiting createPendingNavigationCommit(), the action + // can still dispatch its older payload afterward. The old pre-2c code had + // the same race, and Next.js has similar behavior. Tightening this would + // need a stronger commit-version gate than activeNavigationId alone. if (disposition === "hard-navigate") { window.location.assign(window.location.href); return undefined; diff --git a/packages/vinext/src/server/app-elements.ts b/packages/vinext/src/server/app-elements.ts index 0ec994fe3..250b2eaee 100644 --- a/packages/vinext/src/server/app-elements.ts +++ b/packages/vinext/src/server/app-elements.ts @@ -46,8 +46,11 @@ export function readAppElementsMetadata(elements: AppElements): AppElementsMetad } const rootLayoutTreePath = elements[APP_ROOT_LAYOUT_KEY]; + if (rootLayoutTreePath === undefined) { + throw new Error("[vinext] Missing __rootLayout key in App Router payload"); + } if (rootLayoutTreePath !== null && typeof rootLayoutTreePath !== "string") { - throw new Error("[vinext] Missing or invalid __rootLayout in App Router payload"); + throw new Error("[vinext] Invalid __rootLayout in App Router payload: expected string or null"); } return { diff --git a/tests/app-elements.test.ts b/tests/app-elements.test.ts index 5e2c168ae..c1fef4fc3 100644 --- a/tests/app-elements.test.ts +++ b/tests/app-elements.test.ts @@ -63,6 +63,16 @@ describe("app elements payload helpers", () => { [APP_ROUTE_KEY]: "route:/dashboard", }), ), - ).toThrow("[vinext] Missing or invalid __rootLayout in App Router payload"); + ).toThrow("[vinext] Invalid __rootLayout in App Router payload: expected string or null"); + }); + + it("rejects payloads with a missing __rootLayout key", () => { + expect(() => + readAppElementsMetadata( + normalizeAppElements({ + [APP_ROUTE_KEY]: "route:/dashboard", + }), + ), + ).toThrow("[vinext] Missing __rootLayout key in App Router payload"); }); }); diff --git a/tests/app-page-route-wiring.test.ts b/tests/app-page-route-wiring.test.ts index e86077653..e81c67817 100644 --- a/tests/app-page-route-wiring.test.ts +++ b/tests/app-page-route-wiring.test.ts @@ -256,20 +256,10 @@ describe("app page route wiring helpers", () => { rootNotFoundModule: null, }); - const body = await withTimeout( - renderHtml( - createElement( - Fragment, - null, - readChildren(elements["layout:/"]), - readChildren(elements["page:/layout-only"]), - ), - ), - 1_000, - ); + const body = await withTimeout(renderRouteEntry(elements, "route:/layout-only"), 1_000); expect(body).toContain("Layout only"); - expect(body).toContain("Page content"); + expect(body).not.toContain("Page content"); }); it("preserves route subtree when a layout entry has no default export", async () => { From 7c626289e63e6a36fb592f9cfd4fdceace1ae73a Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Tue, 7 Apr 2026 01:37:25 +1000 Subject: [PATCH 25/25] Refactor same-url payload commits --- .../vinext/src/server/app-browser-entry.ts | 138 ++++++++---------- .../vinext/src/server/app-browser-state.ts | 32 ++++ tests/app-browser-entry.test.ts | 21 +++ 3 files changed, 112 insertions(+), 79 deletions(-) diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index 40873b3a4..23fb2586a 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -53,6 +53,7 @@ import { type AppWireElements, } from "./app-elements.js"; import { + createPendingSameUrlCommit, createPendingNavigationCommit, resolvePendingNavigationCommitDisposition, routerReducer, @@ -312,6 +313,58 @@ function normalizeAppElementsPromise(payload: Promise): Promise return Promise.resolve(payload).then((elements) => normalizeAppElements(elements)); } +async function commitSameUrlPayload( + nextElements: Promise, + returnValue?: ServerActionResult["returnValue"], +): Promise { + const navigationSnapshot = createClientNavigationRenderSnapshot( + window.location.href, + latestClientParams, + ); + const currentState = getBrowserRouterState(); + const startedNavigationId = activeNavigationId; + const { disposition, pending } = await createPendingSameUrlCommit({ + activeNavigationId, + currentState, + navigationSnapshot, + nextElements, + renderId: ++nextNavigationRenderId, + startedNavigationId, + type: "navigate", + }); + + // Known limitation: if a same-URL navigation fully commits while this + // server action is awaiting createPendingNavigationCommit(), the action + // can still dispatch its older payload afterward. The old pre-2c code had + // the same race, and Next.js has similar behavior. Tightening this would + // need a stronger commit-version gate than activeNavigationId alone. + if (disposition === "hard-navigate") { + window.location.assign(window.location.href); + return undefined; + } + + if (disposition === "dispatch") { + dispatchBrowserTree( + pending.action.elements, + navigationSnapshot, + pending.action.renderId, + "navigate", + pending.routeId, + pending.rootLayoutTreePath, + false, + ); + } + + if (returnValue) { + if (!returnValue.ok) { + throw returnValue.data; + } + return returnValue.data; + } + + return undefined; +} + function BrowserRoot({ initialElements, initialNavigationSnapshot, @@ -330,7 +383,8 @@ function BrowserRoot({ }); // Keep the latest router state in a ref so external callers (navigate(), - // server actions, HMR) always read the current state. + // server actions, HMR) always read the current state. Safe: those readers + // run from events/effects, never from React render itself. const stateRef = useRef(treeState); stateRef.current = treeState; @@ -615,87 +669,13 @@ 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)) { - const navigationSnapshot = createClientNavigationRenderSnapshot( - window.location.href, - latestClientParams, + return commitSameUrlPayload( + Promise.resolve(normalizeAppElements(result.root)), + result.returnValue, ); - const currentState = getBrowserRouterState(); - const startedNavigationId = activeNavigationId; - const pending = await createPendingNavigationCommit({ - currentState, - nextElements: Promise.resolve(normalizeAppElements(result.root)), - navigationSnapshot, - renderId: ++nextNavigationRenderId, - type: "navigate", - }); - const disposition = resolvePendingNavigationCommitDisposition({ - activeNavigationId, - currentRootLayoutTreePath: currentState.rootLayoutTreePath, - nextRootLayoutTreePath: pending.rootLayoutTreePath, - startedNavigationId, - }); - // Known limitation: if a same-URL navigation fully commits while this - // server action is awaiting createPendingNavigationCommit(), the action - // can still dispatch its older payload afterward. The old pre-2c code had - // the same race, and Next.js has similar behavior. Tightening this would - // need a stronger commit-version gate than activeNavigationId alone. - if (disposition === "hard-navigate") { - window.location.assign(window.location.href); - return undefined; - } - if (disposition === "dispatch") { - dispatchBrowserTree( - pending.action.elements, - navigationSnapshot, - pending.action.renderId, - "navigate", - pending.routeId, - pending.rootLayoutTreePath, - false, - ); - } - if (result.returnValue) { - if (!result.returnValue.ok) throw result.returnValue.data; - return result.returnValue.data; - } - return undefined; } - const navigationSnapshot = createClientNavigationRenderSnapshot( - window.location.href, - latestClientParams, - ); - const currentState = getBrowserRouterState(); - const startedNavigationId = activeNavigationId; - const pending = await createPendingNavigationCommit({ - currentState, - nextElements: Promise.resolve(normalizeAppElements(result)), - navigationSnapshot, - renderId: ++nextNavigationRenderId, - type: "navigate", - }); - const disposition = resolvePendingNavigationCommitDisposition({ - activeNavigationId, - currentRootLayoutTreePath: currentState.rootLayoutTreePath, - nextRootLayoutTreePath: pending.rootLayoutTreePath, - startedNavigationId, - }); - if (disposition === "hard-navigate") { - window.location.assign(window.location.href); - return undefined; - } - if (disposition === "dispatch") { - dispatchBrowserTree( - pending.action.elements, - navigationSnapshot, - pending.action.renderId, - "navigate", - pending.routeId, - pending.rootLayoutTreePath, - false, - ); - } - return undefined; + return commitSameUrlPayload(Promise.resolve(normalizeAppElements(result))); }); } diff --git a/packages/vinext/src/server/app-browser-state.ts b/packages/vinext/src/server/app-browser-state.ts index 953dd24b7..92e98f4ff 100644 --- a/packages/vinext/src/server/app-browser-state.ts +++ b/packages/vinext/src/server/app-browser-state.ts @@ -26,6 +26,10 @@ export type PendingNavigationCommit = { }; export type PendingNavigationCommitDisposition = "dispatch" | "hard-navigate" | "skip"; +export type PendingSameUrlCommit = { + disposition: PendingNavigationCommitDisposition; + pending: PendingNavigationCommit; +}; export function routerReducer(state: AppRouterState, action: AppRouterAction): AppRouterState { switch (action.type) { @@ -106,3 +110,31 @@ export async function createPendingNavigationCommit(options: { routeId: metadata.routeId, }; } + +export async function createPendingSameUrlCommit(options: { + activeNavigationId: number; + currentState: AppRouterState; + navigationSnapshot: ClientNavigationRenderSnapshot; + nextElements: Promise; + renderId?: number; + startedNavigationId: number; + type: "navigate" | "replace"; +}): Promise { + const pending = await createPendingNavigationCommit({ + currentState: options.currentState, + nextElements: options.nextElements, + navigationSnapshot: options.navigationSnapshot, + renderId: options.renderId, + type: options.type, + }); + + return { + disposition: resolvePendingNavigationCommitDisposition({ + activeNavigationId: options.activeNavigationId, + currentRootLayoutTreePath: options.currentState.rootLayoutTreePath, + nextRootLayoutTreePath: pending.rootLayoutTreePath, + startedNavigationId: options.startedNavigationId, + }), + pending, + }; +} diff --git a/tests/app-browser-entry.test.ts b/tests/app-browser-entry.test.ts index b038d8afd..f5bc6a12e 100644 --- a/tests/app-browser-entry.test.ts +++ b/tests/app-browser-entry.test.ts @@ -8,6 +8,7 @@ import { } from "../packages/vinext/src/server/app-elements.js"; import { createClientNavigationRenderSnapshot } from "../packages/vinext/src/shims/navigation.js"; import { + createPendingSameUrlCommit, createPendingNavigationCommit, routerReducer, resolvePendingNavigationCommitDisposition, @@ -178,6 +179,26 @@ describe("app browser entry state helpers", () => { expect(refreshCommit.rootLayoutTreePath).toBe("/"); }); + it("classifies same-url payload commits in one step", async () => { + const currentState = createState({ + rootLayoutTreePath: "/(marketing)", + }); + + const result = await createPendingSameUrlCommit({ + activeNavigationId: 7, + currentState, + navigationSnapshot: currentState.navigationSnapshot, + nextElements: Promise.resolve(createResolvedElements("route:/dashboard", "/(dashboard)")), + renderId: 3, + startedNavigationId: 7, + type: "navigate", + }); + + expect(result.disposition).toBe("hard-navigate"); + expect(result.pending.routeId).toBe("route:/dashboard"); + expect(result.pending.action.renderId).toBe(3); + }); + it("treats null root-layout identities as soft-navigation compatible", () => { expect(shouldHardNavigate(null, null)).toBe(false); expect(shouldHardNavigate(null, "/")).toBe(false);