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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] Fix template-only route wrappers --- .../src/server/app-page-route-wiring.tsx | 30 +++++++---- tests/app-page-route-wiring.test.ts | 53 +++++++++++++++++++ 2 files changed, 73 insertions(+), 10 deletions(-) diff --git a/packages/vinext/src/server/app-page-route-wiring.tsx b/packages/vinext/src/server/app-page-route-wiring.tsx index f662db3fd..1909780a6 100644 --- a/packages/vinext/src/server/app-page-route-wiring.tsx +++ b/packages/vinext/src/server/app-page-route-wiring.tsx @@ -291,7 +291,11 @@ export function buildAppPageElements< const pageId = `page:${options.routePath}`; const layoutEntries = createAppPageLayoutEntries(options.route); const templateEntries = createAppPageTemplateEntries(options.route); + const layoutEntriesByTreePosition = new Map>(); const templateEntriesByTreePosition = new Map>(); + for (const layoutEntry of layoutEntries) { + layoutEntriesByTreePosition.set(layoutEntry.treePosition, layoutEntry); + } for (const templateEntry of templateEntries) { templateEntriesByTreePosition.set(templateEntry.treePosition, templateEntry); } @@ -489,14 +493,11 @@ export function buildAppPageElements< ); } - for (let index = layoutEntries.length - 1; index >= 0; index--) { - const layoutEntry = layoutEntries[index]; - let layoutChildren = routeChildren; - const templateEntry = templateEntries.find( - (entry) => entry.treePosition === layoutEntry.treePosition, - ); + for (let index = orderedTreePositions.length - 1; index >= 0; index--) { + const treePosition = orderedTreePositions[index]; + const templateEntry = templateEntriesByTreePosition.get(treePosition); if (templateEntry) { - layoutChildren = ( + routeChildren = ( - {layoutChildren} + {routeChildren} ); } + const layoutEntry = layoutEntriesByTreePosition.get(treePosition); + if (!layoutEntry) { + continue; + } + let layoutChildren = routeChildren; const layoutErrorComponent = getErrorBoundaryExport(layoutEntry.errorModule); if (layoutErrorComponent) { layoutChildren = ( @@ -538,7 +544,7 @@ export function buildAppPageElements< .filter(([, slot]) => { const targetIndex = slot.layoutIndex >= 0 ? slot.layoutIndex : layoutEntries.length - 1; - return targetIndex === index; + return targetIndex === layoutIndicesByTreePosition.get(treePosition); }) .map(([slotName]) => [slotName, []]), ), @@ -546,7 +552,11 @@ export function buildAppPageElements< > {layoutChildren} diff --git a/tests/app-page-route-wiring.test.ts b/tests/app-page-route-wiring.test.ts index 99286cfa9..8ca0844d8 100644 --- a/tests/app-page-route-wiring.test.ts +++ b/tests/app-page-route-wiring.test.ts @@ -1,6 +1,7 @@ import { Fragment, createElement, isValidElement, type ReactNode } from "react"; import { describe, expect, it } from "vite-plus/test"; import { useSelectedLayoutSegments } from "../packages/vinext/src/shims/navigation.js"; +import type { AppElements } from "../packages/vinext/src/server/app-elements.js"; import { buildAppPageElements, createAppPageLayoutEntries, @@ -60,6 +61,17 @@ async function renderHtml(node: ReactNode): Promise { return readStream(stream); } +async function renderRouteEntry(elements: AppElements, routeId: string): Promise { + const { ElementsContext, Slot } = await import("../packages/vinext/src/shims/slot.js"); + return renderHtml( + createElement( + ElementsContext.Provider, + { value: elements }, + createElement(Slot, { id: routeId }), + ), + ); +} + async function withTimeout(promise: Promise, timeoutMs: number): Promise { return new Promise((resolve, reject) => { const timeoutId = setTimeout(() => { @@ -291,4 +303,45 @@ describe("app page route wiring helpers", () => { expect(body).toContain("page:de"); expect(body).not.toContain("page:en"); }); + + it("renders template-only segments in the route entry even without a matching layout", async () => { + function BlogTemplate(props: Record) { + return createElement("div", { "data-template": "blog" }, readChildren(props.children)); + } + + function BlogPage() { + return createElement("main", null, "Blog page"); + } + + const elements = buildAppPageElements({ + element: createElement(BlogPage), + makeThenableParams(params) { + return Promise.resolve(params); + }, + matchedParams: {}, + resolvedMetadata: null, + resolvedViewport: {}, + route: { + error: null, + errors: [null], + layoutTreePositions: [0], + layouts: [{ default: RootLayout }], + loading: null, + notFound: null, + notFounds: [null], + routeSegments: ["blog"], + slots: null, + templateTreePositions: [1], + templates: [{ default: BlogTemplate }], + }, + routePath: "/blog", + rootNotFoundModule: null, + }); + + const body = await renderRouteEntry(elements, "route:/blog"); + + expect(body).toContain('data-layout="root"'); + expect(body).toContain('data-template="blog"'); + expect(body).toContain("Blog page"); + }); }); From 9bf09a88ca50e4cd3cdf6bc8622db439be871931 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Thu, 2 Apr 2026 17:55:57 +1100 Subject: [PATCH 19/19] test: add E2E verification for layout persistence flat payload pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prove the flat keyed map architecture works end-to-end: - Layout state persists across sibling navigation (counter survives) - Template remounts on segment boundary change, persists within segment - Error boundary clears on navigate-away-and-back - Back/forward preserves layout state through history - Parallel slots persist on soft nav, show default.tsx on hard nav Zero production code changes — test fixtures and Playwright specs only. --- .../e2e/app-router/layout-persistence.spec.ts | 228 ++++++++++++++++++ .../app/components/layout-counter.tsx | 21 ++ .../app/components/template-counter.tsx | 22 ++ .../app-basic/app/dashboard/layout.tsx | 9 + .../app-basic/app/error-test/error.tsx | 5 + tests/fixtures/app-basic/app/page.tsx | 3 + tests/fixtures/app-basic/app/template.tsx | 3 + 7 files changed, 291 insertions(+) create mode 100644 tests/e2e/app-router/layout-persistence.spec.ts create mode 100644 tests/fixtures/app-basic/app/components/layout-counter.tsx create mode 100644 tests/fixtures/app-basic/app/components/template-counter.tsx diff --git a/tests/e2e/app-router/layout-persistence.spec.ts b/tests/e2e/app-router/layout-persistence.spec.ts new file mode 100644 index 000000000..5a6c72821 --- /dev/null +++ b/tests/e2e/app-router/layout-persistence.spec.ts @@ -0,0 +1,228 @@ +import { test, expect } from "@playwright/test"; + +const BASE = "http://localhost:4174"; + +/** + * Wait for the RSC browser entry to hydrate. + */ +async function waitForHydration(page: import("@playwright/test").Page) { + await expect(async () => { + const ready = await page.evaluate(() => "__VINEXT_RSC_ROOT__" in window); + expect(ready).toBe(true); + }).toPass({ timeout: 10_000 }); +} + +// --------------------------------------------------------------------------- +// 1. Layout persistence — navigate between sibling routes, prove the layout +// DOM survives and client state in it persists. +// --------------------------------------------------------------------------- + +test.describe("Layout persistence", () => { + test("dashboard layout counter survives sibling navigation", async ({ page }) => { + await page.goto(`${BASE}/dashboard`); + await expect(page.locator("h1")).toHaveText("Dashboard"); + await waitForHydration(page); + + // Increment the counter in the dashboard layout + await page.click('[data-testid="layout-increment"]'); + await page.click('[data-testid="layout-increment"]'); + await page.click('[data-testid="layout-increment"]'); + await expect(page.locator('[data-testid="layout-count"]')).toHaveText("Layout count: 3"); + + // Navigate to settings (sibling route under same layout) + await page.click('[data-testid="dash-settings-link"]'); + await expect(page.locator("h1")).toHaveText("Settings"); + + // Layout counter should still be 3 — the layout was NOT remounted + await expect(page.locator('[data-testid="layout-count"]')).toHaveText("Layout count: 3"); + + // Navigate back to dashboard home + await page.click('[data-testid="dash-home-link"]'); + await expect(page.locator("h1")).toHaveText("Dashboard"); + + // Counter should still be 3 + await expect(page.locator('[data-testid="layout-count"]')).toHaveText("Layout count: 3"); + }); + + test("layout counter resets on hard navigation", async ({ page }) => { + await page.goto(`${BASE}/dashboard`); + await waitForHydration(page); + + // Increment counter + await page.click('[data-testid="layout-increment"]'); + await expect(page.locator('[data-testid="layout-count"]')).toHaveText("Layout count: 1"); + + // Hard navigation (full page load) should reset everything + await page.goto(`${BASE}/dashboard`); + await waitForHydration(page); + + await expect(page.locator('[data-testid="layout-count"]')).toHaveText("Layout count: 0"); + }); +}); + +// --------------------------------------------------------------------------- +// 2. Template remount — prove template state resets on segment boundary +// change but persists on search param change. +// --------------------------------------------------------------------------- + +test.describe("Template remount", () => { + test("root template counter resets when navigating between top-level segments", async ({ + page, + }) => { + await page.goto(`${BASE}/`); + await expect(page.locator("h1")).toHaveText("Welcome to App Router"); + await waitForHydration(page); + + // Increment the template counter + await page.click('[data-testid="template-increment"]'); + await page.click('[data-testid="template-increment"]'); + await expect(page.locator('[data-testid="template-count"]')).toHaveText("Template count: 2"); + + // Navigate to /about — this changes the root segment from "" to "about", + // so the root template should remount and the counter should reset. + await page.click('a[href="/about"]'); + await expect(page.locator("h1")).toHaveText("About"); + + await expect(page.locator('[data-testid="template-count"]')).toHaveText("Template count: 0"); + }); + + test("root template counter persists within same top-level segment", async ({ page }) => { + await page.goto(`${BASE}/dashboard`); + await expect(page.locator("h1")).toHaveText("Dashboard"); + await waitForHydration(page); + + // Increment the template counter + await page.click('[data-testid="template-increment"]'); + await page.click('[data-testid="template-increment"]'); + await expect(page.locator('[data-testid="template-count"]')).toHaveText("Template count: 2"); + + // Navigate to /dashboard/settings — this is still under the "dashboard" + // top-level segment, so the root template should NOT remount. + await page.click('[data-testid="dash-settings-link"]'); + await expect(page.locator("h1")).toHaveText("Settings"); + + await expect(page.locator('[data-testid="template-count"]')).toHaveText("Template count: 2"); + }); +}); + +// --------------------------------------------------------------------------- +// 3. Error recovery — trigger an error, navigate away via client nav, +// navigate back, prove the error is gone and normal content renders. +// --------------------------------------------------------------------------- + +test.describe("Error recovery across navigation", () => { + test("navigating away from error and back clears the error", async ({ page }) => { + await page.goto(`${BASE}/error-test`); + await expect(page.locator('[data-testid="error-content"]')).toBeVisible(); + await waitForHydration(page); + + // Trigger error + await expect(async () => { + await page.click('[data-testid="trigger-error"]'); + await expect(page.locator("#error-boundary")).toBeVisible({ timeout: 2_000 }); + }).toPass({ timeout: 15_000 }); + + // Error boundary should be visible + await expect(page.locator("#error-boundary")).toBeVisible(); + + // Client-navigate away to home via the link in the error boundary + await page.click('[data-testid="error-go-home"]'); + await expect(page.locator("h1")).toHaveText("Welcome to App Router"); + + // Client-navigate back to error-test via link on home page + await page.click('[data-testid="error-test-link"]'); + + // Error should be gone — fresh page renders normally + await expect(page.locator('[data-testid="error-content"]')).toBeVisible({ timeout: 5_000 }); + await expect(page.locator("#error-boundary")).not.toBeVisible(); + }); +}); + +// --------------------------------------------------------------------------- +// 4. Back/forward — navigate through a sequence, go back, prove layout +// state survived the round trip. +// --------------------------------------------------------------------------- + +test.describe("Back/forward with layout state", () => { + test("browser back preserves layout counter across navigation history", async ({ page }) => { + await page.goto(`${BASE}/dashboard`); + await expect(page.locator("h1")).toHaveText("Dashboard"); + await waitForHydration(page); + + // Increment layout counter to 2 + await page.click('[data-testid="layout-increment"]'); + await page.click('[data-testid="layout-increment"]'); + await expect(page.locator('[data-testid="layout-count"]')).toHaveText("Layout count: 2"); + + // Navigate: dashboard → settings + await page.click('[data-testid="dash-settings-link"]'); + await expect(page.locator("h1")).toHaveText("Settings"); + + // Counter should still be 2 (layout persisted) + await expect(page.locator('[data-testid="layout-count"]')).toHaveText("Layout count: 2"); + + // Increment once more while on settings + await page.click('[data-testid="layout-increment"]'); + await expect(page.locator('[data-testid="layout-count"]')).toHaveText("Layout count: 3"); + + // Go back to dashboard + await page.goBack(); + await expect(page.locator("h1")).toHaveText("Dashboard"); + + // Counter should still be 3 — back/forward doesn't remount the layout + await expect(page.locator('[data-testid="layout-count"]')).toHaveText("Layout count: 3"); + + // Go forward to settings + await page.goForward(); + await expect(page.locator("h1")).toHaveText("Settings"); + + // Counter should still be 3 + await expect(page.locator('[data-testid="layout-count"]')).toHaveText("Layout count: 3"); + }); +}); + +// --------------------------------------------------------------------------- +// 5. Parallel slots — soft nav keeps slot content; hard load shows default. +// --------------------------------------------------------------------------- + +test.describe("Parallel slot persistence", () => { + test("parallel slot content persists on soft navigation to child route", async ({ page }) => { + // Load /dashboard — parallel slots @team and @analytics have page.tsx + await page.goto(`${BASE}/dashboard`); + await expect(page.locator("h1")).toHaveText("Dashboard"); + await waitForHydration(page); + + // Verify slot content is visible + await expect(page.locator('[data-testid="team-panel"]')).toBeVisible(); + await expect(page.locator('[data-testid="analytics-panel"]')).toBeVisible(); + await expect(page.locator('[data-testid="team-slot"]')).toBeVisible(); + await expect(page.locator('[data-testid="analytics-slot"]')).toBeVisible(); + + // Soft navigate to /dashboard/settings + await page.click('[data-testid="dash-settings-link"]'); + await expect(page.locator("h1")).toHaveText("Settings"); + + // Parallel slot content should persist from the soft nav — + // the slots don't have a page.tsx for /settings, so the previous + // slot content is retained (absent key = persisted from prior soft nav). + await expect(page.locator('[data-testid="team-panel"]')).toBeVisible(); + await expect(page.locator('[data-testid="analytics-panel"]')).toBeVisible(); + }); + + test("parallel slots show default.tsx on hard navigation to child route", async ({ page }) => { + // Hard-load /dashboard/settings directly — slots should show default.tsx + await page.goto(`${BASE}/dashboard/settings`); + await expect(page.locator("h1")).toHaveText("Settings"); + await waitForHydration(page); + + // On hard load, slots should render their default.tsx content + await expect(page.locator('[data-testid="team-panel"]')).toBeVisible(); + await expect(page.locator('[data-testid="analytics-panel"]')).toBeVisible(); + await expect(page.locator('[data-testid="team-default"]')).toBeVisible(); + await expect(page.locator('[data-testid="analytics-default"]')).toBeVisible(); + + // The page-specific slot content should NOT be visible + await expect(page.locator('[data-testid="team-slot"]')).not.toBeVisible(); + await expect(page.locator('[data-testid="analytics-slot"]')).not.toBeVisible(); + }); +}); diff --git a/tests/fixtures/app-basic/app/components/layout-counter.tsx b/tests/fixtures/app-basic/app/components/layout-counter.tsx new file mode 100644 index 000000000..14d9bd94a --- /dev/null +++ b/tests/fixtures/app-basic/app/components/layout-counter.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { useState } from "react"; + +/** + * Client counter used to verify layout persistence. + * If the layout remounts, this counter resets to 0. + * If the layout persists across navigation, the counter retains its value. + */ +export function LayoutCounter() { + const [count, setCount] = useState(0); + + return ( +
+ Layout count: {count} + +
+ ); +} diff --git a/tests/fixtures/app-basic/app/components/template-counter.tsx b/tests/fixtures/app-basic/app/components/template-counter.tsx new file mode 100644 index 000000000..c5be0f61d --- /dev/null +++ b/tests/fixtures/app-basic/app/components/template-counter.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { useState } from "react"; + +/** + * Client counter used to verify template remount behavior. + * Templates remount when navigation crosses their segment boundary, + * so this counter should reset. But search param changes within the + * same segment should NOT cause a remount, so the counter persists. + */ +export function TemplateCounter() { + const [count, setCount] = useState(0); + + return ( +
+ Template count: {count} + +
+ ); +} diff --git a/tests/fixtures/app-basic/app/dashboard/layout.tsx b/tests/fixtures/app-basic/app/dashboard/layout.tsx index 169c725b6..d5d53901a 100644 --- a/tests/fixtures/app-basic/app/dashboard/layout.tsx +++ b/tests/fixtures/app-basic/app/dashboard/layout.tsx @@ -1,3 +1,5 @@ +import Link from "next/link"; +import { LayoutCounter } from "../components/layout-counter"; import { SegmentDisplay } from "./segment-display"; export default function DashboardLayout({ @@ -13,7 +15,14 @@ export default function DashboardLayout({
+
{children}
{team && } diff --git a/tests/fixtures/app-basic/app/error-test/error.tsx b/tests/fixtures/app-basic/app/error-test/error.tsx index 3aac6291c..c2d01c210 100644 --- a/tests/fixtures/app-basic/app/error-test/error.tsx +++ b/tests/fixtures/app-basic/app/error-test/error.tsx @@ -1,11 +1,16 @@ "use client"; +import Link from "next/link"; + export default function ErrorPage({ error, reset }: { error: Error; reset: () => void }) { return (

Something went wrong!

{error.message}

+ + Go home +
); } diff --git a/tests/fixtures/app-basic/app/page.tsx b/tests/fixtures/app-basic/app/page.tsx index a67457c09..2881acbb7 100644 --- a/tests/fixtures/app-basic/app/page.tsx +++ b/tests/fixtures/app-basic/app/page.tsx @@ -21,6 +21,9 @@ export default function HomePage() { Nav Flash List + + Error Test + ); diff --git a/tests/fixtures/app-basic/app/template.tsx b/tests/fixtures/app-basic/app/template.tsx index 4139d6756..7c642dc0d 100644 --- a/tests/fixtures/app-basic/app/template.tsx +++ b/tests/fixtures/app-basic/app/template.tsx @@ -1,3 +1,5 @@ +import { TemplateCounter } from "./components/template-counter"; + /** * Root template — wraps all pages but re-mounts on navigation. * Unlike layout.tsx, template.tsx creates a new instance for each route. @@ -6,6 +8,7 @@ export default function RootTemplate({ children }: { children: React.ReactNode } return (
Template Active
+ {children}
);