diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index 23e1c3ec..52b21e0b 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 00000000..91e7fe0f --- /dev/null +++ b/packages/vinext/src/server/app-page-route-wiring.tsx @@ -0,0 +1,337 @@ +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 AppPageErrorComponent = ComponentType<{ error: Error; reset: () => void }>; + +export type AppPageModule = Record & { + default?: AppPageComponent | null | undefined; +}; + +export type AppPageErrorModule = Record & { + default?: AppPageErrorComponent | null | undefined; +}; + +export type AppPageRouteWiringSlot< + TModule extends AppPageModule = AppPageModule, + TErrorModule extends AppPageErrorModule = AppPageErrorModule, +> = { + default?: TModule | null; + error?: TErrorModule | null; + layout?: TModule | null; + layoutIndex: number; + loading?: TModule | null; + page?: TModule | null; +}; + +export type AppPageRouteWiringRoute< + TModule extends AppPageModule = AppPageModule, + TErrorModule extends AppPageErrorModule = AppPageErrorModule, +> = { + error?: TErrorModule | null; + errors?: readonly (TErrorModule | null | undefined)[] | null; + layoutTreePositions?: readonly number[] | null; + layouts: readonly (TModule | null | undefined)[]; + loading?: TModule | null; + notFound?: TModule | null; + notFounds?: readonly (TModule | null | undefined)[] | null; + routeSegments?: readonly string[]; + slots?: Readonly>> | null; + templates?: readonly (TModule | null | undefined)[] | null; +}; + +export type AppPageSlotOverride = { + pageModule: TModule; + params?: AppPageParams; + props?: Readonly>; +}; + +export type AppPageLayoutEntry< + TModule extends AppPageModule = AppPageModule, + TErrorModule extends AppPageErrorModule = AppPageErrorModule, +> = { + errorModule?: TErrorModule | null | undefined; + id: string; + layoutModule?: TModule | null | undefined; + notFoundModule?: TModule | null | undefined; + treePath: string; + treePosition: number; +}; + +export type BuildAppPageRouteElementOptions< + TModule extends AppPageModule = AppPageModule, + TErrorModule extends AppPageErrorModule = AppPageErrorModule, +> = { + element: ReactNode; + globalErrorModule?: TErrorModule | null; + makeThenableParams: (params: AppPageParams) => unknown; + matchedParams: AppPageParams; + resolvedMetadata: Metadata | null; + resolvedViewport: Viewport; + rootNotFoundModule?: TModule | null; + route: AppPageRouteWiringRoute; + slotOverrides?: Readonly>> | null; +}; + +function getDefaultExport( + module: TModule | null | undefined, +): AppPageComponent | null { + return module?.default ?? null; +} + +function getErrorBoundaryExport( + module: TModule | null | undefined, +): AppPageErrorComponent | null { + return module?.default ?? null; +} + +export function createAppPageTreePath( + routeSegments: readonly string[] | null | undefined, + treePosition: number, +): string { + const treePathSegments = routeSegments?.slice(0, treePosition) ?? []; + if (treePathSegments.length === 0) { + return "/"; + } + return `/${treePathSegments.join("/")}`; +} + +export function createAppPageLayoutEntries< + TModule extends AppPageModule, + TErrorModule extends AppPageErrorModule, +>( + route: Pick< + AppPageRouteWiringRoute, + "errors" | "layoutTreePositions" | "layouts" | "notFounds" | "routeSegments" + >, +): AppPageLayoutEntry[] { + return route.layouts.map((layoutModule, index) => { + const treePosition = route.layoutTreePositions?.[index] ?? 0; + const treePath = createAppPageTreePath(route.routeSegments, treePosition); + return { + errorModule: route.errors?.[index] ?? null, + id: `layout:${treePath}`, + layoutModule, + notFoundModule: route.notFounds?.[index] ?? null, + treePath, + treePosition, + }; + }); +} + +export function resolveAppPageChildSegments( + routeSegments: readonly string[], + treePosition: number, + params: Readonly>, +): string[] { + const rawSegments = routeSegments.slice(treePosition); + const resolvedSegments: string[] = []; + + for (const segment of rawSegments) { + if ( + segment.startsWith("[[...") && + segment.endsWith("]]") && + segment.length >= "[[...x]]".length + ) { + 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< + TModule extends AppPageModule, + TErrorModule extends AppPageErrorModule, +>(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 = 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; + // Next.js doesn't pass params to templates at all (createElement(Template, null, children)). + // We pass raw params here for convenience; layouts below receive thenable (Promise) params + // to match the Next.js 15+ async params contract. + 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/packages/vinext/src/shims/error-boundary.tsx b/packages/vinext/src/shims/error-boundary.tsx index 1f097ba1..cadcbdb9 100644 --- a/packages/vinext/src/shims/error-boundary.tsx +++ b/packages/vinext/src/shims/error-boundary.tsx @@ -9,8 +9,13 @@ export type ErrorBoundaryProps = { children: React.ReactNode; }; +type ErrorBoundaryInnerProps = { + pathname: string; +} & ErrorBoundaryProps; + export type ErrorBoundaryState = { error: Error | null; + previousPathname: string; }; /** @@ -18,13 +23,26 @@ export type ErrorBoundaryState = { * This must be a client component since error boundaries use * componentDidCatch / getDerivedStateFromError. */ -export class ErrorBoundary extends React.Component { - constructor(props: ErrorBoundaryProps) { +export class ErrorBoundaryInner extends React.Component< + ErrorBoundaryInnerProps, + ErrorBoundaryState +> { + constructor(props: ErrorBoundaryInnerProps) { super(props); - this.state = { error: null }; + this.state = { error: null, previousPathname: props.pathname }; + } + + static getDerivedStateFromProps( + props: ErrorBoundaryInnerProps, + state: ErrorBoundaryState, + ): ErrorBoundaryState | null { + if (props.pathname !== state.previousPathname && state.error) { + return { error: null, previousPathname: props.pathname }; + } + return { error: state.error, previousPathname: props.pathname }; } - static getDerivedStateFromError(error: Error): ErrorBoundaryState { + static getDerivedStateFromError(error: Error): Partial { // notFound(), forbidden(), unauthorized(), and redirect() must propagate // past error boundaries. Re-throw them so they bubble up to the // framework's HTTP access fallback / redirect handler. @@ -54,6 +72,15 @@ export class ErrorBoundary extends React.Component + {children} + + ); +} + // --------------------------------------------------------------------------- // NotFoundBoundary — catches notFound() on the client and renders not-found.tsx // --------------------------------------------------------------------------- diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index 7c8a503e..96989fff 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 00000000..1bc1956a --- /dev/null +++ b/tests/app-page-route-wiring.test.ts @@ -0,0 +1,175 @@ +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("passes route group segments through unchanged", () => { + expect(resolveAppPageChildSegments(["(auth)", "login"], 0, {})).toEqual(["(auth)", "login"]); + }); + + it("skips optional catch-all when param is undefined", () => { + expect(resolveAppPageChildSegments(["docs", "[[...slug]]"], 0, {})).toEqual(["docs"]); + }); + + it("skips optional catch-all when param is an empty array", () => { + expect(resolveAppPageChildSegments(["docs", "[[...slug]]"], 0, { slug: [] })).toEqual(["docs"]); + }); + + it("falls back to raw segment for dynamic param with undefined value", () => { + expect(resolveAppPageChildSegments(["blog", "[id]"], 0, {})).toEqual(["blog", "[id]"]); + }); + + it("preserves empty-string param instead of falling back to raw segment", () => { + expect(resolveAppPageChildSegments(["blog", "[...slug]"], 0, { slug: "" })).toEqual([ + "blog", + "", + ]); + }); + + 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 3639bb9f..330cc014 100644 --- a/tests/error-boundary.test.ts +++ b/tests/error-boundary.test.ts @@ -18,48 +18,81 @@ vi.mock("next/navigation", () => ({ usePathname: () => "/", })); // The error boundary is primarily a client-side component. +// +// Verified against Next.js source: +// - packages/next/src/client/components/error-boundary.tsx +// - packages/next/src/client/components/navigation.ts +// +// Next.js resets segment error boundaries on pathname changes using a +// previousPathname field, and usePathname() is pathname-only rather than +// query-aware. These tests lock our shim to that behavior. + +type ErrorBoundaryInnerConstructor = { + getDerivedStateFromError(error: Error): Partial<{ + error: Error | null; + previousPathname: string; + }>; + getDerivedStateFromProps( + props: { + children: React.ReactNode; + fallback: React.ComponentType<{ error: Error; reset: () => void }>; + pathname: string; + }, + state: { + error: Error | null; + previousPathname: string; + }, + ): { + error: Error | null; + previousPathname: string; + } | null; +}; + +function isErrorBoundaryInnerConstructor(value: unknown): value is ErrorBoundaryInnerConstructor { + return value !== null && typeof value === "function"; +} + +function createErrorWithDigest(message: string, digest: string) { + return Object.assign(new Error(message), { digest }); +} // Test the digest detection patterns used by the boundaries describe("ErrorBoundary digest patterns", () => { it("NEXT_NOT_FOUND digest matches legacy not-found pattern", () => { - const error = new Error("Not Found"); - (error as any).digest = "NEXT_NOT_FOUND"; - - // The ErrorBoundary re-throws errors with these digests - const digest = (error as any).digest; - expect(digest === "NEXT_NOT_FOUND").toBe(true); + const error = createErrorWithDigest("Not Found", "NEXT_NOT_FOUND"); + expect(Reflect.get(error, "digest")).toBe("NEXT_NOT_FOUND"); }); it("NEXT_HTTP_ERROR_FALLBACK;404 matches new not-found pattern", () => { - const error = new Error("Not Found"); - (error as any).digest = "NEXT_HTTP_ERROR_FALLBACK;404"; + const digest = "NEXT_HTTP_ERROR_FALLBACK;404"; + const error = createErrorWithDigest("Not Found", digest); - const digest = (error as any).digest; + expect(Reflect.get(error, "digest")).toBe(digest); expect(digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")).toBe(true); expect(digest).toBe("NEXT_HTTP_ERROR_FALLBACK;404"); }); it("NEXT_HTTP_ERROR_FALLBACK;403 matches forbidden pattern", () => { - const error = new Error("Forbidden"); - (error as any).digest = "NEXT_HTTP_ERROR_FALLBACK;403"; + const digest = "NEXT_HTTP_ERROR_FALLBACK;403"; + const error = createErrorWithDigest("Forbidden", digest); - const digest = (error as any).digest; + expect(Reflect.get(error, "digest")).toBe(digest); expect(digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")).toBe(true); }); it("NEXT_HTTP_ERROR_FALLBACK;401 matches unauthorized pattern", () => { - const error = new Error("Unauthorized"); - (error as any).digest = "NEXT_HTTP_ERROR_FALLBACK;401"; + const digest = "NEXT_HTTP_ERROR_FALLBACK;401"; + const error = createErrorWithDigest("Unauthorized", digest); - const digest = (error as any).digest; + expect(Reflect.get(error, "digest")).toBe(digest); expect(digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")).toBe(true); }); it("NEXT_REDIRECT digest matches redirect pattern", () => { - const error = new Error("Redirect"); - (error as any).digest = "NEXT_REDIRECT;replace;/login;307;"; + const digest = "NEXT_REDIRECT;replace;/login;307;"; + const error = createErrorWithDigest("Redirect", digest); - const digest = (error as any).digest; + expect(Reflect.get(error, "digest")).toBe(digest); expect(digest.startsWith("NEXT_REDIRECT;")).toBe(true); }); @@ -70,12 +103,12 @@ describe("ErrorBoundary digest patterns", () => { }); it("errors with non-special digests are caught by ErrorBoundary", () => { - const error = new Error("Custom error"); - (error as any).digest = "SOME_CUSTOM_DIGEST"; + const digest = "SOME_CUSTOM_DIGEST"; + const error = createErrorWithDigest("Custom error", digest); - const digest = (error as any).digest; + expect(Reflect.get(error, "digest")).toBe(digest); // These should NOT be re-thrown — they should be caught - expect(digest === "NEXT_NOT_FOUND").toBe(false); + expect(digest).not.toBe("NEXT_NOT_FOUND"); expect(digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")).toBe(false); expect(digest.startsWith("NEXT_REDIRECT;")).toBe(false); }); @@ -85,53 +118,138 @@ 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 }); + }); + + // No direct Next.js test equivalent; behavior inferred from + // packages/next/src/client/components/error-boundary.tsx (getDerivedStateFromProps). + // Next.js uses the same pathname !== previousPathname guard at line 93 to clear + // error state on navigation. Their E2E test (test/e2e/app-dir/errors/index.test.ts) + // only covers the button-click reset path, not pathname-based reset. + 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", + }); + }); + + // Validates the getDerivedStateFromError → getDerivedStateFromProps sequence + // on the same pathname. Inferred from Next.js error-boundary.tsx: getDerivedStateFromError + // returns { error } (partial state), React merges it preserving previousPathname, then + // getDerivedStateFromProps sees matching pathnames and preserves the error. + 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", + }); }); });