diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index 95050afd..c6687310 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -149,9 +149,7 @@ export function generateRscEntry( if (route.pagePath) getImportVar(route.pagePath); if (route.routePath) getImportVar(route.routePath); for (const layout of route.layouts) getImportVar(layout); - for (const tmpl of route.templates) { - if (tmpl) getImportVar(tmpl); - } + for (const tmpl of route.templates) getImportVar(tmpl); if (route.loadingPath) getImportVar(route.loadingPath); if (route.errorPath) getImportVar(route.errorPath); if (route.layoutErrorPaths) @@ -181,7 +179,7 @@ export function generateRscEntry( // Build route table as serialized JS const routeEntries = routes.map((route) => { const layoutVars = route.layouts.map((l) => getImportVar(l)); - const templateVars = route.templates.map((t) => (t ? getImportVar(t) : "null")); + const templateVars = route.templates.map((t) => getImportVar(t)); const notFoundVars = (route.notFoundPaths || []).map((nf) => (nf ? getImportVar(nf) : "null")); const slotEntries = route.parallelSlots.map((slot) => { const interceptEntries = slot.interceptingRoutes.map( @@ -217,6 +215,7 @@ ${interceptEntries.join(",\n")} routeHandler: ${route.routePath ? getImportVar(route.routePath) : "null"}, layouts: [${layoutVars.join(", ")}], routeSegments: ${JSON.stringify(route.routeSegments)}, + templateTreePositions: ${JSON.stringify(route.templateTreePositions)}, layoutTreePositions: ${JSON.stringify(route.layoutTreePositions)}, templates: [${templateVars.join(", ")}], errors: [${layoutErrorVars.join(", ")}], @@ -385,7 +384,8 @@ import { renderAppPageHttpAccessFallback as __renderAppPageHttpAccessFallback, } from ${JSON.stringify(appPageBoundaryRenderPath)}; import { - buildAppPageRouteElement as __buildAppPageRouteElement, + buildAppPageElements as __buildAppPageElements, + createAppPageTreePath as __createAppPageTreePath, resolveAppPageChildSegments as __resolveAppPageChildSegments, } from ${JSON.stringify(appPageRouteWiringPath)}; import { @@ -906,10 +906,22 @@ function findIntercept(pathname) { return null; } -async function buildPageElement(route, params, opts, searchParams) { +async function buildPageElements(route, params, routePath, opts, searchParams) { const PageComponent = route.page?.default; if (!PageComponent) { - return createElement("div", null, "Page has no default export"); + const _noExportRouteId = "route:" + routePath; + let _noExportRootLayout = null; + if (route.layouts?.length > 0) { + // Compute the root layout tree path for this error payload using the + // canonical helper so it stays aligned with buildAppPageElements(). + const _tp = route.layoutTreePositions?.[0] ?? 0; + _noExportRootLayout = __createAppPageTreePath(route.routeSegments, _tp); + } + return { + __route: _noExportRouteId, + __rootLayout: _noExportRootLayout, + [_noExportRouteId]: createElement("div", null, "Page has no default export"), + }; } // Resolve metadata and viewport from layouts and page. @@ -1007,13 +1019,14 @@ async function buildPageElement(route, params, opts, searchParams) { // dynamic, and this avoids false positives from React internals. if (hasSearchParams) markDynamicUsage(); } - return __buildAppPageRouteElement({ + return __buildAppPageElements({ element: createElement(PageComponent, pageProps), globalErrorModule: ${globalErrorVar ? globalErrorVar : "null"}, makeThenableParams, matchedParams: params, resolvedMetadata, resolvedViewport, + routePath, rootNotFoundModule: ${rootNotFoundVar ? rootNotFoundVar : "null"}, route, slotOverrides: @@ -1674,10 +1687,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { returnValue = { ok: true, data }; } catch (e) { // Detect redirect() / permanentRedirect() called inside the action. - // These throw errors with digest "NEXT_REDIRECT;;[;]". - // The type field is empty when redirect() was called without an explicit - // type argument. In Server Action context, Next.js defaults to "push" so - // the Back button works after form submissions. + // These throw errors with digest "NEXT_REDIRECT;replace;url[;status]". // The URL is encodeURIComponent-encoded to prevent semicolons in the URL // from corrupting the delimiter-based digest format. if (e && typeof e === "object" && "digest" in e) { @@ -1686,7 +1696,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const parts = digest.split(";"); actionRedirect = { url: decodeURIComponent(parts[2]), - type: parts[1] || "push", // Server Action → default "push" + type: parts[1] || "push", // "push" or "replace" status: parts[3] ? parseInt(parts[3], 10) : 307, }; returnValue = { ok: true, data: undefined }; @@ -1723,9 +1733,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept", }); - // Merge middleware headers first so the framework's own redirect control - // headers below are always authoritative and cannot be clobbered by - // middleware that happens to set x-action-redirect* keys. __mergeMiddlewareResponseHeaders(redirectHeaders, _mwCtx.headers); redirectHeaders.set("x-action-redirect", actionRedirect.url); redirectHeaders.set("x-action-redirect-type", actionRedirect.type); @@ -1749,9 +1756,20 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { searchParams: url.searchParams, params: actionParams, }); - element = await buildPageElement(actionRoute, actionParams, undefined, url.searchParams); + element = buildPageElements( + actionRoute, + actionParams, + cleanPathname, + undefined, + url.searchParams, + ); } else { - element = createElement("div", null, "Page not found"); + const _actionRouteId = "route:" + cleanPathname; + element = { + __route: _actionRouteId, + __rootLayout: null, + [_actionRouteId]: createElement("div", null, "Page not found"), + }; } const onRenderError = createRscOnErrorHandler( @@ -1772,15 +1790,22 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const actionPendingCookies = getAndClearPendingCookies(); const actionDraftCookie = getDraftModeCookieHeader(); - const actionHeaders = new Headers({ "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }); + const actionHeaders = new Headers({ + "Content-Type": "text/x-component; charset=utf-8", + "Vary": "RSC, Accept", + }); __mergeMiddlewareResponseHeaders(actionHeaders, _mwCtx.headers); + const actionResponse = new Response(rscStream, { + status: _mwCtx.status ?? 200, + headers: actionHeaders, + }); if (actionPendingCookies.length > 0 || actionDraftCookie) { for (const cookie of actionPendingCookies) { - actionHeaders.append("Set-Cookie", cookie); + actionResponse.headers.append("Set-Cookie", cookie); } - if (actionDraftCookie) actionHeaders.append("Set-Cookie", actionDraftCookie); + if (actionDraftCookie) actionResponse.headers.append("Set-Cookie", actionDraftCookie); } - return new Response(rscStream, { status: _mwCtx.status ?? 200, headers: actionHeaders }); + return actionResponse; } catch (err) { getAndClearPendingCookies(); // Clear pending cookies on error console.error("[vinext] Server action error:", err); @@ -2103,7 +2128,13 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { return _runWithUnifiedCtx(__revalUCtx, async () => { _ensureFetchPatch(); setNavigationContext({ pathname: cleanPathname, searchParams: new URLSearchParams(), params }); - const __revalElement = await buildPageElement(route, params, undefined, new URLSearchParams()); + const __revalElement = await buildPageElements( + route, + params, + cleanPathname, + undefined, + new URLSearchParams(), + ); const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); const __revalRscCapture = __teeAppPageRscStreamForCapture(__revalRscStream, true); @@ -2152,44 +2183,25 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // If the target URL matches an intercepting route in a parallel slot, // render the source route with the intercepting page in the slot. const __interceptResult = await __resolveAppPageIntercept({ - buildPageElement, + buildPageElement(interceptRoute, interceptParams, interceptOpts, interceptSearchParams) { + return buildPageElements( + interceptRoute, + interceptParams, + cleanPathname, + interceptOpts, + interceptSearchParams, + ); + }, cleanPathname, currentRoute: route, findIntercept, - getRoutePattern(sourceRoute) { - return sourceRoute.pattern; + getRouteParamNames(sourceRoute) { + return sourceRoute.params; }, getSourceRoute(sourceRouteIndex) { return routes[sourceRouteIndex]; }, isRscRequest, - matchSourceRouteParams(pattern) { - // Extract actual URL param values by prefix-matching the request pathname - // against the source route's pattern. This handles all interception conventions: - // (.) same-level, (..) one-level-up, and (...) root — the source pattern's - // dynamic segments that align with the URL get their real values extracted. - // We must NOT use matchRoute(pattern) here: the trie would match the literal - // ":param" strings as dynamic segment values, returning e.g. {id: ":id"}. - const patternParts = pattern.split("/").filter(Boolean); - const urlParts = cleanPathname.split("/").filter(Boolean); - const params = Object.create(null); - for (let i = 0; i < patternParts.length; i++) { - const pp = patternParts[i]; - if (pp.endsWith("+") || pp.endsWith("*")) { - // urlParts.slice(i) safely returns [] when i >= urlParts.length, - // which is the correct value for optional catch-all with zero segments. - params[pp.slice(1, -1)] = urlParts.slice(i); - break; - } - if (i >= urlParts.length) break; - if (pp.startsWith(":")) { - params[pp.slice(1)] = urlParts[i]; - } else if (pp !== urlParts[i]) { - break; - } - } - return params; - }, renderInterceptResponse(sourceRoute, interceptElement) { const interceptOnError = createRscOnErrorHandler( request, @@ -2203,7 +2215,10 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // by the client, and async server components that run during consumption need the // context to still be live. The AsyncLocalStorage scope from runWithRequestContext // handles cleanup naturally when all async continuations complete. - const interceptHeaders = new Headers({ "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }); + const interceptHeaders = new Headers({ + "Content-Type": "text/x-component; charset=utf-8", + "Vary": "RSC, Accept", + }); __mergeMiddlewareResponseHeaders(interceptHeaders, _mwCtx.headers); return new Response(interceptStream, { status: _mwCtx.status ?? 200, @@ -2227,7 +2242,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const __pageBuildResult = await __buildAppPageElement({ buildPageElement() { - return buildPageElement(route, params, interceptOpts, url.searchParams); + return buildPageElements(route, params, cleanPathname, interceptOpts, url.searchParams); }, renderErrorBoundaryPage(buildErr) { return renderErrorBoundaryPage(route, buildErr, isRscRequest, request, params); @@ -2258,19 +2273,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // rscCssTransform — no manual loadCss() call needed. const _hasLoadingBoundary = !!(route.loading && route.loading.default); const _asyncLayoutParams = makeThenableParams(params); - // Convert URLSearchParams to a plain object then wrap in makeThenableParams() - // so probePage() passes the same shape that buildPageElement() gives to the - // real render. Without this, pages that destructure await-ed searchParams - // throw TypeError during probe. - const _probeSearchObj = {}; - url.searchParams.forEach(function(v, k) { - if (k in _probeSearchObj) { - _probeSearchObj[k] = Array.isArray(_probeSearchObj[k]) ? _probeSearchObj[k].concat(v) : [_probeSearchObj[k], v]; - } else { - _probeSearchObj[k] = v; - } - }); - const _asyncSearchParams = makeThenableParams(_probeSearchObj); return __renderAppPageLifecycle({ cleanPathname, clearRequestContext() { @@ -2316,6 +2318,17 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { return LayoutComp({ params: _asyncLayoutParams, children: null }); }, probePage() { + const _probeSearchObj = {}; + url.searchParams.forEach(function(v, k) { + if (k in _probeSearchObj) { + _probeSearchObj[k] = Array.isArray(_probeSearchObj[k]) + ? _probeSearchObj[k].concat(v) + : [_probeSearchObj[k], v]; + } else { + _probeSearchObj[k] = v; + } + }); + const _asyncSearchParams = makeThenableParams(_probeSearchObj); return PageComponent({ params: _asyncLayoutParams, searchParams: _asyncSearchParams }); }, revalidateSeconds, diff --git a/packages/vinext/src/routing/app-router.ts b/packages/vinext/src/routing/app-router.ts index 4ffcbe66..30b2ab00 100644 --- a/packages/vinext/src/routing/app-router.ts +++ b/packages/vinext/src/routing/app-router.ts @@ -77,8 +77,8 @@ export type AppRoute = { routePath: string | null; /** Ordered list of layout files from root to leaf */ layouts: string[]; - /** Template files aligned with layouts array (null where no template exists at that level) */ - templates: (string | null)[]; + /** Ordered list of template files from root to leaf (parallel to layouts) */ + templates: string[]; /** Parallel route slots (from @slot directories at the route's directory level) */ parallelSlots: ParallelSlot[]; /** Loading component path */ @@ -112,6 +112,8 @@ export type AppRoute = { * Used at render time to compute the child segments for useSelectedLayoutSegments(). */ routeSegments: string[]; + /** Tree position (directory depth from app/ root) for each template. */ + templateTreePositions?: number[]; /** * Tree position (directory depth from app/ root) for each layout. * Used to slice routeSegments and determine which segments are below each layout. @@ -181,9 +183,6 @@ export async function appRouter( routes.push(...slotSubRoutes); validateRoutePatterns(routes.map((route) => route.pattern)); - // Deduplicate intercept target patterns: child routes inherit parent slots - // (including their intercepting routes), so the same target pattern can appear - // on both the parent and child route. Collect unique patterns only. const interceptTargetPatterns = [ ...new Set( routes.flatMap((route) => @@ -258,11 +257,7 @@ function discoverSlotSubRoutes( // that useSelectedLayoutSegments() sees the correct segment list at runtime. rawSegments: string[]; // Pre-computed URL parts, params, isDynamic from convertSegmentsToRouteParts. - converted: { - urlSegments: string[]; - params: string[]; - isDynamic: boolean; - }; + converted: { urlSegments: string[]; params: string[]; isDynamic: boolean }; slotPages: Map; } >(); @@ -356,6 +351,7 @@ function discoverSlotSubRoutes( forbiddenPath: parentRoute.forbiddenPath, unauthorizedPath: parentRoute.unauthorizedPath, routeSegments: [...parentRoute.routeSegments, ...rawSegments], + templateTreePositions: parentRoute.templateTreePositions, layoutTreePositions: parentRoute.layoutTreePositions, isDynamic: parentRoute.isDynamic || subIsDynamic, params: [...parentRoute.params, ...subParams], @@ -431,9 +427,10 @@ function fileToAppRoute( const pattern = "/" + urlSegments.join("/"); - // Discover layouts and layout-aligned templates from root to leaf + // Discover layouts and templates from root to leaf const layouts = discoverLayouts(segments, appDir, matcher); - const templates = discoverLayoutAlignedTemplates(segments, appDir, matcher); + const templates = discoverTemplates(segments, appDir, matcher); + const templateTreePositions = computeLayoutTreePositions(appDir, templates); // Compute the tree position (directory depth) for each layout. const layoutTreePositions = computeLayoutTreePositions(appDir, layouts); @@ -478,6 +475,7 @@ function fileToAppRoute( forbiddenPath, unauthorizedPath, routeSegments: segments, + templateTreePositions, layoutTreePositions, isDynamic, params, @@ -522,37 +520,27 @@ function discoverLayouts(segments: string[], appDir: string, matcher: ValidFileM } /** - * Discover template files aligned with the layouts array. - * Walks the same directory levels as discoverLayouts and, for each level - * that contributes a layout entry, checks whether template.tsx also exists. - * Returns an array of the same length as discoverLayouts() would return, - * with the template path (or null) at each corresponding layout level. - * - * This enables interleaving templates with their corresponding layouts, - * matching Next.js behavior where each segment's hierarchy is - * Layout > Template > ErrorBoundary > children. + * Discover all template files from root to the given directory. + * Each level of the directory tree may have a template.tsx. + * Templates are like layouts but re-mount on navigation. */ -function discoverLayoutAlignedTemplates( +function discoverTemplates( segments: string[], appDir: string, matcher: ValidFileMatcher, -): (string | null)[] { - const templates: (string | null)[] = []; +): string[] { + const templates: string[] = []; - // Root level (only if root has a layout — matching discoverLayouts logic) - const rootLayout = findFile(appDir, "layout", matcher); - if (rootLayout) { - templates.push(findFile(appDir, "template", matcher)); - } + // Check root template + const rootTemplate = findFile(appDir, "template", matcher); + if (rootTemplate) templates.push(rootTemplate); // Check each directory level let currentDir = appDir; for (const segment of segments) { currentDir = path.join(currentDir, segment); - const layout = findFile(currentDir, "layout", matcher); - if (layout) { - templates.push(findFile(currentDir, "template", matcher)); - } + const template = findFile(currentDir, "template", matcher); + if (template) templates.push(template); } return templates; @@ -695,7 +683,7 @@ function discoverInheritedParallelSlots( ...slot, pagePath: null, // Don't use ancestor's page.tsx layoutIndex: lvlLayoutIdx, - routeSegments: null, // Inherited slot shows default.tsx, not an active page + routeSegments: null, // defaultPath, loadingPath, errorPath, interceptingRoutes remain }; // Iteration goes root-to-leaf, so later (closer) ancestors overwrite @@ -745,7 +733,7 @@ function discoverParallelSlots( errorPath: findFile(slotDir, "error", matcher), interceptingRoutes, layoutIndex: -1, // Will be set by discoverInheritedParallelSlots - routeSegments: pagePath ? [] : null, // Root page = [], no page = null (default fallback) + routeSegments: pagePath ? [] : null, }); } diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index dd74e35e..c6f05841 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -5,10 +5,10 @@ import { startTransition, use, useLayoutEffect, - useState, + useReducer, + useRef, type Dispatch, type ReactNode, - type SetStateAction, } from "react"; import { createFromFetch, @@ -46,22 +46,32 @@ import { createProgressiveRscStream, getVinextBrowserGlobal, } from "./app-browser-stream.js"; +import { + normalizeAppElements, + readAppElementsMetadata, + type AppElements, + type AppWireElements, +} from "./app-elements.js"; +import { + createPendingNavigationCommit, + resolveAndClassifyNavigationCommit, + resolvePendingNavigationCommitDisposition, + routerReducer, + type AppRouterAction, + type AppRouterState, +} from "./app-browser-state.js"; +import { ElementsContext, Slot } from "../shims/slot.js"; type SearchParamInput = ConstructorParameters[0]; type ServerActionResult = { - root: ReactNode; + root: AppWireElements; returnValue?: { ok: boolean; data: unknown; }; }; -type BrowserTreeState = { - renderId: number; - node: ReactNode; - navigationSnapshot: ClientNavigationRenderSnapshot; -}; type NavigationKind = "navigate" | "traverse" | "refresh"; type HistoryUpdateMode = "push" | "replace"; type VisitedResponseCacheEntry = { @@ -89,7 +99,8 @@ let nextNavigationRenderId = 0; let activeNavigationId = 0; const pendingNavigationCommits = new Map void>(); const pendingNavigationPrePaintEffects = new Map void>(); -let setBrowserTreeState: Dispatch> | null = null; +let dispatchBrowserRouterAction: Dispatch | null = null; +let browserRouterStateRef: { current: AppRouterState } | null = null; let latestClientParams: Record = {}; const visitedResponseCache = new Map(); @@ -97,11 +108,18 @@ function isServerActionResult(value: unknown): value is ServerActionResult { return !!value && typeof value === "object" && "root" in value; } -function getBrowserTreeStateSetter(): Dispatch> { - if (!setBrowserTreeState) { - throw new Error("[vinext] Browser tree state is not initialized"); +function getBrowserRouterDispatch(): Dispatch { + if (!dispatchBrowserRouterAction) { + throw new Error("[vinext] Browser router dispatch is not initialized"); + } + return dispatchBrowserRouterAction; +} + +function getBrowserRouterState(): AppRouterState { + if (!browserRouterStateRef) { + throw new Error("[vinext] Browser router state is not initialized"); } - return setBrowserTreeState; + return browserRouterStateRef.current; } function applyClientParams(params: Record): void { @@ -171,9 +189,11 @@ function drainPrePaintEffects(upToRenderId: number): void { function createNavigationCommitEffect( href: string, historyUpdateMode: HistoryUpdateMode | undefined, + params: Record, ): () => void { return () => { const targetHref = new URL(href, window.location.origin).href; + stageClientParams(params); if (historyUpdateMode === "replace" && window.location.href !== targetHref) { replaceHistoryStateWithoutNotify(null, "", href); @@ -286,34 +306,121 @@ function NavigationCommitSignal({ return children; } +function normalizeAppElementsPromise(payload: Promise): Promise { + // Wrap in Promise.resolve() because createFromReadableStream() returns a + // React Flight thenable whose .then() returns undefined (not a new Promise). + // Without the wrap, chaining .then() produces undefined → use() crashes. + return Promise.resolve(payload).then((elements) => normalizeAppElements(elements)); +} + +async function commitSameUrlNavigatePayload( + nextElements: Promise, + returnValue?: ServerActionResult["returnValue"], +): Promise { + const navigationSnapshot = createClientNavigationRenderSnapshot( + window.location.href, + latestClientParams, + ); + const currentState = getBrowserRouterState(); + const startedNavigationId = activeNavigationId; + const { disposition, pending } = await resolveAndClassifyNavigationCommit({ + activeNavigationId, + currentState, + navigationSnapshot, + nextElements, + renderId: ++nextNavigationRenderId, + startedNavigationId, + type: "navigate", + }); + + // Known limitation: if a same-URL navigation fully commits while this + // server action is awaiting createPendingNavigationCommit(), the action + // can still dispatch its older payload afterward. The old pre-2c code had + // the same race, and Next.js has similar behavior. Tightening this would + // need a stronger commit-version gate than activeNavigationId alone. + if (disposition === "hard-navigate") { + window.location.assign(window.location.href); + return undefined; + } + + if (disposition === "dispatch") { + dispatchBrowserTree( + pending.action.elements, + navigationSnapshot, + pending.action.renderId, + "navigate", + pending.routeId, + pending.rootLayoutTreePath, + false, + ); + } + + // Same-URL server actions still return their action value even if the UI + // update was skipped due to a superseding navigation. That preserves the + // existing caller contract; a future Phase 2 router state model could make + // skipped UI updates observable to the caller without conflating them here. + if (returnValue) { + if (!returnValue.ok) { + throw returnValue.data; + } + return returnValue.data; + } + + return undefined; +} + function BrowserRoot({ - initialNode, + initialElements, initialNavigationSnapshot, }: { - initialNode: ReactNode | Promise; + initialElements: Promise; initialNavigationSnapshot: ClientNavigationRenderSnapshot; }) { - const resolvedNode = use(initialNode as Promise); - const [treeState, setTreeState] = useState({ - renderId: 0, - node: resolvedNode, + const resolvedElements = use(initialElements); + const initialMetadata = readAppElementsMetadata(resolvedElements); + const [treeState, dispatchTreeState] = useReducer(routerReducer, { + elements: resolvedElements, navigationSnapshot: initialNavigationSnapshot, + renderId: 0, + rootLayoutTreePath: initialMetadata.rootLayoutTreePath, + routeId: initialMetadata.routeId, }); - // Assign the module-level setter via useLayoutEffect instead of during render - // to avoid side effects that React Strict Mode / concurrent features may - // call multiple times. useLayoutEffect fires synchronously during commit, - // before hydrateRoot returns to main(), so setBrowserTreeState is available - // before __VINEXT_RSC_NAVIGATE__ is assigned. setTreeState is referentially - // stable so the effect only runs on mount. + // Keep the latest router state in a ref so external callers (navigate(), + // server actions, HMR) always read the current state. Safe: those readers + // run from events/effects, never from React render itself. + // Note: stateRef.current is written during render, not in an effect, to + // avoid a stale-read window between commit and layout effects. This mirrors + // the same render-phase ref update pattern used by Next.js's own router. + const stateRef = useRef(treeState); + stateRef.current = treeState; + + // Publish the stable ref object and dispatch during layout commit. This keeps + // the module-level escape hatches aligned with React's committed tree without + // performing module writes during render. __VINEXT_RSC_NAVIGATE__ is assigned + // after hydrateRoot() returns; by then this layout effect has already run for + // the hydration commit, so getBrowserRouterState() never observes a null ref. useLayoutEffect(() => { - setBrowserTreeState = setTreeState; - }, []); // eslint-disable-line react-hooks/exhaustive-deps -- setTreeState is referentially stable + dispatchBrowserRouterAction = dispatchTreeState; + browserRouterStateRef = stateRef; + return () => { + if (dispatchBrowserRouterAction === dispatchTreeState) { + dispatchBrowserRouterAction = null; + } + if (browserRouterStateRef === stateRef) { + browserRouterStateRef = null; + } + }; + }, [dispatchTreeState]); const committedTree = createElement( NavigationCommitSignal, { renderId: treeState.renderId }, - treeState.node, + createElement( + ElementsContext.Provider, + { value: treeState.elements }, + createElement(Slot, { id: treeState.routeId }), + ), ); const ClientNavigationRenderContext = getClientNavigationRenderContext(); @@ -328,83 +435,104 @@ function BrowserRoot({ ); } -function updateBrowserTree( - node: ReactNode | Promise, +function dispatchBrowserTree( + elements: AppElements, navigationSnapshot: ClientNavigationRenderSnapshot, renderId: number, + actionType: "navigate" | "replace", + routeId: string, + rootLayoutTreePath: string | null, useTransitionMode: boolean, - snapshotActivated = false, ): void { - const setter = getBrowserTreeStateSetter(); - - const resolvedThenSet = (resolvedNode: ReactNode) => { - setter({ renderId, node: resolvedNode, navigationSnapshot }); - }; - - // Balance the activate/commit pairing if the async payload rejects after - // activateNavigationSnapshot() was called. Only decrement when snapshotActivated - // is true — server action callers skip renderNavigationPayload entirely and - // never call activateNavigationSnapshot(), so decrementing there would corrupt - // the counter for any concurrent RSC navigation. - const handleAsyncError = () => { - pendingNavigationPrePaintEffects.delete(renderId); - const resolve = pendingNavigationCommits.get(renderId); - pendingNavigationCommits.delete(renderId); - if (snapshotActivated) { - commitClientNavigationState(); - } - resolve?.(); - }; - - if (node != null && typeof (node as PromiseLike).then === "function") { - const thenable = node as PromiseLike; - if (useTransitionMode) { - void thenable.then( - (resolved) => startTransition(() => resolvedThenSet(resolved)), - handleAsyncError, - ); - } else { - void thenable.then(resolvedThenSet, handleAsyncError); - } - return; - } + const dispatch = getBrowserRouterDispatch(); + + const applyAction = () => + dispatch({ + elements, + navigationSnapshot, + renderId, + rootLayoutTreePath, + routeId, + type: actionType, + }); - const syncNode = node as ReactNode; if (useTransitionMode) { - startTransition(() => resolvedThenSet(syncNode)); - return; + startTransition(applyAction); + } else { + applyAction(); } - - resolvedThenSet(syncNode); } -function renderNavigationPayload( - payload: Promise | ReactNode, +async function renderNavigationPayload( + payload: Promise, navigationSnapshot: ClientNavigationRenderSnapshot, + targetHref: string, + navId: number, prePaintEffect: (() => void) | null = null, useTransition = true, + actionType: "navigate" | "replace" = "navigate", ): Promise { const renderId = ++nextNavigationRenderId; - queuePrePaintNavigationEffect(renderId, prePaintEffect); - const committed = new Promise((resolve) => { pendingNavigationCommits.set(renderId, resolve); }); - activateNavigationSnapshot(); - - // Wrap updateBrowserTree in try-catch to ensure counter is decremented - // if a synchronous error occurs before the async promise chain is established. + let snapshotActivated = false; try { - updateBrowserTree(payload, navigationSnapshot, renderId, useTransition, true); + const currentState = getBrowserRouterState(); + const pending = await createPendingNavigationCommit({ + currentState, + nextElements: payload, + navigationSnapshot, + renderId, + type: actionType, + }); + + const disposition = resolvePendingNavigationCommitDisposition({ + activeNavigationId, + currentRootLayoutTreePath: currentState.rootLayoutTreePath, + nextRootLayoutTreePath: pending.rootLayoutTreePath, + startedNavigationId: navId, + }); + + if (disposition === "skip") { + const resolve = pendingNavigationCommits.get(renderId); + pendingNavigationCommits.delete(renderId); + resolve?.(); + return; + } + + if (disposition === "hard-navigate") { + pendingNavigationCommits.delete(renderId); + window.location.assign(targetHref); + return; + } + + queuePrePaintNavigationEffect(renderId, prePaintEffect); + activateNavigationSnapshot(); + snapshotActivated = true; + dispatchBrowserTree( + pending.action.elements, + navigationSnapshot, + renderId, + actionType, + pending.routeId, + pending.rootLayoutTreePath, + useTransition, + ); } catch (error) { - // Clean up pending state and decrement counter on synchronous error. + // Clean up pending state on error. Only decrement the snapshot counter + // if activateNavigationSnapshot() was actually called — if + // createPendingNavigationCommit() threw, the counter was never + // incremented so decrementing would underflow it. pendingNavigationPrePaintEffects.delete(renderId); const resolve = pendingNavigationCommits.get(renderId); pendingNavigationCommits.delete(renderId); - commitClientNavigationState(); + if (snapshotActivated) { + commitClientNavigationState(); + } resolve?.(); - throw error; // Re-throw to maintain error propagation + throw error; } return committed; @@ -534,41 +662,25 @@ function registerServerActionCallback(): void { clearClientNavigationCaches(); - const result = await createFromFetch( + const result = await createFromFetch( Promise.resolve(fetchResponse), { temporaryReferences }, ); - // Note: Server actions update the tree via updateBrowserTree directly (not - // renderNavigationPayload) because they stay on the same URL. This means - // activateNavigationSnapshot is not called, so hooks use useSyncExternalStore - // values directly. snapshotActivated is intentionally omitted (defaults false) - // so handleAsyncError skips commitClientNavigationState() — decrementing an - // unincremented counter would corrupt it for concurrent RSC navigations. - // If server actions ever trigger URL changes via RSC payload (instead of hard - // redirects), this would need renderNavigationPayload() + snapshotActivated=true. + // Server actions stay on the same URL and use commitSameUrlNavigatePayload() + // for merge-based dispatch. This path does not call + // activateNavigationSnapshot() because there is no URL change to commit, so + // hooks continue reading the live external-store values directly. If server + // actions ever trigger URL changes via RSC payload (instead of hard + // redirects), this would need renderNavigationPayload(). if (isServerActionResult(result)) { - updateBrowserTree( - result.root, - createClientNavigationRenderSnapshot(window.location.href, latestClientParams), - ++nextNavigationRenderId, - false, + return commitSameUrlNavigatePayload( + Promise.resolve(normalizeAppElements(result.root)), + result.returnValue, ); - if (result.returnValue) { - if (!result.returnValue.ok) throw result.returnValue.data; - return result.returnValue.data; - } - return undefined; } - // Same reasoning as above: snapshotActivated omitted intentionally. - updateBrowserTree( - result, - createClientNavigationRenderSnapshot(window.location.href, latestClientParams), - ++nextNavigationRenderId, - false, - ); - return result; + return commitSameUrlNavigatePayload(Promise.resolve(normalizeAppElements(result))); }); } @@ -576,7 +688,7 @@ async function main(): Promise { registerServerActionCallback(); const rscStream = await readInitialRscStream(); - const root = createFromReadableStream(rscStream); + const root = normalizeAppElementsPromise(createFromReadableStream(rscStream)); const initialNavigationSnapshot = createClientNavigationRenderSnapshot( window.location.href, latestClientParams, @@ -585,7 +697,7 @@ async function main(): Promise { window.__VINEXT_RSC_ROOT__ = hydrateRoot( document, createElement(BrowserRoot, { - initialNode: root, + initialElements: root, initialNavigationSnapshot, }), import.meta.env.DEV ? { onCaughtError() {} } : undefined, @@ -625,8 +737,6 @@ async function main(): Promise { stripBasePath(url.pathname, __basePath) === stripBasePath(window.location.pathname, __basePath); const cachedRoute = getVisitedResponse(rscUrl, navigationKind); - const navigationCommitEffect = createNavigationCommitEffect(href, historyUpdateMode); - if (cachedRoute) { // Check stale-navigation before and after createFromFetch. The pre-check // avoids wasted parse work; the post-check catches supersessions that @@ -642,23 +752,20 @@ async function main(): Promise { // wrapping only) — no stale-navigation recheck needed between here and the // next await. const cachedNavigationSnapshot = createClientNavigationRenderSnapshot(href, cachedParams); - const cachedPayload = await createFromFetch( - Promise.resolve(restoreRscResponse(cachedRoute.response)), + const cachedPayload = normalizeAppElementsPromise( + createFromFetch( + Promise.resolve(restoreRscResponse(cachedRoute.response)), + ), ); if (navId !== activeNavigationId) return; - // Stage params only after confirming this navigation hasn't been superseded. - // Set _snapshotPending before stageClientParams: if renderNavigationPayload - // throws synchronously, its inner catch calls commitClientNavigationState() - // which would flush pendingClientParams for a route that never rendered. - // Ordering _snapshotPending first makes the intent explicit — params are - // staged as part of an in-flight snapshot, not as a standalone side-effect. _snapshotPending = true; // Set before renderNavigationPayload - stageClientParams(cachedParams); // NB: if this throws, outer catch hard-navigates, resetting all JS state try { await renderNavigationPayload( cachedPayload, cachedNavigationSnapshot, - navigationCommitEffect, + href, + navId, + createNavigationCommitEffect(href, historyUpdateMode, cachedParams), isSameRoute, ); } finally { @@ -726,23 +833,20 @@ async function main(): Promise { if (navId !== activeNavigationId) return; - const rscPayload = await createFromFetch( - Promise.resolve(restoreRscResponse(responseSnapshot)), + const rscPayload = normalizeAppElementsPromise( + createFromFetch(Promise.resolve(restoreRscResponse(responseSnapshot))), ); if (navId !== activeNavigationId) return; - // Stage params only after confirming this navigation hasn't been superseded - // (avoids stale cache entries). Set _snapshotPending before stageClientParams - // for the same reason as the cached path above: ensures params are only staged - // as part of an in-flight snapshot. _snapshotPending = true; // Set before renderNavigationPayload - stageClientParams(navParams); // NB: if this throws, outer catch hard-navigates, resetting all JS state try { await renderNavigationPayload( rscPayload, navigationSnapshot, - navigationCommitEffect, + href, + navId, + createNavigationCommitEffect(href, historyUpdateMode, navParams), isSameRoute, ); } finally { @@ -753,6 +857,9 @@ async function main(): Promise { // catch from double-decrementing navigationSnapshotActiveCount. _snapshotPending = false; } + // Don't cache the response if this navigation was superseded during + // renderNavigationPayload's await — the elements were never dispatched. + if (navId !== activeNavigationId) return; // Store the visited response only after renderNavigationPayload succeeds. // If we stored it before and renderNavigationPayload threw, a future // back/forward navigation could replay a snapshot from a navigation that @@ -801,14 +908,28 @@ async function main(): Promise { import.meta.hot.on("rsc:update", async () => { try { clearClientNavigationCaches(); - const rscPayload = await createFromFetch( - fetch(toRscUrl(window.location.pathname + window.location.search)), + const navigationSnapshot = createClientNavigationRenderSnapshot( + window.location.href, + latestClientParams, ); - // HMR updates skip renderNavigationPayload — no snapshot activated. - updateBrowserTree( - rscPayload, - createClientNavigationRenderSnapshot(window.location.href, latestClientParams), - ++nextNavigationRenderId, + const pending = await createPendingNavigationCommit({ + currentState: getBrowserRouterState(), + nextElements: normalizeAppElementsPromise( + createFromFetch( + fetch(toRscUrl(window.location.pathname + window.location.search)), + ), + ), + navigationSnapshot, + renderId: ++nextNavigationRenderId, + type: "replace", + }); + dispatchBrowserTree( + pending.action.elements, + navigationSnapshot, + pending.action.renderId, + "replace", + pending.routeId, + pending.rootLayoutTreePath, false, ); } catch (error) { @@ -818,4 +939,6 @@ async function main(): Promise { } } -void main(); +if (typeof document !== "undefined") { + void main(); +} diff --git a/packages/vinext/src/server/app-browser-state.ts b/packages/vinext/src/server/app-browser-state.ts new file mode 100644 index 00000000..672a569d --- /dev/null +++ b/packages/vinext/src/server/app-browser-state.ts @@ -0,0 +1,140 @@ +import { mergeElements } from "../shims/slot.js"; +import { readAppElementsMetadata, type AppElements } from "./app-elements.js"; +import type { ClientNavigationRenderSnapshot } from "../shims/navigation.js"; + +export type AppRouterState = { + elements: AppElements; + renderId: number; + navigationSnapshot: ClientNavigationRenderSnapshot; + rootLayoutTreePath: string | null; + routeId: string; +}; + +export type AppRouterAction = { + elements: AppElements; + navigationSnapshot: ClientNavigationRenderSnapshot; + renderId: number; + rootLayoutTreePath: string | null; + routeId: string; + type: "navigate" | "replace"; +}; + +export type PendingNavigationCommit = { + action: AppRouterAction; + rootLayoutTreePath: string | null; + routeId: string; +}; + +export type PendingNavigationCommitDisposition = "dispatch" | "hard-navigate" | "skip"; +export type ClassifiedPendingNavigationCommit = { + disposition: PendingNavigationCommitDisposition; + pending: PendingNavigationCommit; +}; + +export function routerReducer(state: AppRouterState, action: AppRouterAction): AppRouterState { + switch (action.type) { + case "navigate": + return { + elements: mergeElements(state.elements, action.elements), + navigationSnapshot: action.navigationSnapshot, + renderId: action.renderId, + rootLayoutTreePath: action.rootLayoutTreePath, + routeId: action.routeId, + }; + case "replace": + return { + elements: action.elements, + navigationSnapshot: action.navigationSnapshot, + renderId: action.renderId, + rootLayoutTreePath: action.rootLayoutTreePath, + routeId: action.routeId, + }; + default: { + const _exhaustive: never = action.type; + throw new Error("[vinext] Unknown router action: " + String(_exhaustive)); + } + } +} + +export function shouldHardNavigate( + currentRootLayoutTreePath: string | null, + nextRootLayoutTreePath: string | null, +): boolean { + // `null` means the payload could not identify an enclosing root layout + // boundary. Treat that as soft-navigation compatible so fallback payloads + // do not force a hard reload purely because metadata is absent. + return ( + currentRootLayoutTreePath !== null && + nextRootLayoutTreePath !== null && + currentRootLayoutTreePath !== nextRootLayoutTreePath + ); +} + +export function resolvePendingNavigationCommitDisposition(options: { + activeNavigationId: number; + currentRootLayoutTreePath: string | null; + nextRootLayoutTreePath: string | null; + startedNavigationId: number; +}): PendingNavigationCommitDisposition { + if (options.startedNavigationId !== options.activeNavigationId) { + return "skip"; + } + + if (shouldHardNavigate(options.currentRootLayoutTreePath, options.nextRootLayoutTreePath)) { + return "hard-navigate"; + } + + return "dispatch"; +} + +export async function createPendingNavigationCommit(options: { + currentState: AppRouterState; + nextElements: Promise; + navigationSnapshot: ClientNavigationRenderSnapshot; + renderId: number; + type: "navigate" | "replace"; +}): Promise { + const elements = await options.nextElements; + const metadata = readAppElementsMetadata(elements); + + return { + action: { + elements, + navigationSnapshot: options.navigationSnapshot, + renderId: options.renderId, + rootLayoutTreePath: metadata.rootLayoutTreePath, + routeId: metadata.routeId, + type: options.type, + }, + rootLayoutTreePath: metadata.rootLayoutTreePath, + routeId: metadata.routeId, + }; +} + +export async function resolveAndClassifyNavigationCommit(options: { + activeNavigationId: number; + currentState: AppRouterState; + navigationSnapshot: ClientNavigationRenderSnapshot; + nextElements: Promise; + renderId: number; + startedNavigationId: number; + type: "navigate" | "replace"; +}): Promise { + const pending = await createPendingNavigationCommit({ + currentState: options.currentState, + nextElements: options.nextElements, + navigationSnapshot: options.navigationSnapshot, + renderId: options.renderId, + type: options.type, + }); + + return { + disposition: resolvePendingNavigationCommitDisposition({ + activeNavigationId: options.activeNavigationId, + currentRootLayoutTreePath: options.currentState.rootLayoutTreePath, + nextRootLayoutTreePath: pending.rootLayoutTreePath, + startedNavigationId: options.startedNavigationId, + }), + pending, + }; +} diff --git a/packages/vinext/src/server/app-elements.ts b/packages/vinext/src/server/app-elements.ts new file mode 100644 index 00000000..250b2eae --- /dev/null +++ b/packages/vinext/src/server/app-elements.ts @@ -0,0 +1,60 @@ +import type { ReactNode } from "react"; + +export const APP_ROUTE_KEY = "__route"; +export const APP_ROOT_LAYOUT_KEY = "__rootLayout"; +export const APP_UNMATCHED_SLOT_WIRE_VALUE = "__VINEXT_UNMATCHED_SLOT__"; + +export const UNMATCHED_SLOT = Symbol.for("vinext.unmatchedSlot"); + +export type AppElementValue = ReactNode | typeof UNMATCHED_SLOT | string | null; +export type AppWireElementValue = ReactNode | string | null; + +export type AppElements = Readonly>; +export type AppWireElements = Readonly>; + +export type AppElementsMetadata = { + routeId: string; + rootLayoutTreePath: string | null; +}; + +export function normalizeAppElements(elements: AppWireElements): AppElements { + let needsNormalization = false; + for (const [key, value] of Object.entries(elements)) { + if (key.startsWith("slot:") && value === APP_UNMATCHED_SLOT_WIRE_VALUE) { + needsNormalization = true; + break; + } + } + + if (!needsNormalization) { + return elements; + } + + const normalized: Record = {}; + for (const [key, value] of Object.entries(elements)) { + normalized[key] = + key.startsWith("slot:") && value === APP_UNMATCHED_SLOT_WIRE_VALUE ? UNMATCHED_SLOT : value; + } + + return normalized; +} + +export function readAppElementsMetadata(elements: AppElements): AppElementsMetadata { + const routeId = elements[APP_ROUTE_KEY]; + if (typeof routeId !== "string") { + throw new Error("[vinext] Missing __route string in App Router payload"); + } + + const rootLayoutTreePath = elements[APP_ROOT_LAYOUT_KEY]; + if (rootLayoutTreePath === undefined) { + throw new Error("[vinext] Missing __rootLayout key in App Router payload"); + } + if (rootLayoutTreePath !== null && typeof rootLayoutTreePath !== "string") { + throw new Error("[vinext] Invalid __rootLayout in App Router payload: expected string or null"); + } + + return { + routeId, + rootLayoutTreePath, + }; +} diff --git a/packages/vinext/src/server/app-page-boundary-render.ts b/packages/vinext/src/server/app-page-boundary-render.ts index 1aca237a..2982acb6 100644 --- a/packages/vinext/src/server/app-page-boundary-render.ts +++ b/packages/vinext/src/server/app-page-boundary-render.ts @@ -24,6 +24,8 @@ import { renderAppPageHtmlResponse, type AppPageSsrHandler, } from "./app-page-stream.js"; +import { APP_ROOT_LAYOUT_KEY, APP_ROUTE_KEY, type AppElements } from "./app-elements.js"; +import { createAppPageLayoutEntries } from "./app-page-route-wiring.js"; // oxlint-disable-next-line @typescript-eslint/no-explicit-any type AppPageComponent = ComponentType; @@ -36,6 +38,13 @@ type AppPageBoundaryOnError = ( errorContext: unknown, ) => unknown; +type AppPageBoundaryRscPayloadOptions = { + element: ReactNode; + layoutModules: readonly (TModule | null | undefined)[]; + pathname: string; + route?: AppPageBoundaryRoute | null; +}; + export type AppPageBoundaryRoute = { error?: TModule | null; errors?: readonly (TModule | null | undefined)[] | null; @@ -62,7 +71,7 @@ type AppPageBoundaryRenderCommonOptions Promise; makeThenableParams: (params: AppPageParams) => unknown; renderToReadableStream: ( - element: ReactNode, + element: ReactNode | AppElements, options: { onError: AppPageBoundaryOnError }, ) => ReadableStream; requestUrl: string; @@ -200,14 +209,58 @@ function wrapRenderedBoundaryElement( }); } +function resolveAppPageBoundaryRootLayoutTreePath( + route: AppPageBoundaryRoute | null | undefined, + layoutModules: readonly (TModule | null | undefined)[], +): string | null { + if (route?.layouts) { + const rootLayoutEntry = createAppPageLayoutEntries({ + errors: route.errors, + layoutTreePositions: route.layoutTreePositions, + layouts: route.layouts, + notFounds: null, + routeSegments: route.routeSegments, + })[0]; + + if (rootLayoutEntry) { + return rootLayoutEntry.treePath; + } + } + + return layoutModules.length > 0 ? "/" : null; +} + +function createAppPageBoundaryRscPayload( + options: AppPageBoundaryRscPayloadOptions, +): AppElements { + const routeId = `route:${options.pathname}`; + + return { + [APP_ROUTE_KEY]: routeId, + [APP_ROOT_LAYOUT_KEY]: resolveAppPageBoundaryRootLayoutTreePath( + options.route, + options.layoutModules, + ), + [routeId]: options.element, + }; +} + async function renderAppPageBoundaryElementResponse( options: AppPageBoundaryRenderCommonOptions & { element: ReactNode; + layoutModules: readonly (TModule | null | undefined)[]; + route?: AppPageBoundaryRoute | null; routePattern?: string; status: number; }, ): Promise { const pathname = new URL(options.requestUrl).pathname; + const payload = createAppPageBoundaryRscPayload({ + element: options.element, + layoutModules: options.layoutModules, + pathname, + route: options.route, + }); return renderAppPageBoundaryResponse({ async createHtmlResponse(rscStream, responseStatus) { @@ -230,7 +283,7 @@ async function renderAppPageBoundaryElementResponse( return renderAppPageBoundaryElementResponse({ ...options, element, + layoutModules, + route: options.route, routePattern: options.route?.pattern, status: 200, }); diff --git a/packages/vinext/src/server/app-page-render.ts b/packages/vinext/src/server/app-page-render.ts index 8591cae7..ed66bf72 100644 --- a/packages/vinext/src/server/app-page-render.ts +++ b/packages/vinext/src/server/app-page-render.ts @@ -84,14 +84,14 @@ export type RenderAppPageLifecycleOptions = { ) => Promise; renderPageSpecialError: (specialError: AppPageSpecialError) => Promise; renderToReadableStream: ( - element: ReactNode, + element: ReactNode | Record, options: { onError: AppPageBoundaryOnError }, ) => ReadableStream; routeHasLocalBoundary: boolean; routePattern: string; runWithSuppressedHookWarning(probe: () => Promise): Promise; waitUntil?: (promise: Promise) => void; - element: ReactNode; + element: ReactNode | Record; }; function buildResponseTiming( diff --git a/packages/vinext/src/server/app-page-request.ts b/packages/vinext/src/server/app-page-request.ts index 2e7c68f5..b520c18e 100644 --- a/packages/vinext/src/server/app-page-request.ts +++ b/packages/vinext/src/server/app-page-request.ts @@ -40,10 +40,9 @@ export type ResolveAppPageInterceptOptions = { cleanPathname: string; currentRoute: TRoute; findIntercept: (pathname: string) => AppPageInterceptMatch | null; - getRoutePattern: (route: TRoute) => string; + getRouteParamNames: (route: TRoute) => readonly string[]; getSourceRoute: (sourceRouteIndex: number) => TRoute | undefined; isRscRequest: boolean; - matchSourceRouteParams: (pattern: string) => AppPageParams | null; renderInterceptResponse: (route: TRoute, element: unknown) => Promise | Response; searchParams: URLSearchParams; setNavigationContext: (context: { @@ -59,6 +58,22 @@ export type ResolveAppPageInterceptResult = { response: Response | null; }; +function pickRouteParams( + matchedParams: AppPageParams, + routeParamNames: readonly string[], +): AppPageParams { + const params: AppPageParams = {}; + + for (const paramName of routeParamNames) { + const value = matchedParams[paramName]; + if (value !== undefined) { + params[paramName] = value; + } + } + + return params; +} + function areStaticParamsAllowed( params: AppPageParams, staticParams: readonly Record[], @@ -133,8 +148,10 @@ export async function resolveAppPageIntercept( const interceptOpts = options.toInterceptOpts(intercept); if (sourceRoute && sourceRoute !== options.currentRoute) { - const sourceParams = - options.matchSourceRouteParams(options.getRoutePattern(sourceRoute)) ?? ({} as AppPageParams); + const sourceParams = pickRouteParams( + intercept.matchedParams, + options.getRouteParamNames(sourceRoute), + ); options.setNavigationContext({ params: intercept.matchedParams, pathname: options.cleanPathname, diff --git a/packages/vinext/src/server/app-page-route-wiring.tsx b/packages/vinext/src/server/app-page-route-wiring.tsx index 0ef76598..0a0ba07d 100644 --- a/packages/vinext/src/server/app-page-route-wiring.tsx +++ b/packages/vinext/src/server/app-page-route-wiring.tsx @@ -1,8 +1,21 @@ import { Suspense, type ComponentType, type ReactNode } from "react"; +import { + APP_ROOT_LAYOUT_KEY, + APP_ROUTE_KEY, + APP_UNMATCHED_SLOT_WIRE_VALUE, + type AppElements, +} from "./app-elements.js"; import { ErrorBoundary, NotFoundBoundary } from "../shims/error-boundary.js"; import { LayoutSegmentProvider } from "../shims/layout-segment-context.js"; import { MetadataHead, ViewportHead, type Metadata, type Viewport } from "../shims/metadata.js"; +import { Children, ParallelSlot, Slot } from "../shims/slot.js"; import type { AppPageParams } from "./app-page-boundary.js"; +import { + createAppRenderDependency, + renderAfterAppDependencies, + renderWithAppDependencyBarrier, + type AppRenderDependency, +} from "./app-render-dependency.js"; type AppPageComponentProps = { children?: ReactNode; @@ -32,11 +45,6 @@ export type AppPageRouteWiringSlot< layoutIndex: number; loading?: TModule | null; page?: TModule | null; - /** - * Filesystem segments from the slot's root to its active page. - * Used to populate the LayoutSegmentProvider's segmentMap for this slot. - * null when the slot has no active page (showing default.tsx fallback). - */ routeSegments?: readonly string[] | null; }; @@ -53,6 +61,7 @@ export type AppPageRouteWiringRoute< notFounds?: readonly (TModule | null | undefined)[] | null; routeSegments?: readonly string[]; slots?: Readonly>> | null; + templateTreePositions?: readonly number[] | null; templates?: readonly (TModule | null | undefined)[] | null; }; @@ -89,6 +98,20 @@ export type BuildAppPageRouteElementOptions< slotOverrides?: Readonly>> | null; }; +export type BuildAppPageElementsOptions< + TModule extends AppPageModule = AppPageModule, + TErrorModule extends AppPageErrorModule = AppPageErrorModule, +> = BuildAppPageRouteElementOptions & { + routePath: string; +}; + +type AppPageTemplateEntry = { + id: string; + templateModule?: TModule | null | undefined; + treePath: string; + treePosition: number; +}; + function getDefaultExport( module: TModule | null | undefined, ): AppPageComponent | null { @@ -135,10 +158,28 @@ export function createAppPageLayoutEntries< }); } +export function createAppPageTemplateEntries( + route: Pick< + AppPageRouteWiringRoute, + "routeSegments" | "templateTreePositions" | "templates" + >, +): AppPageTemplateEntry[] { + return (route.templates ?? []).map((templateModule, index) => { + const treePosition = route.templateTreePositions?.[index] ?? 0; + const treePath = createAppPageTreePath(route.routeSegments, treePosition); + return { + id: `template:${treePath}`, + templateModule, + treePath, + treePosition, + }; + }); +} + export function resolveAppPageChildSegments( routeSegments: readonly string[], treePosition: number, - params: Readonly>, + params: AppPageParams, ): string[] { const rawSegments = routeSegments.slice(treePosition); const resolvedSegments: string[] = []; @@ -147,7 +188,7 @@ export function resolveAppPageChildSegments( if ( segment.startsWith("[[...") && segment.endsWith("]]") && - segment.length >= "[[...x]]".length + segment.length > "[[...x]]".length - 1 ) { const paramName = segment.slice(5, -2); const paramValue = params[paramName]; @@ -187,74 +228,156 @@ export function resolveAppPageChildSegments( return resolvedSegments; } -export function buildAppPageRouteElement< +function resolveAppPageVisibleSegments( + routeSegments: readonly string[], + params: AppPageParams, +): string[] { + const resolvedSegments = resolveAppPageChildSegments(routeSegments, 0, params); + return resolvedSegments.filter((segment) => !(segment.startsWith("(") && segment.endsWith(")"))); +} + +function resolveAppPageTemplateKey( + routeSegments: readonly string[], + treePosition: number, + params: AppPageParams, +): string { + const visibleSegments = resolveAppPageVisibleSegments(routeSegments.slice(treePosition), params); + return visibleSegments[0] ?? ""; +} + +function createAppPageParallelSlotEntries< TModule extends AppPageModule, TErrorModule extends AppPageErrorModule, ->(options: BuildAppPageRouteElementOptions): ReactNode { - let element: ReactNode = ( - {options.element} - ); +>( + layoutIndex: number, + layoutEntries: readonly AppPageLayoutEntry[], + route: AppPageRouteWiringRoute, + params: AppPageParams, +): Readonly> | undefined { + const parallelSlots: Record = {}; + + for (const [slotName, slot] of Object.entries(route.slots ?? {})) { + const targetIndex = slot.layoutIndex >= 0 ? slot.layoutIndex : layoutEntries.length - 1; + if (targetIndex !== layoutIndex) { + continue; + } + + const layoutEntry = layoutEntries[targetIndex]; + const treePath = layoutEntry?.treePath ?? "/"; + const slotSegments = slot.routeSegments + ? resolveAppPageChildSegments(slot.routeSegments, 0, params) + : []; + parallelSlots[slotName] = ( + + + + ); + } - element = ( + return Object.keys(parallelSlots).length > 0 ? parallelSlots : undefined; +} + +function createAppPageRouteHead(metadata: Metadata | null, viewport: Viewport): ReactNode { + return ( <> - {options.resolvedMetadata ? : null} - - {element} + {metadata ? : null} + ); +} - const loadingComponent = getDefaultExport(options.route.loading); - if (loadingComponent) { - const LoadingComponent = loadingComponent; - element = }>{element}; +export function buildAppPageElements< + TModule extends AppPageModule, + TErrorModule extends AppPageErrorModule, +>(options: BuildAppPageElementsOptions): AppElements { + const elements: Record = {}; + const routeId = `route:${options.routePath}`; + const pageId = `page:${options.routePath}`; + const layoutEntries = createAppPageLayoutEntries(options.route); + const templateEntries = createAppPageTemplateEntries(options.route); + const layoutEntriesByTreePosition = new Map>(); + const templateEntriesByTreePosition = new Map>(); + for (const layoutEntry of layoutEntries) { + layoutEntriesByTreePosition.set(layoutEntry.treePosition, layoutEntry); } - - 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}; + for (const templateEntry of templateEntries) { + templateEntriesByTreePosition.set(templateEntry.treePosition, templateEntry); } - - const notFoundComponent = - getDefaultExport(options.route.notFound) ?? getDefaultExport(options.rootNotFoundModule); - if (notFoundComponent) { - const NotFoundComponent = notFoundComponent; - element = }>{element}; + const layoutIndicesByTreePosition = new Map(); + for (let index = 0; index < layoutEntries.length; index++) { + layoutIndicesByTreePosition.set(layoutEntries[index].treePosition, index); } - - const templates = options.route.templates ?? []; - const routeSlots = options.route.slots ?? {}; - const layoutEntries = createAppPageLayoutEntries(options.route); + const layoutDependenciesByIndex = new Map(); + const layoutDependenciesBefore: AppRenderDependency[][] = []; + const slotDependenciesByLayoutIndex: AppRenderDependency[][] = []; + const templateDependenciesById = new Map(); + const templateDependenciesBeforeById = new Map(); + const pageDependencies: AppRenderDependency[] = []; const routeThenableParams = options.makeThenableParams(options.matchedParams); - - for (let index = layoutEntries.length - 1; index >= 0; index--) { - const layoutEntry = layoutEntries[index]; - - // Next.js nesting per segment (outer to inner): Layout > Template > Error > NotFound > children - // Building bottom-up: NotFoundBoundary is the innermost wrapper, then ErrorBoundary, then Template. - const layoutNotFoundComponent = getDefaultExport(layoutEntry.notFoundModule); - if (layoutNotFoundComponent) { - const LayoutNotFoundComponent = layoutNotFoundComponent; - element = ( - }>{element} - ); + const rootLayoutTreePath = layoutEntries[0]?.treePath ?? null; + const orderedTreePositions = Array.from( + new Set([ + ...layoutEntries.map((entry) => entry.treePosition), + ...templateEntries.map((entry) => entry.treePosition), + ]), + ).sort((left, right) => left - right); + + for (const treePosition of orderedTreePositions) { + const layoutIndex = layoutIndicesByTreePosition.get(treePosition); + if (layoutIndex !== undefined) { + const layoutEntry = layoutEntries[layoutIndex]; + layoutDependenciesBefore[layoutIndex] = [...pageDependencies]; + if (getDefaultExport(layoutEntry.layoutModule)) { + const layoutDependency = createAppRenderDependency(); + layoutDependenciesByIndex.set(layoutIndex, layoutDependency); + pageDependencies.push(layoutDependency); + } + slotDependenciesByLayoutIndex[layoutIndex] = [...pageDependencies]; } - const layoutErrorComponent = getErrorBoundaryExport(layoutEntry.errorModule); - if (layoutErrorComponent) { - element = {element}; + const templateEntry = templateEntriesByTreePosition.get(treePosition); + if (!templateEntry || !getDefaultExport(templateEntry.templateModule)) { + continue; } - const templateComponent = getDefaultExport(templates[index]); - if (templateComponent) { - const TemplateComponent = templateComponent; - element = {element}; + const templateDependency = createAppRenderDependency(); + templateDependenciesById.set(templateEntry.id, templateDependency); + templateDependenciesBeforeById.set(templateEntry.id, [...pageDependencies]); + pageDependencies.push(templateDependency); + } + + elements[APP_ROUTE_KEY] = routeId; + elements[APP_ROOT_LAYOUT_KEY] = rootLayoutTreePath; + elements[pageId] = renderAfterAppDependencies(options.element, pageDependencies); + + for (const templateEntry of templateEntries) { + const templateComponent = getDefaultExport(templateEntry.templateModule); + if (!templateComponent) { + continue; } + const TemplateComponent = templateComponent; + const templateDependency = templateDependenciesById.get(templateEntry.id); + const templateElement = templateDependency ? ( + renderWithAppDependencyBarrier( + + + , + templateDependency, + ) + ) : ( + + + + ); + elements[templateEntry.id] = renderAfterAppDependencies( + templateElement, + templateDependenciesBeforeById.get(templateEntry.id) ?? [], + ); + } + for (let index = 0; index < layoutEntries.length; index++) { + const layoutEntry = layoutEntries[index]; const layoutComponent = getDefaultExport(layoutEntry.layoutModule); if (!layoutComponent) { continue; @@ -264,62 +387,166 @@ export function buildAppPageRouteElement< params: routeThenableParams, }; - for (const [slotName, slot] of Object.entries(routeSlots)) { + for (const [slotName, slot] of Object.entries(options.route.slots ?? {})) { const targetIndex = slot.layoutIndex >= 0 ? slot.layoutIndex : layoutEntries.length - 1; - if (index !== targetIndex) { + if (targetIndex !== index) { continue; } + layoutProps[slotName] = ; + } - 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 LayoutComponent = layoutComponent; + const layoutDependency = layoutDependenciesByIndex.get(index); + const layoutElement = layoutDependency ? ( + renderWithAppDependencyBarrier( + + + , + layoutDependency, + ) + ) : ( + + + + ); + elements[layoutEntry.id] = renderAfterAppDependencies( + layoutElement, + layoutDependenciesBefore[index] ?? [], + ); + } - const slotProps: Record = { - params: options.makeThenableParams(slotParams), - }; - if (slotOverride?.props) { - Object.assign(slotProps, slotOverride.props); - } + for (const [slotName, slot] of Object.entries(options.route.slots ?? {})) { + const targetIndex = slot.layoutIndex >= 0 ? slot.layoutIndex : layoutEntries.length - 1; + const treePath = layoutEntries[targetIndex]?.treePath ?? "/"; + const slotId = `slot:${slotName}:${treePath}`; + const slotOverride = options.slotOverrides?.[slotName]; + const slotParams = slotOverride?.params ?? options.matchedParams; + const slotComponent = + getDefaultExport(slotOverride?.pageModule) ?? + getDefaultExport(slot.page) ?? + getDefaultExport(slot.default); + + if (!slotComponent) { + elements[slotId] = APP_UNMATCHED_SLOT_WIRE_VALUE; + continue; + } + + const slotProps: Record = { + params: options.makeThenableParams(slotParams), + }; + if (slotOverride?.props) { + Object.assign(slotProps, slotOverride.props); + } - const SlotComponent = slotComponent; - let slotElement: ReactNode = ; + const SlotComponent = slotComponent; + let slotElement: ReactNode = ; - const slotLayoutComponent = getDefaultExport(slot.layout); - if (slotLayoutComponent) { - const SlotLayoutComponent = slotLayoutComponent; - slotElement = ( - - {slotElement} - - ); - } + 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 slotLoadingComponent = getDefaultExport(slot.loading); + if (slotLoadingComponent) { + const SlotLoadingComponent = slotLoadingComponent; + slotElement = }>{slotElement}; + } + + const slotErrorComponent = getErrorBoundaryExport(slot.error); + if (slotErrorComponent) { + slotElement = {slotElement}; + } + + elements[slotId] = renderAfterAppDependencies( + slotElement, + targetIndex >= 0 ? (slotDependenciesByLayoutIndex[targetIndex] ?? []) : [], + ); + } + + let routeChildren: ReactNode = ( + + + + ); + + const routeLoadingComponent = getDefaultExport(options.route.loading); + if (routeLoadingComponent) { + const RouteLoadingComponent = routeLoadingComponent; + routeChildren = }>{routeChildren}; + } + + const lastLayoutErrorModule = + options.route.errors && options.route.errors.length > 0 + ? options.route.errors[options.route.errors.length - 1] + : null; + const pageErrorComponent = getErrorBoundaryExport(options.route.error); + if (pageErrorComponent && options.route.error !== lastLayoutErrorModule) { + routeChildren = {routeChildren}; + } + + const notFoundComponent = + getDefaultExport(options.route.notFound) ?? getDefaultExport(options.rootNotFoundModule); + if (notFoundComponent) { + const NotFoundComponent = notFoundComponent; + routeChildren = ( + }>{routeChildren} + ); + } - const slotErrorComponent = getErrorBoundaryExport(slot.error); - if (slotErrorComponent) { - slotElement = {slotElement}; + for (let index = orderedTreePositions.length - 1; index >= 0; index--) { + const treePosition = orderedTreePositions[index]; + let segmentChildren: ReactNode = routeChildren; + const layoutEntry = layoutEntriesByTreePosition.get(treePosition); + const templateEntry = templateEntriesByTreePosition.get(treePosition); + + // Next.js nesting per segment (outer to inner): Layout > Template > Error > NotFound > children. + // Building bottom-up means NotFoundBoundary must wrap the leaf subtree first, + // then ErrorBoundary, then Template, with the Layout slot outermost. + if (layoutEntry) { + const layoutNotFoundComponent = getDefaultExport(layoutEntry.notFoundModule); + if (layoutNotFoundComponent) { + const LayoutNotFoundComponent = layoutNotFoundComponent; + segmentChildren = ( + }> + {segmentChildren} + + ); } - layoutProps[slotName] = slotElement; + const layoutErrorComponent = getErrorBoundaryExport(layoutEntry.errorModule); + if (layoutErrorComponent) { + segmentChildren = ( + {segmentChildren} + ); + } } - const LayoutComponent = layoutComponent; - element = {element}; + if (templateEntry && getDefaultExport(templateEntry.templateModule)) { + segmentChildren = ( + + {segmentChildren} + + ); + } - // Build the segment map for this layout level. The "children" key always - // contains the route segments below this layout. Named parallel slots at - // this layout level add their own keys with per-slot segment data. + if (!layoutEntry) { + routeChildren = segmentChildren; + continue; + } + const layoutHasElement = getDefaultExport(layoutEntry.layoutModule) !== null; + const layoutIndex = layoutIndicesByTreePosition.get(treePosition) ?? -1; const segmentMap: { children: string[] } & Record = { children: resolveAppPageChildSegments( options.route.routeSegments ?? [], @@ -327,30 +554,48 @@ export function buildAppPageRouteElement< options.matchedParams, ), }; - for (const [slotName, slot] of Object.entries(routeSlots)) { + for (const [slotName, slot] of Object.entries(options.route.slots ?? {})) { const targetIndex = slot.layoutIndex >= 0 ? slot.layoutIndex : layoutEntries.length - 1; - if (index !== targetIndex) { + if (targetIndex !== layoutIndex) { continue; } - if (slot.routeSegments) { - // Slot has an active page — resolve its segments (dynamic params → values) - segmentMap[slotName] = resolveAppPageChildSegments( - slot.routeSegments, - 0, // Slot segments are already relative to the slot root - options.matchedParams, - ); - } else { - // Slot is showing default.tsx or has no page — empty segments - segmentMap[slotName] = []; - } + segmentMap[slotName] = slot.routeSegments + ? resolveAppPageChildSegments(slot.routeSegments, 0, options.matchedParams) + : []; } - element = {element}; + + routeChildren = ( + + {layoutHasElement ? ( + + {segmentChildren} + + ) : ( + segmentChildren + )} + + ); } const globalErrorComponent = getErrorBoundaryExport(options.globalErrorModule); if (globalErrorComponent) { - element = {element}; + routeChildren = {routeChildren}; } - return element; + elements[routeId] = ( + <> + {createAppPageRouteHead(options.resolvedMetadata, options.resolvedViewport)} + {routeChildren} + + ); + + return elements; } diff --git a/packages/vinext/src/server/app-render-dependency.tsx b/packages/vinext/src/server/app-render-dependency.tsx new file mode 100644 index 00000000..4476043f --- /dev/null +++ b/packages/vinext/src/server/app-render-dependency.tsx @@ -0,0 +1,65 @@ +import { type ReactNode } from "react"; + +export type AppRenderDependency = { + promise: Promise; + release: () => void; +}; + +export function createAppRenderDependency(): AppRenderDependency { + let released = false; + let resolve!: () => void; + + const promise = new Promise((promiseResolve) => { + resolve = promiseResolve; + }); + + return { + promise, + release() { + if (released) { + return; + } + released = true; + resolve(); + }, + }; +} + +export function renderAfterAppDependencies( + children: ReactNode, + dependencies: readonly AppRenderDependency[], +): ReactNode { + if (dependencies.length === 0) { + return children; + } + + async function AwaitAppRenderDependencies() { + await Promise.all(dependencies.map((dependency) => dependency.promise)); + return children; + } + + return ; +} + +export function renderWithAppDependencyBarrier( + children: ReactNode, + dependency: AppRenderDependency, +): ReactNode { + function ReleaseAppRenderDependency() { + // This render-time release is intentional. The dependency barrier is only + // used inside the RSC render graph, where producing this leaf means the + // owning entry has reached the serialization point that downstream entries + // are allowed to observe. If Phase 2 adds AbortSignal-based render + // timeouts, this dependency will also need an abort/reject path so stuck + // async layouts do not suspend downstream entries forever. + dependency.release(); + return null; + } + + return ( + <> + {children} + + + ); +} diff --git a/packages/vinext/src/server/app-ssr-entry.ts b/packages/vinext/src/server/app-ssr-entry.ts index 32d754c4..f0a50c88 100644 --- a/packages/vinext/src/server/app-ssr-entry.ts +++ b/packages/vinext/src/server/app-ssr-entry.ts @@ -1,7 +1,7 @@ /// import type { ReactNode } from "react"; -import { Fragment, createElement as createReactElement } from "react"; +import { Fragment, createElement as createReactElement, use } from "react"; import { createFromReadableStream } from "@vitejs/plugin-rsc/ssr"; import { renderToReadableStream, renderToStaticMarkup } from "react-dom/server.edge"; import * as clientReferences from "virtual:vite-rsc/client-references"; @@ -16,6 +16,12 @@ import { import { runWithNavigationContext } from "../shims/navigation-state.js"; import { safeJsonStringify } from "./html.js"; import { createRscEmbedTransform, createTickBufferedTransform } from "./app-ssr-stream.js"; +import { + normalizeAppElements, + readAppElementsMetadata, + type AppWireElements, +} from "./app-elements.js"; +import { ElementsContext, Slot } from "../shims/slot.js"; export type FontPreload = { href: string; @@ -167,13 +173,20 @@ export async function handleSsr( const [ssrStream, embedStream] = rscStream.tee(); const rscEmbed = createRscEmbedTransform(embedStream); - let flightRoot: Promise | null = null; + let flightRoot: PromiseLike | null = null; function VinextFlightRoot(): ReactNode { if (!flightRoot) { - flightRoot = createFromReadableStream(ssrStream); + flightRoot = createFromReadableStream(ssrStream); } - return flightRoot as unknown as ReactNode; + const wireElements = use(flightRoot); + const elements = normalizeAppElements(wireElements); + const metadata = readAppElementsMetadata(elements); + return createReactElement( + ElementsContext.Provider, + { value: elements }, + createReactElement(Slot, { id: metadata.routeId }), + ); } const root = createReactElement(VinextFlightRoot); diff --git a/packages/vinext/src/shims/slot.tsx b/packages/vinext/src/shims/slot.tsx index 8cc7b879..fb6d3944 100644 --- a/packages/vinext/src/shims/slot.tsx +++ b/packages/vinext/src/shims/slot.tsx @@ -1,21 +1,20 @@ "use client"; import * as React from "react"; +import { UNMATCHED_SLOT, type AppElementValue, type AppElements } from "../server/app-elements.js"; import { notFound } from "./navigation.js"; -export type Elements = Record; +const EMPTY_ELEMENTS: AppElements = Object.freeze({}); +const warnedMissingEntryIds = new Set(); -// Shared across requests — safe because the resolved value is frozen. -// A Slot rendered outside an ElementsContext.Provider sees {} and returns null for all IDs. -const EMPTY_ELEMENTS_PROMISE = Promise.resolve(Object.freeze({})); -// Client-only optimisation: memoises merged promises by identity so React.use() sees a stable -// reference across re-renders. During SSR each request creates fresh promises so the cache is -// never hit, but the WeakMap entries are GC-eligible once the request's promises are collected. -const mergeCache = new WeakMap, WeakMap, Promise>>(); +export { UNMATCHED_SLOT }; -export const UNMATCHED_SLOT = Symbol.for("vinext.unmatchedSlot"); - -export const ElementsContext = React.createContext>(EMPTY_ELEMENTS_PROMISE); +/** + * Holds resolved AppElements (not a Promise). React 19's use(Promise) during + * hydration triggers "async Client Component" for native Promises that lack + * React's internal .status property. Storing resolved values sidesteps this. + */ +export const ElementsContext = React.createContext(EMPTY_ELEMENTS); export const ChildrenContext = React.createContext(null); @@ -23,28 +22,16 @@ export const ParallelSlotsContext = React.createContext > | null>(null); -export function mergeElementsPromise( - prev: Promise, - next: Promise, -): Promise { - let nextCache = mergeCache.get(prev); - if (!nextCache) { - nextCache = new WeakMap(); - mergeCache.set(prev, nextCache); - } - - const cached = nextCache.get(next); - if (cached) { - return cached; +export function mergeElements(prev: AppElements, next: AppElements): AppElements { + const merged: Record = { ...prev, ...next }; + // On soft navigation, unmatched parallel slots preserve their previous subtree + // instead of firing notFound(). Only hard navigation (full page load) should 404. + // This matches Next.js behavior for parallel route persistence. + for (const key of Object.keys(merged)) { + if (key.startsWith("slot:") && merged[key] === UNMATCHED_SLOT && Object.hasOwn(prev, key)) { + merged[key] = prev[key]; + } } - - // Cached permanently including rejections — intentional because these promises come from - // createFromFetch() and a rejection means the navigation itself has failed. - const merged = Promise.all([prev, next]).then(([prevElements, nextElements]) => ({ - ...prevElements, - ...nextElements, - })); - nextCache.set(next, merged); return merged; } @@ -57,15 +44,21 @@ export function Slot({ children?: React.ReactNode; parallelSlots?: Readonly>; }) { - const elements = React.use(React.useContext(ElementsContext)); + const elements = React.useContext(ElementsContext); if (!Object.hasOwn(elements, id)) { + if (process.env.NODE_ENV !== "production" && !id.startsWith("slot:")) { + if (!warnedMissingEntryIds.has(id)) { + warnedMissingEntryIds.add(id); + console.warn("[vinext] Missing App Router element entry during render: " + id); + } + } return null; } const element = elements[id]; if (element === UNMATCHED_SLOT) { - notFound(); // throws — never reaches the JSX below + notFound(); } return ( diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index 4c87e645..e4d28e7d 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -78,7 +78,8 @@ import { renderAppPageHttpAccessFallback as __renderAppPageHttpAccessFallback, } from "/packages/vinext/src/server/app-page-boundary-render.js"; import { - buildAppPageRouteElement as __buildAppPageRouteElement, + buildAppPageElements as __buildAppPageElements, + createAppPageTreePath as __createAppPageTreePath, resolveAppPageChildSegments as __resolveAppPageChildSegments, } from "/packages/vinext/src/server/app-page-route-wiring.js"; import { @@ -395,6 +396,7 @@ const routes = [ routeHandler: null, layouts: [mod_1], routeSegments: [], + templateTreePositions: [], layoutTreePositions: [0], templates: [], errors: [null], @@ -417,6 +419,7 @@ const routes = [ routeHandler: null, layouts: [mod_1], routeSegments: ["about"], + templateTreePositions: [], layoutTreePositions: [0], templates: [], errors: [null], @@ -439,6 +442,7 @@ const routes = [ routeHandler: null, layouts: [mod_1, mod_4], routeSegments: ["blog",":slug"], + templateTreePositions: [], layoutTreePositions: [0,1], templates: [], errors: [null, null], @@ -461,6 +465,7 @@ const routes = [ routeHandler: null, layouts: [mod_1, mod_6], routeSegments: ["dashboard"], + templateTreePositions: [1], layoutTreePositions: [0,1], templates: [mod_7], errors: [null, mod_9], @@ -662,10 +667,22 @@ function findIntercept(pathname) { return null; } -async function buildPageElement(route, params, opts, searchParams) { +async function buildPageElements(route, params, routePath, opts, searchParams) { const PageComponent = route.page?.default; if (!PageComponent) { - return createElement("div", null, "Page has no default export"); + const _noExportRouteId = "route:" + routePath; + let _noExportRootLayout = null; + if (route.layouts?.length > 0) { + // Compute the root layout tree path for this error payload using the + // canonical helper so it stays aligned with buildAppPageElements(). + const _tp = route.layoutTreePositions?.[0] ?? 0; + _noExportRootLayout = __createAppPageTreePath(route.routeSegments, _tp); + } + return { + __route: _noExportRouteId, + __rootLayout: _noExportRootLayout, + [_noExportRouteId]: createElement("div", null, "Page has no default export"), + }; } // Resolve metadata and viewport from layouts and page. @@ -763,13 +780,14 @@ async function buildPageElement(route, params, opts, searchParams) { // dynamic, and this avoids false positives from React internals. if (hasSearchParams) markDynamicUsage(); } - return __buildAppPageRouteElement({ + return __buildAppPageElements({ element: createElement(PageComponent, pageProps), globalErrorModule: null, makeThenableParams, matchedParams: params, resolvedMetadata, resolvedViewport, + routePath, rootNotFoundModule: null, route, slotOverrides: @@ -1398,10 +1416,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { returnValue = { ok: true, data }; } catch (e) { // Detect redirect() / permanentRedirect() called inside the action. - // These throw errors with digest "NEXT_REDIRECT;;[;]". - // The type field is empty when redirect() was called without an explicit - // type argument. In Server Action context, Next.js defaults to "push" so - // the Back button works after form submissions. + // These throw errors with digest "NEXT_REDIRECT;replace;url[;status]". // The URL is encodeURIComponent-encoded to prevent semicolons in the URL // from corrupting the delimiter-based digest format. if (e && typeof e === "object" && "digest" in e) { @@ -1410,7 +1425,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const parts = digest.split(";"); actionRedirect = { url: decodeURIComponent(parts[2]), - type: parts[1] || "push", // Server Action → default "push" + type: parts[1] || "push", // "push" or "replace" status: parts[3] ? parseInt(parts[3], 10) : 307, }; returnValue = { ok: true, data: undefined }; @@ -1447,9 +1462,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept", }); - // Merge middleware headers first so the framework's own redirect control - // headers below are always authoritative and cannot be clobbered by - // middleware that happens to set x-action-redirect* keys. __mergeMiddlewareResponseHeaders(redirectHeaders, _mwCtx.headers); redirectHeaders.set("x-action-redirect", actionRedirect.url); redirectHeaders.set("x-action-redirect-type", actionRedirect.type); @@ -1473,9 +1485,20 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { searchParams: url.searchParams, params: actionParams, }); - element = await buildPageElement(actionRoute, actionParams, undefined, url.searchParams); + element = buildPageElements( + actionRoute, + actionParams, + cleanPathname, + undefined, + url.searchParams, + ); } else { - element = createElement("div", null, "Page not found"); + const _actionRouteId = "route:" + cleanPathname; + element = { + __route: _actionRouteId, + __rootLayout: null, + [_actionRouteId]: createElement("div", null, "Page not found"), + }; } const onRenderError = createRscOnErrorHandler( @@ -1496,15 +1519,22 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const actionPendingCookies = getAndClearPendingCookies(); const actionDraftCookie = getDraftModeCookieHeader(); - const actionHeaders = new Headers({ "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }); + const actionHeaders = new Headers({ + "Content-Type": "text/x-component; charset=utf-8", + "Vary": "RSC, Accept", + }); __mergeMiddlewareResponseHeaders(actionHeaders, _mwCtx.headers); + const actionResponse = new Response(rscStream, { + status: _mwCtx.status ?? 200, + headers: actionHeaders, + }); if (actionPendingCookies.length > 0 || actionDraftCookie) { for (const cookie of actionPendingCookies) { - actionHeaders.append("Set-Cookie", cookie); + actionResponse.headers.append("Set-Cookie", cookie); } - if (actionDraftCookie) actionHeaders.append("Set-Cookie", actionDraftCookie); + if (actionDraftCookie) actionResponse.headers.append("Set-Cookie", actionDraftCookie); } - return new Response(rscStream, { status: _mwCtx.status ?? 200, headers: actionHeaders }); + return actionResponse; } catch (err) { getAndClearPendingCookies(); // Clear pending cookies on error console.error("[vinext] Server action error:", err); @@ -1797,7 +1827,13 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { return _runWithUnifiedCtx(__revalUCtx, async () => { _ensureFetchPatch(); setNavigationContext({ pathname: cleanPathname, searchParams: new URLSearchParams(), params }); - const __revalElement = await buildPageElement(route, params, undefined, new URLSearchParams()); + const __revalElement = await buildPageElements( + route, + params, + cleanPathname, + undefined, + new URLSearchParams(), + ); const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); const __revalRscCapture = __teeAppPageRscStreamForCapture(__revalRscStream, true); @@ -1846,44 +1882,25 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // If the target URL matches an intercepting route in a parallel slot, // render the source route with the intercepting page in the slot. const __interceptResult = await __resolveAppPageIntercept({ - buildPageElement, + buildPageElement(interceptRoute, interceptParams, interceptOpts, interceptSearchParams) { + return buildPageElements( + interceptRoute, + interceptParams, + cleanPathname, + interceptOpts, + interceptSearchParams, + ); + }, cleanPathname, currentRoute: route, findIntercept, - getRoutePattern(sourceRoute) { - return sourceRoute.pattern; + getRouteParamNames(sourceRoute) { + return sourceRoute.params; }, getSourceRoute(sourceRouteIndex) { return routes[sourceRouteIndex]; }, isRscRequest, - matchSourceRouteParams(pattern) { - // Extract actual URL param values by prefix-matching the request pathname - // against the source route's pattern. This handles all interception conventions: - // (.) same-level, (..) one-level-up, and (...) root — the source pattern's - // dynamic segments that align with the URL get their real values extracted. - // We must NOT use matchRoute(pattern) here: the trie would match the literal - // ":param" strings as dynamic segment values, returning e.g. {id: ":id"}. - const patternParts = pattern.split("/").filter(Boolean); - const urlParts = cleanPathname.split("/").filter(Boolean); - const params = Object.create(null); - for (let i = 0; i < patternParts.length; i++) { - const pp = patternParts[i]; - if (pp.endsWith("+") || pp.endsWith("*")) { - // urlParts.slice(i) safely returns [] when i >= urlParts.length, - // which is the correct value for optional catch-all with zero segments. - params[pp.slice(1, -1)] = urlParts.slice(i); - break; - } - if (i >= urlParts.length) break; - if (pp.startsWith(":")) { - params[pp.slice(1)] = urlParts[i]; - } else if (pp !== urlParts[i]) { - break; - } - } - return params; - }, renderInterceptResponse(sourceRoute, interceptElement) { const interceptOnError = createRscOnErrorHandler( request, @@ -1897,7 +1914,10 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // by the client, and async server components that run during consumption need the // context to still be live. The AsyncLocalStorage scope from runWithRequestContext // handles cleanup naturally when all async continuations complete. - const interceptHeaders = new Headers({ "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }); + const interceptHeaders = new Headers({ + "Content-Type": "text/x-component; charset=utf-8", + "Vary": "RSC, Accept", + }); __mergeMiddlewareResponseHeaders(interceptHeaders, _mwCtx.headers); return new Response(interceptStream, { status: _mwCtx.status ?? 200, @@ -1921,7 +1941,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const __pageBuildResult = await __buildAppPageElement({ buildPageElement() { - return buildPageElement(route, params, interceptOpts, url.searchParams); + return buildPageElements(route, params, cleanPathname, interceptOpts, url.searchParams); }, renderErrorBoundaryPage(buildErr) { return renderErrorBoundaryPage(route, buildErr, isRscRequest, request, params); @@ -1952,19 +1972,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // rscCssTransform — no manual loadCss() call needed. const _hasLoadingBoundary = !!(route.loading && route.loading.default); const _asyncLayoutParams = makeThenableParams(params); - // Convert URLSearchParams to a plain object then wrap in makeThenableParams() - // so probePage() passes the same shape that buildPageElement() gives to the - // real render. Without this, pages that destructure await-ed searchParams - // throw TypeError during probe. - const _probeSearchObj = {}; - url.searchParams.forEach(function(v, k) { - if (k in _probeSearchObj) { - _probeSearchObj[k] = Array.isArray(_probeSearchObj[k]) ? _probeSearchObj[k].concat(v) : [_probeSearchObj[k], v]; - } else { - _probeSearchObj[k] = v; - } - }); - const _asyncSearchParams = makeThenableParams(_probeSearchObj); return __renderAppPageLifecycle({ cleanPathname, clearRequestContext() { @@ -2010,6 +2017,17 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { return LayoutComp({ params: _asyncLayoutParams, children: null }); }, probePage() { + const _probeSearchObj = {}; + url.searchParams.forEach(function(v, k) { + if (k in _probeSearchObj) { + _probeSearchObj[k] = Array.isArray(_probeSearchObj[k]) + ? _probeSearchObj[k].concat(v) + : [_probeSearchObj[k], v]; + } else { + _probeSearchObj[k] = v; + } + }); + const _asyncSearchParams = makeThenableParams(_probeSearchObj); return PageComponent({ params: _asyncLayoutParams, searchParams: _asyncSearchParams }); }, revalidateSeconds, @@ -2159,7 +2177,8 @@ import { renderAppPageHttpAccessFallback as __renderAppPageHttpAccessFallback, } from "/packages/vinext/src/server/app-page-boundary-render.js"; import { - buildAppPageRouteElement as __buildAppPageRouteElement, + buildAppPageElements as __buildAppPageElements, + createAppPageTreePath as __createAppPageTreePath, resolveAppPageChildSegments as __resolveAppPageChildSegments, } from "/packages/vinext/src/server/app-page-route-wiring.js"; import { @@ -2476,6 +2495,7 @@ const routes = [ routeHandler: null, layouts: [mod_1], routeSegments: [], + templateTreePositions: [], layoutTreePositions: [0], templates: [], errors: [null], @@ -2498,6 +2518,7 @@ const routes = [ routeHandler: null, layouts: [mod_1], routeSegments: ["about"], + templateTreePositions: [], layoutTreePositions: [0], templates: [], errors: [null], @@ -2520,6 +2541,7 @@ const routes = [ routeHandler: null, layouts: [mod_1, mod_4], routeSegments: ["blog",":slug"], + templateTreePositions: [], layoutTreePositions: [0,1], templates: [], errors: [null, null], @@ -2542,6 +2564,7 @@ const routes = [ routeHandler: null, layouts: [mod_1, mod_6], routeSegments: ["dashboard"], + templateTreePositions: [1], layoutTreePositions: [0,1], templates: [mod_7], errors: [null, mod_9], @@ -2743,10 +2766,22 @@ function findIntercept(pathname) { return null; } -async function buildPageElement(route, params, opts, searchParams) { +async function buildPageElements(route, params, routePath, opts, searchParams) { const PageComponent = route.page?.default; if (!PageComponent) { - return createElement("div", null, "Page has no default export"); + const _noExportRouteId = "route:" + routePath; + let _noExportRootLayout = null; + if (route.layouts?.length > 0) { + // Compute the root layout tree path for this error payload using the + // canonical helper so it stays aligned with buildAppPageElements(). + const _tp = route.layoutTreePositions?.[0] ?? 0; + _noExportRootLayout = __createAppPageTreePath(route.routeSegments, _tp); + } + return { + __route: _noExportRouteId, + __rootLayout: _noExportRootLayout, + [_noExportRouteId]: createElement("div", null, "Page has no default export"), + }; } // Resolve metadata and viewport from layouts and page. @@ -2844,13 +2879,14 @@ async function buildPageElement(route, params, opts, searchParams) { // dynamic, and this avoids false positives from React internals. if (hasSearchParams) markDynamicUsage(); } - return __buildAppPageRouteElement({ + return __buildAppPageElements({ element: createElement(PageComponent, pageProps), globalErrorModule: null, makeThenableParams, matchedParams: params, resolvedMetadata, resolvedViewport, + routePath, rootNotFoundModule: null, route, slotOverrides: @@ -3485,10 +3521,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { returnValue = { ok: true, data }; } catch (e) { // Detect redirect() / permanentRedirect() called inside the action. - // These throw errors with digest "NEXT_REDIRECT;;[;]". - // The type field is empty when redirect() was called without an explicit - // type argument. In Server Action context, Next.js defaults to "push" so - // the Back button works after form submissions. + // These throw errors with digest "NEXT_REDIRECT;replace;url[;status]". // The URL is encodeURIComponent-encoded to prevent semicolons in the URL // from corrupting the delimiter-based digest format. if (e && typeof e === "object" && "digest" in e) { @@ -3497,7 +3530,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const parts = digest.split(";"); actionRedirect = { url: decodeURIComponent(parts[2]), - type: parts[1] || "push", // Server Action → default "push" + type: parts[1] || "push", // "push" or "replace" status: parts[3] ? parseInt(parts[3], 10) : 307, }; returnValue = { ok: true, data: undefined }; @@ -3534,9 +3567,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept", }); - // Merge middleware headers first so the framework's own redirect control - // headers below are always authoritative and cannot be clobbered by - // middleware that happens to set x-action-redirect* keys. __mergeMiddlewareResponseHeaders(redirectHeaders, _mwCtx.headers); redirectHeaders.set("x-action-redirect", actionRedirect.url); redirectHeaders.set("x-action-redirect-type", actionRedirect.type); @@ -3560,9 +3590,20 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { searchParams: url.searchParams, params: actionParams, }); - element = await buildPageElement(actionRoute, actionParams, undefined, url.searchParams); + element = buildPageElements( + actionRoute, + actionParams, + cleanPathname, + undefined, + url.searchParams, + ); } else { - element = createElement("div", null, "Page not found"); + const _actionRouteId = "route:" + cleanPathname; + element = { + __route: _actionRouteId, + __rootLayout: null, + [_actionRouteId]: createElement("div", null, "Page not found"), + }; } const onRenderError = createRscOnErrorHandler( @@ -3583,15 +3624,22 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const actionPendingCookies = getAndClearPendingCookies(); const actionDraftCookie = getDraftModeCookieHeader(); - const actionHeaders = new Headers({ "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }); + const actionHeaders = new Headers({ + "Content-Type": "text/x-component; charset=utf-8", + "Vary": "RSC, Accept", + }); __mergeMiddlewareResponseHeaders(actionHeaders, _mwCtx.headers); + const actionResponse = new Response(rscStream, { + status: _mwCtx.status ?? 200, + headers: actionHeaders, + }); if (actionPendingCookies.length > 0 || actionDraftCookie) { for (const cookie of actionPendingCookies) { - actionHeaders.append("Set-Cookie", cookie); + actionResponse.headers.append("Set-Cookie", cookie); } - if (actionDraftCookie) actionHeaders.append("Set-Cookie", actionDraftCookie); + if (actionDraftCookie) actionResponse.headers.append("Set-Cookie", actionDraftCookie); } - return new Response(rscStream, { status: _mwCtx.status ?? 200, headers: actionHeaders }); + return actionResponse; } catch (err) { getAndClearPendingCookies(); // Clear pending cookies on error console.error("[vinext] Server action error:", err); @@ -3884,7 +3932,13 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { return _runWithUnifiedCtx(__revalUCtx, async () => { _ensureFetchPatch(); setNavigationContext({ pathname: cleanPathname, searchParams: new URLSearchParams(), params }); - const __revalElement = await buildPageElement(route, params, undefined, new URLSearchParams()); + const __revalElement = await buildPageElements( + route, + params, + cleanPathname, + undefined, + new URLSearchParams(), + ); const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); const __revalRscCapture = __teeAppPageRscStreamForCapture(__revalRscStream, true); @@ -3933,44 +3987,25 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // If the target URL matches an intercepting route in a parallel slot, // render the source route with the intercepting page in the slot. const __interceptResult = await __resolveAppPageIntercept({ - buildPageElement, + buildPageElement(interceptRoute, interceptParams, interceptOpts, interceptSearchParams) { + return buildPageElements( + interceptRoute, + interceptParams, + cleanPathname, + interceptOpts, + interceptSearchParams, + ); + }, cleanPathname, currentRoute: route, findIntercept, - getRoutePattern(sourceRoute) { - return sourceRoute.pattern; + getRouteParamNames(sourceRoute) { + return sourceRoute.params; }, getSourceRoute(sourceRouteIndex) { return routes[sourceRouteIndex]; }, isRscRequest, - matchSourceRouteParams(pattern) { - // Extract actual URL param values by prefix-matching the request pathname - // against the source route's pattern. This handles all interception conventions: - // (.) same-level, (..) one-level-up, and (...) root — the source pattern's - // dynamic segments that align with the URL get their real values extracted. - // We must NOT use matchRoute(pattern) here: the trie would match the literal - // ":param" strings as dynamic segment values, returning e.g. {id: ":id"}. - const patternParts = pattern.split("/").filter(Boolean); - const urlParts = cleanPathname.split("/").filter(Boolean); - const params = Object.create(null); - for (let i = 0; i < patternParts.length; i++) { - const pp = patternParts[i]; - if (pp.endsWith("+") || pp.endsWith("*")) { - // urlParts.slice(i) safely returns [] when i >= urlParts.length, - // which is the correct value for optional catch-all with zero segments. - params[pp.slice(1, -1)] = urlParts.slice(i); - break; - } - if (i >= urlParts.length) break; - if (pp.startsWith(":")) { - params[pp.slice(1)] = urlParts[i]; - } else if (pp !== urlParts[i]) { - break; - } - } - return params; - }, renderInterceptResponse(sourceRoute, interceptElement) { const interceptOnError = createRscOnErrorHandler( request, @@ -3984,7 +4019,10 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // by the client, and async server components that run during consumption need the // context to still be live. The AsyncLocalStorage scope from runWithRequestContext // handles cleanup naturally when all async continuations complete. - const interceptHeaders = new Headers({ "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }); + const interceptHeaders = new Headers({ + "Content-Type": "text/x-component; charset=utf-8", + "Vary": "RSC, Accept", + }); __mergeMiddlewareResponseHeaders(interceptHeaders, _mwCtx.headers); return new Response(interceptStream, { status: _mwCtx.status ?? 200, @@ -4008,7 +4046,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const __pageBuildResult = await __buildAppPageElement({ buildPageElement() { - return buildPageElement(route, params, interceptOpts, url.searchParams); + return buildPageElements(route, params, cleanPathname, interceptOpts, url.searchParams); }, renderErrorBoundaryPage(buildErr) { return renderErrorBoundaryPage(route, buildErr, isRscRequest, request, params); @@ -4039,19 +4077,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // rscCssTransform — no manual loadCss() call needed. const _hasLoadingBoundary = !!(route.loading && route.loading.default); const _asyncLayoutParams = makeThenableParams(params); - // Convert URLSearchParams to a plain object then wrap in makeThenableParams() - // so probePage() passes the same shape that buildPageElement() gives to the - // real render. Without this, pages that destructure await-ed searchParams - // throw TypeError during probe. - const _probeSearchObj = {}; - url.searchParams.forEach(function(v, k) { - if (k in _probeSearchObj) { - _probeSearchObj[k] = Array.isArray(_probeSearchObj[k]) ? _probeSearchObj[k].concat(v) : [_probeSearchObj[k], v]; - } else { - _probeSearchObj[k] = v; - } - }); - const _asyncSearchParams = makeThenableParams(_probeSearchObj); return __renderAppPageLifecycle({ cleanPathname, clearRequestContext() { @@ -4097,6 +4122,17 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { return LayoutComp({ params: _asyncLayoutParams, children: null }); }, probePage() { + const _probeSearchObj = {}; + url.searchParams.forEach(function(v, k) { + if (k in _probeSearchObj) { + _probeSearchObj[k] = Array.isArray(_probeSearchObj[k]) + ? _probeSearchObj[k].concat(v) + : [_probeSearchObj[k], v]; + } else { + _probeSearchObj[k] = v; + } + }); + const _asyncSearchParams = makeThenableParams(_probeSearchObj); return PageComponent({ params: _asyncLayoutParams, searchParams: _asyncSearchParams }); }, revalidateSeconds, @@ -4246,7 +4282,8 @@ import { renderAppPageHttpAccessFallback as __renderAppPageHttpAccessFallback, } from "/packages/vinext/src/server/app-page-boundary-render.js"; import { - buildAppPageRouteElement as __buildAppPageRouteElement, + buildAppPageElements as __buildAppPageElements, + createAppPageTreePath as __createAppPageTreePath, resolveAppPageChildSegments as __resolveAppPageChildSegments, } from "/packages/vinext/src/server/app-page-route-wiring.js"; import { @@ -4564,6 +4601,7 @@ const routes = [ routeHandler: null, layouts: [mod_1], routeSegments: [], + templateTreePositions: [], layoutTreePositions: [0], templates: [], errors: [null], @@ -4586,6 +4624,7 @@ const routes = [ routeHandler: null, layouts: [mod_1], routeSegments: ["about"], + templateTreePositions: [], layoutTreePositions: [0], templates: [], errors: [null], @@ -4608,6 +4647,7 @@ const routes = [ routeHandler: null, layouts: [mod_1, mod_4], routeSegments: ["blog",":slug"], + templateTreePositions: [], layoutTreePositions: [0,1], templates: [], errors: [null, null], @@ -4630,6 +4670,7 @@ const routes = [ routeHandler: null, layouts: [mod_1, mod_6], routeSegments: ["dashboard"], + templateTreePositions: [1], layoutTreePositions: [0,1], templates: [mod_7], errors: [null, mod_9], @@ -4831,10 +4872,22 @@ function findIntercept(pathname) { return null; } -async function buildPageElement(route, params, opts, searchParams) { +async function buildPageElements(route, params, routePath, opts, searchParams) { const PageComponent = route.page?.default; if (!PageComponent) { - return createElement("div", null, "Page has no default export"); + const _noExportRouteId = "route:" + routePath; + let _noExportRootLayout = null; + if (route.layouts?.length > 0) { + // Compute the root layout tree path for this error payload using the + // canonical helper so it stays aligned with buildAppPageElements(). + const _tp = route.layoutTreePositions?.[0] ?? 0; + _noExportRootLayout = __createAppPageTreePath(route.routeSegments, _tp); + } + return { + __route: _noExportRouteId, + __rootLayout: _noExportRootLayout, + [_noExportRouteId]: createElement("div", null, "Page has no default export"), + }; } // Resolve metadata and viewport from layouts and page. @@ -4932,13 +4985,14 @@ async function buildPageElement(route, params, opts, searchParams) { // dynamic, and this avoids false positives from React internals. if (hasSearchParams) markDynamicUsage(); } - return __buildAppPageRouteElement({ + return __buildAppPageElements({ element: createElement(PageComponent, pageProps), globalErrorModule: mod_11, makeThenableParams, matchedParams: params, resolvedMetadata, resolvedViewport, + routePath, rootNotFoundModule: null, route, slotOverrides: @@ -5567,10 +5621,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { returnValue = { ok: true, data }; } catch (e) { // Detect redirect() / permanentRedirect() called inside the action. - // These throw errors with digest "NEXT_REDIRECT;;[;]". - // The type field is empty when redirect() was called without an explicit - // type argument. In Server Action context, Next.js defaults to "push" so - // the Back button works after form submissions. + // These throw errors with digest "NEXT_REDIRECT;replace;url[;status]". // The URL is encodeURIComponent-encoded to prevent semicolons in the URL // from corrupting the delimiter-based digest format. if (e && typeof e === "object" && "digest" in e) { @@ -5579,7 +5630,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const parts = digest.split(";"); actionRedirect = { url: decodeURIComponent(parts[2]), - type: parts[1] || "push", // Server Action → default "push" + type: parts[1] || "push", // "push" or "replace" status: parts[3] ? parseInt(parts[3], 10) : 307, }; returnValue = { ok: true, data: undefined }; @@ -5616,9 +5667,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept", }); - // Merge middleware headers first so the framework's own redirect control - // headers below are always authoritative and cannot be clobbered by - // middleware that happens to set x-action-redirect* keys. __mergeMiddlewareResponseHeaders(redirectHeaders, _mwCtx.headers); redirectHeaders.set("x-action-redirect", actionRedirect.url); redirectHeaders.set("x-action-redirect-type", actionRedirect.type); @@ -5642,9 +5690,20 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { searchParams: url.searchParams, params: actionParams, }); - element = await buildPageElement(actionRoute, actionParams, undefined, url.searchParams); + element = buildPageElements( + actionRoute, + actionParams, + cleanPathname, + undefined, + url.searchParams, + ); } else { - element = createElement("div", null, "Page not found"); + const _actionRouteId = "route:" + cleanPathname; + element = { + __route: _actionRouteId, + __rootLayout: null, + [_actionRouteId]: createElement("div", null, "Page not found"), + }; } const onRenderError = createRscOnErrorHandler( @@ -5665,15 +5724,22 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const actionPendingCookies = getAndClearPendingCookies(); const actionDraftCookie = getDraftModeCookieHeader(); - const actionHeaders = new Headers({ "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }); + const actionHeaders = new Headers({ + "Content-Type": "text/x-component; charset=utf-8", + "Vary": "RSC, Accept", + }); __mergeMiddlewareResponseHeaders(actionHeaders, _mwCtx.headers); + const actionResponse = new Response(rscStream, { + status: _mwCtx.status ?? 200, + headers: actionHeaders, + }); if (actionPendingCookies.length > 0 || actionDraftCookie) { for (const cookie of actionPendingCookies) { - actionHeaders.append("Set-Cookie", cookie); + actionResponse.headers.append("Set-Cookie", cookie); } - if (actionDraftCookie) actionHeaders.append("Set-Cookie", actionDraftCookie); + if (actionDraftCookie) actionResponse.headers.append("Set-Cookie", actionDraftCookie); } - return new Response(rscStream, { status: _mwCtx.status ?? 200, headers: actionHeaders }); + return actionResponse; } catch (err) { getAndClearPendingCookies(); // Clear pending cookies on error console.error("[vinext] Server action error:", err); @@ -5966,7 +6032,13 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { return _runWithUnifiedCtx(__revalUCtx, async () => { _ensureFetchPatch(); setNavigationContext({ pathname: cleanPathname, searchParams: new URLSearchParams(), params }); - const __revalElement = await buildPageElement(route, params, undefined, new URLSearchParams()); + const __revalElement = await buildPageElements( + route, + params, + cleanPathname, + undefined, + new URLSearchParams(), + ); const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); const __revalRscCapture = __teeAppPageRscStreamForCapture(__revalRscStream, true); @@ -6015,44 +6087,25 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // If the target URL matches an intercepting route in a parallel slot, // render the source route with the intercepting page in the slot. const __interceptResult = await __resolveAppPageIntercept({ - buildPageElement, + buildPageElement(interceptRoute, interceptParams, interceptOpts, interceptSearchParams) { + return buildPageElements( + interceptRoute, + interceptParams, + cleanPathname, + interceptOpts, + interceptSearchParams, + ); + }, cleanPathname, currentRoute: route, findIntercept, - getRoutePattern(sourceRoute) { - return sourceRoute.pattern; + getRouteParamNames(sourceRoute) { + return sourceRoute.params; }, getSourceRoute(sourceRouteIndex) { return routes[sourceRouteIndex]; }, isRscRequest, - matchSourceRouteParams(pattern) { - // Extract actual URL param values by prefix-matching the request pathname - // against the source route's pattern. This handles all interception conventions: - // (.) same-level, (..) one-level-up, and (...) root — the source pattern's - // dynamic segments that align with the URL get their real values extracted. - // We must NOT use matchRoute(pattern) here: the trie would match the literal - // ":param" strings as dynamic segment values, returning e.g. {id: ":id"}. - const patternParts = pattern.split("/").filter(Boolean); - const urlParts = cleanPathname.split("/").filter(Boolean); - const params = Object.create(null); - for (let i = 0; i < patternParts.length; i++) { - const pp = patternParts[i]; - if (pp.endsWith("+") || pp.endsWith("*")) { - // urlParts.slice(i) safely returns [] when i >= urlParts.length, - // which is the correct value for optional catch-all with zero segments. - params[pp.slice(1, -1)] = urlParts.slice(i); - break; - } - if (i >= urlParts.length) break; - if (pp.startsWith(":")) { - params[pp.slice(1)] = urlParts[i]; - } else if (pp !== urlParts[i]) { - break; - } - } - return params; - }, renderInterceptResponse(sourceRoute, interceptElement) { const interceptOnError = createRscOnErrorHandler( request, @@ -6066,7 +6119,10 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // by the client, and async server components that run during consumption need the // context to still be live. The AsyncLocalStorage scope from runWithRequestContext // handles cleanup naturally when all async continuations complete. - const interceptHeaders = new Headers({ "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }); + const interceptHeaders = new Headers({ + "Content-Type": "text/x-component; charset=utf-8", + "Vary": "RSC, Accept", + }); __mergeMiddlewareResponseHeaders(interceptHeaders, _mwCtx.headers); return new Response(interceptStream, { status: _mwCtx.status ?? 200, @@ -6090,7 +6146,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const __pageBuildResult = await __buildAppPageElement({ buildPageElement() { - return buildPageElement(route, params, interceptOpts, url.searchParams); + return buildPageElements(route, params, cleanPathname, interceptOpts, url.searchParams); }, renderErrorBoundaryPage(buildErr) { return renderErrorBoundaryPage(route, buildErr, isRscRequest, request, params); @@ -6121,19 +6177,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // rscCssTransform — no manual loadCss() call needed. const _hasLoadingBoundary = !!(route.loading && route.loading.default); const _asyncLayoutParams = makeThenableParams(params); - // Convert URLSearchParams to a plain object then wrap in makeThenableParams() - // so probePage() passes the same shape that buildPageElement() gives to the - // real render. Without this, pages that destructure await-ed searchParams - // throw TypeError during probe. - const _probeSearchObj = {}; - url.searchParams.forEach(function(v, k) { - if (k in _probeSearchObj) { - _probeSearchObj[k] = Array.isArray(_probeSearchObj[k]) ? _probeSearchObj[k].concat(v) : [_probeSearchObj[k], v]; - } else { - _probeSearchObj[k] = v; - } - }); - const _asyncSearchParams = makeThenableParams(_probeSearchObj); return __renderAppPageLifecycle({ cleanPathname, clearRequestContext() { @@ -6179,6 +6222,17 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { return LayoutComp({ params: _asyncLayoutParams, children: null }); }, probePage() { + const _probeSearchObj = {}; + url.searchParams.forEach(function(v, k) { + if (k in _probeSearchObj) { + _probeSearchObj[k] = Array.isArray(_probeSearchObj[k]) + ? _probeSearchObj[k].concat(v) + : [_probeSearchObj[k], v]; + } else { + _probeSearchObj[k] = v; + } + }); + const _asyncSearchParams = makeThenableParams(_probeSearchObj); return PageComponent({ params: _asyncLayoutParams, searchParams: _asyncSearchParams }); }, revalidateSeconds, @@ -6328,7 +6382,8 @@ import { renderAppPageHttpAccessFallback as __renderAppPageHttpAccessFallback, } from "/packages/vinext/src/server/app-page-boundary-render.js"; import { - buildAppPageRouteElement as __buildAppPageRouteElement, + buildAppPageElements as __buildAppPageElements, + createAppPageTreePath as __createAppPageTreePath, resolveAppPageChildSegments as __resolveAppPageChildSegments, } from "/packages/vinext/src/server/app-page-route-wiring.js"; import { @@ -6675,6 +6730,7 @@ const routes = [ routeHandler: null, layouts: [mod_1], routeSegments: [], + templateTreePositions: [], layoutTreePositions: [0], templates: [], errors: [null], @@ -6697,6 +6753,7 @@ const routes = [ routeHandler: null, layouts: [mod_1], routeSegments: ["about"], + templateTreePositions: [], layoutTreePositions: [0], templates: [], errors: [null], @@ -6719,6 +6776,7 @@ const routes = [ routeHandler: null, layouts: [mod_1, mod_4], routeSegments: ["blog",":slug"], + templateTreePositions: [], layoutTreePositions: [0,1], templates: [], errors: [null, null], @@ -6741,6 +6799,7 @@ const routes = [ routeHandler: null, layouts: [mod_1, mod_6], routeSegments: ["dashboard"], + templateTreePositions: [1], layoutTreePositions: [0,1], templates: [mod_7], errors: [null, mod_9], @@ -6942,10 +7001,22 @@ function findIntercept(pathname) { return null; } -async function buildPageElement(route, params, opts, searchParams) { +async function buildPageElements(route, params, routePath, opts, searchParams) { const PageComponent = route.page?.default; if (!PageComponent) { - return createElement("div", null, "Page has no default export"); + const _noExportRouteId = "route:" + routePath; + let _noExportRootLayout = null; + if (route.layouts?.length > 0) { + // Compute the root layout tree path for this error payload using the + // canonical helper so it stays aligned with buildAppPageElements(). + const _tp = route.layoutTreePositions?.[0] ?? 0; + _noExportRootLayout = __createAppPageTreePath(route.routeSegments, _tp); + } + return { + __route: _noExportRouteId, + __rootLayout: _noExportRootLayout, + [_noExportRouteId]: createElement("div", null, "Page has no default export"), + }; } // Resolve metadata and viewport from layouts and page. @@ -7043,13 +7114,14 @@ async function buildPageElement(route, params, opts, searchParams) { // dynamic, and this avoids false positives from React internals. if (hasSearchParams) markDynamicUsage(); } - return __buildAppPageRouteElement({ + return __buildAppPageElements({ element: createElement(PageComponent, pageProps), globalErrorModule: null, makeThenableParams, matchedParams: params, resolvedMetadata, resolvedViewport, + routePath, rootNotFoundModule: null, route, slotOverrides: @@ -7681,10 +7753,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { returnValue = { ok: true, data }; } catch (e) { // Detect redirect() / permanentRedirect() called inside the action. - // These throw errors with digest "NEXT_REDIRECT;;[;]". - // The type field is empty when redirect() was called without an explicit - // type argument. In Server Action context, Next.js defaults to "push" so - // the Back button works after form submissions. + // These throw errors with digest "NEXT_REDIRECT;replace;url[;status]". // The URL is encodeURIComponent-encoded to prevent semicolons in the URL // from corrupting the delimiter-based digest format. if (e && typeof e === "object" && "digest" in e) { @@ -7693,7 +7762,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const parts = digest.split(";"); actionRedirect = { url: decodeURIComponent(parts[2]), - type: parts[1] || "push", // Server Action → default "push" + type: parts[1] || "push", // "push" or "replace" status: parts[3] ? parseInt(parts[3], 10) : 307, }; returnValue = { ok: true, data: undefined }; @@ -7730,9 +7799,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept", }); - // Merge middleware headers first so the framework's own redirect control - // headers below are always authoritative and cannot be clobbered by - // middleware that happens to set x-action-redirect* keys. __mergeMiddlewareResponseHeaders(redirectHeaders, _mwCtx.headers); redirectHeaders.set("x-action-redirect", actionRedirect.url); redirectHeaders.set("x-action-redirect-type", actionRedirect.type); @@ -7756,9 +7822,20 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { searchParams: url.searchParams, params: actionParams, }); - element = await buildPageElement(actionRoute, actionParams, undefined, url.searchParams); + element = buildPageElements( + actionRoute, + actionParams, + cleanPathname, + undefined, + url.searchParams, + ); } else { - element = createElement("div", null, "Page not found"); + const _actionRouteId = "route:" + cleanPathname; + element = { + __route: _actionRouteId, + __rootLayout: null, + [_actionRouteId]: createElement("div", null, "Page not found"), + }; } const onRenderError = createRscOnErrorHandler( @@ -7779,15 +7856,22 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const actionPendingCookies = getAndClearPendingCookies(); const actionDraftCookie = getDraftModeCookieHeader(); - const actionHeaders = new Headers({ "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }); + const actionHeaders = new Headers({ + "Content-Type": "text/x-component; charset=utf-8", + "Vary": "RSC, Accept", + }); __mergeMiddlewareResponseHeaders(actionHeaders, _mwCtx.headers); + const actionResponse = new Response(rscStream, { + status: _mwCtx.status ?? 200, + headers: actionHeaders, + }); if (actionPendingCookies.length > 0 || actionDraftCookie) { for (const cookie of actionPendingCookies) { - actionHeaders.append("Set-Cookie", cookie); + actionResponse.headers.append("Set-Cookie", cookie); } - if (actionDraftCookie) actionHeaders.append("Set-Cookie", actionDraftCookie); + if (actionDraftCookie) actionResponse.headers.append("Set-Cookie", actionDraftCookie); } - return new Response(rscStream, { status: _mwCtx.status ?? 200, headers: actionHeaders }); + return actionResponse; } catch (err) { getAndClearPendingCookies(); // Clear pending cookies on error console.error("[vinext] Server action error:", err); @@ -8080,7 +8164,13 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { return _runWithUnifiedCtx(__revalUCtx, async () => { _ensureFetchPatch(); setNavigationContext({ pathname: cleanPathname, searchParams: new URLSearchParams(), params }); - const __revalElement = await buildPageElement(route, params, undefined, new URLSearchParams()); + const __revalElement = await buildPageElements( + route, + params, + cleanPathname, + undefined, + new URLSearchParams(), + ); const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); const __revalRscCapture = __teeAppPageRscStreamForCapture(__revalRscStream, true); @@ -8129,44 +8219,25 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // If the target URL matches an intercepting route in a parallel slot, // render the source route with the intercepting page in the slot. const __interceptResult = await __resolveAppPageIntercept({ - buildPageElement, + buildPageElement(interceptRoute, interceptParams, interceptOpts, interceptSearchParams) { + return buildPageElements( + interceptRoute, + interceptParams, + cleanPathname, + interceptOpts, + interceptSearchParams, + ); + }, cleanPathname, currentRoute: route, findIntercept, - getRoutePattern(sourceRoute) { - return sourceRoute.pattern; + getRouteParamNames(sourceRoute) { + return sourceRoute.params; }, getSourceRoute(sourceRouteIndex) { return routes[sourceRouteIndex]; }, isRscRequest, - matchSourceRouteParams(pattern) { - // Extract actual URL param values by prefix-matching the request pathname - // against the source route's pattern. This handles all interception conventions: - // (.) same-level, (..) one-level-up, and (...) root — the source pattern's - // dynamic segments that align with the URL get their real values extracted. - // We must NOT use matchRoute(pattern) here: the trie would match the literal - // ":param" strings as dynamic segment values, returning e.g. {id: ":id"}. - const patternParts = pattern.split("/").filter(Boolean); - const urlParts = cleanPathname.split("/").filter(Boolean); - const params = Object.create(null); - for (let i = 0; i < patternParts.length; i++) { - const pp = patternParts[i]; - if (pp.endsWith("+") || pp.endsWith("*")) { - // urlParts.slice(i) safely returns [] when i >= urlParts.length, - // which is the correct value for optional catch-all with zero segments. - params[pp.slice(1, -1)] = urlParts.slice(i); - break; - } - if (i >= urlParts.length) break; - if (pp.startsWith(":")) { - params[pp.slice(1)] = urlParts[i]; - } else if (pp !== urlParts[i]) { - break; - } - } - return params; - }, renderInterceptResponse(sourceRoute, interceptElement) { const interceptOnError = createRscOnErrorHandler( request, @@ -8180,7 +8251,10 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // by the client, and async server components that run during consumption need the // context to still be live. The AsyncLocalStorage scope from runWithRequestContext // handles cleanup naturally when all async continuations complete. - const interceptHeaders = new Headers({ "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }); + const interceptHeaders = new Headers({ + "Content-Type": "text/x-component; charset=utf-8", + "Vary": "RSC, Accept", + }); __mergeMiddlewareResponseHeaders(interceptHeaders, _mwCtx.headers); return new Response(interceptStream, { status: _mwCtx.status ?? 200, @@ -8204,7 +8278,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const __pageBuildResult = await __buildAppPageElement({ buildPageElement() { - return buildPageElement(route, params, interceptOpts, url.searchParams); + return buildPageElements(route, params, cleanPathname, interceptOpts, url.searchParams); }, renderErrorBoundaryPage(buildErr) { return renderErrorBoundaryPage(route, buildErr, isRscRequest, request, params); @@ -8235,19 +8309,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // rscCssTransform — no manual loadCss() call needed. const _hasLoadingBoundary = !!(route.loading && route.loading.default); const _asyncLayoutParams = makeThenableParams(params); - // Convert URLSearchParams to a plain object then wrap in makeThenableParams() - // so probePage() passes the same shape that buildPageElement() gives to the - // real render. Without this, pages that destructure await-ed searchParams - // throw TypeError during probe. - const _probeSearchObj = {}; - url.searchParams.forEach(function(v, k) { - if (k in _probeSearchObj) { - _probeSearchObj[k] = Array.isArray(_probeSearchObj[k]) ? _probeSearchObj[k].concat(v) : [_probeSearchObj[k], v]; - } else { - _probeSearchObj[k] = v; - } - }); - const _asyncSearchParams = makeThenableParams(_probeSearchObj); return __renderAppPageLifecycle({ cleanPathname, clearRequestContext() { @@ -8293,6 +8354,17 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { return LayoutComp({ params: _asyncLayoutParams, children: null }); }, probePage() { + const _probeSearchObj = {}; + url.searchParams.forEach(function(v, k) { + if (k in _probeSearchObj) { + _probeSearchObj[k] = Array.isArray(_probeSearchObj[k]) + ? _probeSearchObj[k].concat(v) + : [_probeSearchObj[k], v]; + } else { + _probeSearchObj[k] = v; + } + }); + const _asyncSearchParams = makeThenableParams(_probeSearchObj); return PageComponent({ params: _asyncLayoutParams, searchParams: _asyncSearchParams }); }, revalidateSeconds, @@ -8442,7 +8514,8 @@ import { renderAppPageHttpAccessFallback as __renderAppPageHttpAccessFallback, } from "/packages/vinext/src/server/app-page-boundary-render.js"; import { - buildAppPageRouteElement as __buildAppPageRouteElement, + buildAppPageElements as __buildAppPageElements, + createAppPageTreePath as __createAppPageTreePath, resolveAppPageChildSegments as __resolveAppPageChildSegments, } from "/packages/vinext/src/server/app-page-route-wiring.js"; import { @@ -8760,6 +8833,7 @@ const routes = [ routeHandler: null, layouts: [mod_1], routeSegments: [], + templateTreePositions: [], layoutTreePositions: [0], templates: [], errors: [null], @@ -8782,6 +8856,7 @@ const routes = [ routeHandler: null, layouts: [mod_1], routeSegments: ["about"], + templateTreePositions: [], layoutTreePositions: [0], templates: [], errors: [null], @@ -8804,6 +8879,7 @@ const routes = [ routeHandler: null, layouts: [mod_1, mod_4], routeSegments: ["blog",":slug"], + templateTreePositions: [], layoutTreePositions: [0,1], templates: [], errors: [null, null], @@ -8826,6 +8902,7 @@ const routes = [ routeHandler: null, layouts: [mod_1, mod_6], routeSegments: ["dashboard"], + templateTreePositions: [1], layoutTreePositions: [0,1], templates: [mod_7], errors: [null, mod_9], @@ -9033,10 +9110,22 @@ function findIntercept(pathname) { return null; } -async function buildPageElement(route, params, opts, searchParams) { +async function buildPageElements(route, params, routePath, opts, searchParams) { const PageComponent = route.page?.default; if (!PageComponent) { - return createElement("div", null, "Page has no default export"); + const _noExportRouteId = "route:" + routePath; + let _noExportRootLayout = null; + if (route.layouts?.length > 0) { + // Compute the root layout tree path for this error payload using the + // canonical helper so it stays aligned with buildAppPageElements(). + const _tp = route.layoutTreePositions?.[0] ?? 0; + _noExportRootLayout = __createAppPageTreePath(route.routeSegments, _tp); + } + return { + __route: _noExportRouteId, + __rootLayout: _noExportRootLayout, + [_noExportRouteId]: createElement("div", null, "Page has no default export"), + }; } // Resolve metadata and viewport from layouts and page. @@ -9134,13 +9223,14 @@ async function buildPageElement(route, params, opts, searchParams) { // dynamic, and this avoids false positives from React internals. if (hasSearchParams) markDynamicUsage(); } - return __buildAppPageRouteElement({ + return __buildAppPageElements({ element: createElement(PageComponent, pageProps), globalErrorModule: null, makeThenableParams, matchedParams: params, resolvedMetadata, resolvedViewport, + routePath, rootNotFoundModule: null, route, slotOverrides: @@ -9769,10 +9859,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { returnValue = { ok: true, data }; } catch (e) { // Detect redirect() / permanentRedirect() called inside the action. - // These throw errors with digest "NEXT_REDIRECT;;[;]". - // The type field is empty when redirect() was called without an explicit - // type argument. In Server Action context, Next.js defaults to "push" so - // the Back button works after form submissions. + // These throw errors with digest "NEXT_REDIRECT;replace;url[;status]". // The URL is encodeURIComponent-encoded to prevent semicolons in the URL // from corrupting the delimiter-based digest format. if (e && typeof e === "object" && "digest" in e) { @@ -9781,7 +9868,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const parts = digest.split(";"); actionRedirect = { url: decodeURIComponent(parts[2]), - type: parts[1] || "push", // Server Action → default "push" + type: parts[1] || "push", // "push" or "replace" status: parts[3] ? parseInt(parts[3], 10) : 307, }; returnValue = { ok: true, data: undefined }; @@ -9818,9 +9905,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept", }); - // Merge middleware headers first so the framework's own redirect control - // headers below are always authoritative and cannot be clobbered by - // middleware that happens to set x-action-redirect* keys. __mergeMiddlewareResponseHeaders(redirectHeaders, _mwCtx.headers); redirectHeaders.set("x-action-redirect", actionRedirect.url); redirectHeaders.set("x-action-redirect-type", actionRedirect.type); @@ -9844,9 +9928,20 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { searchParams: url.searchParams, params: actionParams, }); - element = await buildPageElement(actionRoute, actionParams, undefined, url.searchParams); + element = buildPageElements( + actionRoute, + actionParams, + cleanPathname, + undefined, + url.searchParams, + ); } else { - element = createElement("div", null, "Page not found"); + const _actionRouteId = "route:" + cleanPathname; + element = { + __route: _actionRouteId, + __rootLayout: null, + [_actionRouteId]: createElement("div", null, "Page not found"), + }; } const onRenderError = createRscOnErrorHandler( @@ -9867,15 +9962,22 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const actionPendingCookies = getAndClearPendingCookies(); const actionDraftCookie = getDraftModeCookieHeader(); - const actionHeaders = new Headers({ "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }); + const actionHeaders = new Headers({ + "Content-Type": "text/x-component; charset=utf-8", + "Vary": "RSC, Accept", + }); __mergeMiddlewareResponseHeaders(actionHeaders, _mwCtx.headers); + const actionResponse = new Response(rscStream, { + status: _mwCtx.status ?? 200, + headers: actionHeaders, + }); if (actionPendingCookies.length > 0 || actionDraftCookie) { for (const cookie of actionPendingCookies) { - actionHeaders.append("Set-Cookie", cookie); + actionResponse.headers.append("Set-Cookie", cookie); } - if (actionDraftCookie) actionHeaders.append("Set-Cookie", actionDraftCookie); + if (actionDraftCookie) actionResponse.headers.append("Set-Cookie", actionDraftCookie); } - return new Response(rscStream, { status: _mwCtx.status ?? 200, headers: actionHeaders }); + return actionResponse; } catch (err) { getAndClearPendingCookies(); // Clear pending cookies on error console.error("[vinext] Server action error:", err); @@ -10168,7 +10270,13 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { return _runWithUnifiedCtx(__revalUCtx, async () => { _ensureFetchPatch(); setNavigationContext({ pathname: cleanPathname, searchParams: new URLSearchParams(), params }); - const __revalElement = await buildPageElement(route, params, undefined, new URLSearchParams()); + const __revalElement = await buildPageElements( + route, + params, + cleanPathname, + undefined, + new URLSearchParams(), + ); const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); const __revalRscCapture = __teeAppPageRscStreamForCapture(__revalRscStream, true); @@ -10217,44 +10325,25 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // If the target URL matches an intercepting route in a parallel slot, // render the source route with the intercepting page in the slot. const __interceptResult = await __resolveAppPageIntercept({ - buildPageElement, + buildPageElement(interceptRoute, interceptParams, interceptOpts, interceptSearchParams) { + return buildPageElements( + interceptRoute, + interceptParams, + cleanPathname, + interceptOpts, + interceptSearchParams, + ); + }, cleanPathname, currentRoute: route, findIntercept, - getRoutePattern(sourceRoute) { - return sourceRoute.pattern; + getRouteParamNames(sourceRoute) { + return sourceRoute.params; }, getSourceRoute(sourceRouteIndex) { return routes[sourceRouteIndex]; }, isRscRequest, - matchSourceRouteParams(pattern) { - // Extract actual URL param values by prefix-matching the request pathname - // against the source route's pattern. This handles all interception conventions: - // (.) same-level, (..) one-level-up, and (...) root — the source pattern's - // dynamic segments that align with the URL get their real values extracted. - // We must NOT use matchRoute(pattern) here: the trie would match the literal - // ":param" strings as dynamic segment values, returning e.g. {id: ":id"}. - const patternParts = pattern.split("/").filter(Boolean); - const urlParts = cleanPathname.split("/").filter(Boolean); - const params = Object.create(null); - for (let i = 0; i < patternParts.length; i++) { - const pp = patternParts[i]; - if (pp.endsWith("+") || pp.endsWith("*")) { - // urlParts.slice(i) safely returns [] when i >= urlParts.length, - // which is the correct value for optional catch-all with zero segments. - params[pp.slice(1, -1)] = urlParts.slice(i); - break; - } - if (i >= urlParts.length) break; - if (pp.startsWith(":")) { - params[pp.slice(1)] = urlParts[i]; - } else if (pp !== urlParts[i]) { - break; - } - } - return params; - }, renderInterceptResponse(sourceRoute, interceptElement) { const interceptOnError = createRscOnErrorHandler( request, @@ -10268,7 +10357,10 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // by the client, and async server components that run during consumption need the // context to still be live. The AsyncLocalStorage scope from runWithRequestContext // handles cleanup naturally when all async continuations complete. - const interceptHeaders = new Headers({ "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }); + const interceptHeaders = new Headers({ + "Content-Type": "text/x-component; charset=utf-8", + "Vary": "RSC, Accept", + }); __mergeMiddlewareResponseHeaders(interceptHeaders, _mwCtx.headers); return new Response(interceptStream, { status: _mwCtx.status ?? 200, @@ -10292,7 +10384,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const __pageBuildResult = await __buildAppPageElement({ buildPageElement() { - return buildPageElement(route, params, interceptOpts, url.searchParams); + return buildPageElements(route, params, cleanPathname, interceptOpts, url.searchParams); }, renderErrorBoundaryPage(buildErr) { return renderErrorBoundaryPage(route, buildErr, isRscRequest, request, params); @@ -10323,19 +10415,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // rscCssTransform — no manual loadCss() call needed. const _hasLoadingBoundary = !!(route.loading && route.loading.default); const _asyncLayoutParams = makeThenableParams(params); - // Convert URLSearchParams to a plain object then wrap in makeThenableParams() - // so probePage() passes the same shape that buildPageElement() gives to the - // real render. Without this, pages that destructure await-ed searchParams - // throw TypeError during probe. - const _probeSearchObj = {}; - url.searchParams.forEach(function(v, k) { - if (k in _probeSearchObj) { - _probeSearchObj[k] = Array.isArray(_probeSearchObj[k]) ? _probeSearchObj[k].concat(v) : [_probeSearchObj[k], v]; - } else { - _probeSearchObj[k] = v; - } - }); - const _asyncSearchParams = makeThenableParams(_probeSearchObj); return __renderAppPageLifecycle({ cleanPathname, clearRequestContext() { @@ -10381,6 +10460,17 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { return LayoutComp({ params: _asyncLayoutParams, children: null }); }, probePage() { + const _probeSearchObj = {}; + url.searchParams.forEach(function(v, k) { + if (k in _probeSearchObj) { + _probeSearchObj[k] = Array.isArray(_probeSearchObj[k]) + ? _probeSearchObj[k].concat(v) + : [_probeSearchObj[k], v]; + } else { + _probeSearchObj[k] = v; + } + }); + const _asyncSearchParams = makeThenableParams(_probeSearchObj); return PageComponent({ params: _asyncLayoutParams, searchParams: _asyncSearchParams }); }, revalidateSeconds, @@ -10530,7 +10620,8 @@ import { renderAppPageHttpAccessFallback as __renderAppPageHttpAccessFallback, } from "/packages/vinext/src/server/app-page-boundary-render.js"; import { - buildAppPageRouteElement as __buildAppPageRouteElement, + buildAppPageElements as __buildAppPageElements, + createAppPageTreePath as __createAppPageTreePath, resolveAppPageChildSegments as __resolveAppPageChildSegments, } from "/packages/vinext/src/server/app-page-route-wiring.js"; import { @@ -10847,6 +10938,7 @@ const routes = [ routeHandler: null, layouts: [mod_1], routeSegments: [], + templateTreePositions: [], layoutTreePositions: [0], templates: [], errors: [null], @@ -10869,6 +10961,7 @@ const routes = [ routeHandler: null, layouts: [mod_1], routeSegments: ["about"], + templateTreePositions: [], layoutTreePositions: [0], templates: [], errors: [null], @@ -10891,6 +10984,7 @@ const routes = [ routeHandler: null, layouts: [mod_1, mod_4], routeSegments: ["blog",":slug"], + templateTreePositions: [], layoutTreePositions: [0,1], templates: [], errors: [null, null], @@ -10913,6 +11007,7 @@ const routes = [ routeHandler: null, layouts: [mod_1, mod_6], routeSegments: ["dashboard"], + templateTreePositions: [1], layoutTreePositions: [0,1], templates: [mod_7], errors: [null, mod_9], @@ -11114,10 +11209,22 @@ function findIntercept(pathname) { return null; } -async function buildPageElement(route, params, opts, searchParams) { +async function buildPageElements(route, params, routePath, opts, searchParams) { const PageComponent = route.page?.default; if (!PageComponent) { - return createElement("div", null, "Page has no default export"); + const _noExportRouteId = "route:" + routePath; + let _noExportRootLayout = null; + if (route.layouts?.length > 0) { + // Compute the root layout tree path for this error payload using the + // canonical helper so it stays aligned with buildAppPageElements(). + const _tp = route.layoutTreePositions?.[0] ?? 0; + _noExportRootLayout = __createAppPageTreePath(route.routeSegments, _tp); + } + return { + __route: _noExportRouteId, + __rootLayout: _noExportRootLayout, + [_noExportRouteId]: createElement("div", null, "Page has no default export"), + }; } // Resolve metadata and viewport from layouts and page. @@ -11215,13 +11322,14 @@ async function buildPageElement(route, params, opts, searchParams) { // dynamic, and this avoids false positives from React internals. if (hasSearchParams) markDynamicUsage(); } - return __buildAppPageRouteElement({ + return __buildAppPageElements({ element: createElement(PageComponent, pageProps), globalErrorModule: null, makeThenableParams, matchedParams: params, resolvedMetadata, resolvedViewport, + routePath, rootNotFoundModule: null, route, slotOverrides: @@ -12214,10 +12322,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { returnValue = { ok: true, data }; } catch (e) { // Detect redirect() / permanentRedirect() called inside the action. - // These throw errors with digest "NEXT_REDIRECT;;[;]". - // The type field is empty when redirect() was called without an explicit - // type argument. In Server Action context, Next.js defaults to "push" so - // the Back button works after form submissions. + // These throw errors with digest "NEXT_REDIRECT;replace;url[;status]". // The URL is encodeURIComponent-encoded to prevent semicolons in the URL // from corrupting the delimiter-based digest format. if (e && typeof e === "object" && "digest" in e) { @@ -12226,7 +12331,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const parts = digest.split(";"); actionRedirect = { url: decodeURIComponent(parts[2]), - type: parts[1] || "push", // Server Action → default "push" + type: parts[1] || "push", // "push" or "replace" status: parts[3] ? parseInt(parts[3], 10) : 307, }; returnValue = { ok: true, data: undefined }; @@ -12263,9 +12368,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept", }); - // Merge middleware headers first so the framework's own redirect control - // headers below are always authoritative and cannot be clobbered by - // middleware that happens to set x-action-redirect* keys. __mergeMiddlewareResponseHeaders(redirectHeaders, _mwCtx.headers); redirectHeaders.set("x-action-redirect", actionRedirect.url); redirectHeaders.set("x-action-redirect-type", actionRedirect.type); @@ -12289,9 +12391,20 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { searchParams: url.searchParams, params: actionParams, }); - element = await buildPageElement(actionRoute, actionParams, undefined, url.searchParams); + element = buildPageElements( + actionRoute, + actionParams, + cleanPathname, + undefined, + url.searchParams, + ); } else { - element = createElement("div", null, "Page not found"); + const _actionRouteId = "route:" + cleanPathname; + element = { + __route: _actionRouteId, + __rootLayout: null, + [_actionRouteId]: createElement("div", null, "Page not found"), + }; } const onRenderError = createRscOnErrorHandler( @@ -12312,15 +12425,22 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const actionPendingCookies = getAndClearPendingCookies(); const actionDraftCookie = getDraftModeCookieHeader(); - const actionHeaders = new Headers({ "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }); + const actionHeaders = new Headers({ + "Content-Type": "text/x-component; charset=utf-8", + "Vary": "RSC, Accept", + }); __mergeMiddlewareResponseHeaders(actionHeaders, _mwCtx.headers); + const actionResponse = new Response(rscStream, { + status: _mwCtx.status ?? 200, + headers: actionHeaders, + }); if (actionPendingCookies.length > 0 || actionDraftCookie) { for (const cookie of actionPendingCookies) { - actionHeaders.append("Set-Cookie", cookie); + actionResponse.headers.append("Set-Cookie", cookie); } - if (actionDraftCookie) actionHeaders.append("Set-Cookie", actionDraftCookie); + if (actionDraftCookie) actionResponse.headers.append("Set-Cookie", actionDraftCookie); } - return new Response(rscStream, { status: _mwCtx.status ?? 200, headers: actionHeaders }); + return actionResponse; } catch (err) { getAndClearPendingCookies(); // Clear pending cookies on error console.error("[vinext] Server action error:", err); @@ -12613,7 +12733,13 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { return _runWithUnifiedCtx(__revalUCtx, async () => { _ensureFetchPatch(); setNavigationContext({ pathname: cleanPathname, searchParams: new URLSearchParams(), params }); - const __revalElement = await buildPageElement(route, params, undefined, new URLSearchParams()); + const __revalElement = await buildPageElements( + route, + params, + cleanPathname, + undefined, + new URLSearchParams(), + ); const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); const __revalRscCapture = __teeAppPageRscStreamForCapture(__revalRscStream, true); @@ -12662,44 +12788,25 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // If the target URL matches an intercepting route in a parallel slot, // render the source route with the intercepting page in the slot. const __interceptResult = await __resolveAppPageIntercept({ - buildPageElement, + buildPageElement(interceptRoute, interceptParams, interceptOpts, interceptSearchParams) { + return buildPageElements( + interceptRoute, + interceptParams, + cleanPathname, + interceptOpts, + interceptSearchParams, + ); + }, cleanPathname, currentRoute: route, findIntercept, - getRoutePattern(sourceRoute) { - return sourceRoute.pattern; + getRouteParamNames(sourceRoute) { + return sourceRoute.params; }, getSourceRoute(sourceRouteIndex) { return routes[sourceRouteIndex]; }, isRscRequest, - matchSourceRouteParams(pattern) { - // Extract actual URL param values by prefix-matching the request pathname - // against the source route's pattern. This handles all interception conventions: - // (.) same-level, (..) one-level-up, and (...) root — the source pattern's - // dynamic segments that align with the URL get their real values extracted. - // We must NOT use matchRoute(pattern) here: the trie would match the literal - // ":param" strings as dynamic segment values, returning e.g. {id: ":id"}. - const patternParts = pattern.split("/").filter(Boolean); - const urlParts = cleanPathname.split("/").filter(Boolean); - const params = Object.create(null); - for (let i = 0; i < patternParts.length; i++) { - const pp = patternParts[i]; - if (pp.endsWith("+") || pp.endsWith("*")) { - // urlParts.slice(i) safely returns [] when i >= urlParts.length, - // which is the correct value for optional catch-all with zero segments. - params[pp.slice(1, -1)] = urlParts.slice(i); - break; - } - if (i >= urlParts.length) break; - if (pp.startsWith(":")) { - params[pp.slice(1)] = urlParts[i]; - } else if (pp !== urlParts[i]) { - break; - } - } - return params; - }, renderInterceptResponse(sourceRoute, interceptElement) { const interceptOnError = createRscOnErrorHandler( request, @@ -12713,7 +12820,10 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // by the client, and async server components that run during consumption need the // context to still be live. The AsyncLocalStorage scope from runWithRequestContext // handles cleanup naturally when all async continuations complete. - const interceptHeaders = new Headers({ "Content-Type": "text/x-component; charset=utf-8", "Vary": "RSC, Accept" }); + const interceptHeaders = new Headers({ + "Content-Type": "text/x-component; charset=utf-8", + "Vary": "RSC, Accept", + }); __mergeMiddlewareResponseHeaders(interceptHeaders, _mwCtx.headers); return new Response(interceptStream, { status: _mwCtx.status ?? 200, @@ -12737,7 +12847,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const __pageBuildResult = await __buildAppPageElement({ buildPageElement() { - return buildPageElement(route, params, interceptOpts, url.searchParams); + return buildPageElements(route, params, cleanPathname, interceptOpts, url.searchParams); }, renderErrorBoundaryPage(buildErr) { return renderErrorBoundaryPage(route, buildErr, isRscRequest, request, params); @@ -12768,19 +12878,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // rscCssTransform — no manual loadCss() call needed. const _hasLoadingBoundary = !!(route.loading && route.loading.default); const _asyncLayoutParams = makeThenableParams(params); - // Convert URLSearchParams to a plain object then wrap in makeThenableParams() - // so probePage() passes the same shape that buildPageElement() gives to the - // real render. Without this, pages that destructure await-ed searchParams - // throw TypeError during probe. - const _probeSearchObj = {}; - url.searchParams.forEach(function(v, k) { - if (k in _probeSearchObj) { - _probeSearchObj[k] = Array.isArray(_probeSearchObj[k]) ? _probeSearchObj[k].concat(v) : [_probeSearchObj[k], v]; - } else { - _probeSearchObj[k] = v; - } - }); - const _asyncSearchParams = makeThenableParams(_probeSearchObj); return __renderAppPageLifecycle({ cleanPathname, clearRequestContext() { @@ -12826,6 +12923,17 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { return LayoutComp({ params: _asyncLayoutParams, children: null }); }, probePage() { + const _probeSearchObj = {}; + url.searchParams.forEach(function(v, k) { + if (k in _probeSearchObj) { + _probeSearchObj[k] = Array.isArray(_probeSearchObj[k]) + ? _probeSearchObj[k].concat(v) + : [_probeSearchObj[k], v]; + } else { + _probeSearchObj[k] = v; + } + }); + const _asyncSearchParams = makeThenableParams(_probeSearchObj); return PageComponent({ params: _asyncLayoutParams, searchParams: _asyncSearchParams }); }, revalidateSeconds, diff --git a/tests/app-browser-entry.test.ts b/tests/app-browser-entry.test.ts new file mode 100644 index 00000000..dc0752cd --- /dev/null +++ b/tests/app-browser-entry.test.ts @@ -0,0 +1,221 @@ +import React from "react"; +import { describe, expect, it } from "vite-plus/test"; +import { + APP_ROOT_LAYOUT_KEY, + APP_ROUTE_KEY, + normalizeAppElements, + type AppElements, +} from "../packages/vinext/src/server/app-elements.js"; +import { createClientNavigationRenderSnapshot } from "../packages/vinext/src/shims/navigation.js"; +import { + createPendingNavigationCommit, + resolveAndClassifyNavigationCommit, + routerReducer, + resolvePendingNavigationCommitDisposition, + shouldHardNavigate, + type AppRouterState, +} from "../packages/vinext/src/server/app-browser-state.js"; + +function createResolvedElements( + routeId: string, + rootLayoutTreePath: string | null, + extraEntries: Record = {}, +) { + return normalizeAppElements({ + [APP_ROUTE_KEY]: routeId, + [APP_ROOT_LAYOUT_KEY]: rootLayoutTreePath, + ...extraEntries, + }); +} + +function createState(overrides: Partial = {}): AppRouterState { + return { + elements: createResolvedElements("route:/initial", "/"), + navigationSnapshot: createClientNavigationRenderSnapshot("https://example.com/initial", {}), + renderId: 0, + rootLayoutTreePath: "/", + routeId: "route:/initial", + ...overrides, + }; +} + +describe("app browser entry state helpers", () => { + it("requires renderId when creating pending commits", () => { + // @ts-expect-error renderId is required to avoid duplicate commit ids. + void createPendingNavigationCommit({ + currentState: createState(), + nextElements: Promise.resolve(createResolvedElements("route:/dashboard", "/")), + navigationSnapshot: createState().navigationSnapshot, + type: "navigate", + }); + }); + + it("merges elements on navigate", async () => { + const previousElements = createResolvedElements("route:/initial", "/", { + "layout:/": React.createElement("div", null, "layout"), + }); + const nextElements = createResolvedElements("route:/next", "/", { + "page:/next": React.createElement("main", null, "next"), + }); + + const nextState = routerReducer( + createState({ + elements: previousElements, + }), + { + elements: nextElements, + navigationSnapshot: createState().navigationSnapshot, + renderId: 1, + rootLayoutTreePath: "/", + routeId: "route:/next", + type: "navigate", + }, + ); + + expect(nextState.routeId).toBe("route:/next"); + expect(nextState.rootLayoutTreePath).toBe("/"); + expect(nextState.elements).toMatchObject({ + "layout:/": expect.anything(), + "page:/next": expect.anything(), + }); + }); + + it("replaces elements on replace", () => { + const nextElements = createResolvedElements("route:/next", "/", { + "page:/next": React.createElement("main", null, "next"), + }); + + const nextState = routerReducer(createState(), { + elements: nextElements, + navigationSnapshot: createState().navigationSnapshot, + renderId: 1, + rootLayoutTreePath: "/", + routeId: "route:/next", + type: "replace", + }); + + expect(nextState.elements).toBe(nextElements); + expect(nextState.elements).toMatchObject({ + "page:/next": expect.anything(), + }); + }); + + it("hard navigates instead of merging when the root layout changes", async () => { + const currentState = createState({ + rootLayoutTreePath: "/(marketing)", + }); + const pending = await createPendingNavigationCommit({ + currentState, + nextElements: Promise.resolve(createResolvedElements("route:/dashboard", "/(dashboard)")), + navigationSnapshot: currentState.navigationSnapshot, + renderId: 1, + type: "navigate", + }); + + expect( + resolvePendingNavigationCommitDisposition({ + activeNavigationId: 3, + currentRootLayoutTreePath: currentState.rootLayoutTreePath, + nextRootLayoutTreePath: pending.rootLayoutTreePath, + startedNavigationId: 3, + }), + ).toBe("hard-navigate"); + }); + + it("defers commit classification until the payload has resolved", async () => { + let resolveElements: ((value: AppElements) => void) | undefined; + const nextElements = new Promise((resolve) => { + resolveElements = resolve; + }); + let resolved = false; + const pending = createPendingNavigationCommit({ + currentState: createState(), + nextElements, + navigationSnapshot: createState().navigationSnapshot, + renderId: 1, + type: "navigate", + }).then((result) => { + resolved = true; + return result; + }); + + expect(resolved).toBe(false); + + if (!resolveElements) { + throw new Error("Expected deferred elements resolver"); + } + + resolveElements( + normalizeAppElements({ + [APP_ROUTE_KEY]: "route:/dashboard", + [APP_ROOT_LAYOUT_KEY]: "/", + "page:/dashboard": React.createElement("main", null, "dashboard"), + }), + ); + + const result = await pending; + + expect(resolved).toBe(true); + expect(result.routeId).toBe("route:/dashboard"); + }); + + it("skips a pending commit when a newer navigation has become active", async () => { + const currentState = createState(); + const pending = await createPendingNavigationCommit({ + currentState, + nextElements: Promise.resolve(createResolvedElements("route:/dashboard", "/")), + navigationSnapshot: currentState.navigationSnapshot, + renderId: 1, + type: "navigate", + }); + + expect( + resolvePendingNavigationCommitDisposition({ + activeNavigationId: 5, + currentRootLayoutTreePath: currentState.rootLayoutTreePath, + nextRootLayoutTreePath: pending.rootLayoutTreePath, + startedNavigationId: 4, + }), + ).toBe("skip"); + }); + + it("builds a merge commit for refresh and server-action payloads", async () => { + const refreshCommit = await createPendingNavigationCommit({ + currentState: createState(), + nextElements: Promise.resolve(createResolvedElements("route:/dashboard", "/")), + navigationSnapshot: createState().navigationSnapshot, + renderId: 1, + type: "navigate", + }); + + expect(refreshCommit.action.type).toBe("navigate"); + expect(refreshCommit.routeId).toBe("route:/dashboard"); + expect(refreshCommit.rootLayoutTreePath).toBe("/"); + }); + + it("classifies pending commits in one step for same-url payloads", async () => { + const currentState = createState({ + rootLayoutTreePath: "/(marketing)", + }); + + const result = await resolveAndClassifyNavigationCommit({ + activeNavigationId: 7, + currentState, + navigationSnapshot: currentState.navigationSnapshot, + nextElements: Promise.resolve(createResolvedElements("route:/dashboard", "/(dashboard)")), + renderId: 3, + startedNavigationId: 7, + type: "navigate", + }); + + expect(result.disposition).toBe("hard-navigate"); + expect(result.pending.routeId).toBe("route:/dashboard"); + expect(result.pending.action.renderId).toBe(3); + }); + + it("treats null root-layout identities as soft-navigation compatible", () => { + expect(shouldHardNavigate(null, null)).toBe(false); + expect(shouldHardNavigate(null, "/")).toBe(false); + expect(shouldHardNavigate("/", null)).toBe(false); + }); +}); diff --git a/tests/app-elements.test.ts b/tests/app-elements.test.ts new file mode 100644 index 00000000..c1fef4fc --- /dev/null +++ b/tests/app-elements.test.ts @@ -0,0 +1,78 @@ +import React from "react"; +import { describe, expect, it } from "vite-plus/test"; +import { UNMATCHED_SLOT } from "../packages/vinext/src/shims/slot.js"; +import { + APP_ROOT_LAYOUT_KEY, + APP_ROUTE_KEY, + APP_UNMATCHED_SLOT_WIRE_VALUE, + normalizeAppElements, + readAppElementsMetadata, +} from "../packages/vinext/src/server/app-elements.js"; + +describe("app elements payload helpers", () => { + it("normalizes the unmatched-slot wire marker to UNMATCHED_SLOT for slot entries", () => { + const normalized = normalizeAppElements({ + [APP_ROOT_LAYOUT_KEY]: "/", + [APP_ROUTE_KEY]: "route:/dashboard", + "page:/dashboard": React.createElement("main", null, "dashboard"), + "slot:modal:/": APP_UNMATCHED_SLOT_WIRE_VALUE, + }); + + expect(normalized["slot:modal:/"]).toBe(UNMATCHED_SLOT); + expect(normalized["page:/dashboard"]).not.toBe(UNMATCHED_SLOT); + }); + + it("does not rewrite the unmatched-slot wire marker for non-slot entries", () => { + const normalized = normalizeAppElements({ + [APP_ROOT_LAYOUT_KEY]: "/", + [APP_ROUTE_KEY]: "route:/dashboard", + "page:/dashboard": APP_UNMATCHED_SLOT_WIRE_VALUE, + }); + + expect(normalized["page:/dashboard"]).toBe(APP_UNMATCHED_SLOT_WIRE_VALUE); + }); + + it("reads route metadata from the normalized payload", () => { + const metadata = readAppElementsMetadata( + normalizeAppElements({ + [APP_ROOT_LAYOUT_KEY]: "/(dashboard)", + [APP_ROUTE_KEY]: "route:/dashboard", + "route:/dashboard": React.createElement("div", null, "route"), + }), + ); + + expect(metadata.routeId).toBe("route:/dashboard"); + expect(metadata.rootLayoutTreePath).toBe("/(dashboard)"); + }); + + it("rejects payloads with a missing __route key", () => { + expect(() => + readAppElementsMetadata( + normalizeAppElements({ + [APP_ROOT_LAYOUT_KEY]: "/", + }), + ), + ).toThrow("[vinext] Missing __route string in App Router payload"); + }); + + it("rejects payloads with an invalid __rootLayout value", () => { + expect(() => + readAppElementsMetadata( + normalizeAppElements({ + [APP_ROOT_LAYOUT_KEY]: 123, + [APP_ROUTE_KEY]: "route:/dashboard", + }), + ), + ).toThrow("[vinext] Invalid __rootLayout in App Router payload: expected string or null"); + }); + + it("rejects payloads with a missing __rootLayout key", () => { + expect(() => + readAppElementsMetadata( + normalizeAppElements({ + [APP_ROUTE_KEY]: "route:/dashboard", + }), + ), + ).toThrow("[vinext] Missing __rootLayout key in App Router payload"); + }); +}); diff --git a/tests/app-page-boundary-render.test.ts b/tests/app-page-boundary-render.test.ts index b3328866..a1c83a35 100644 --- a/tests/app-page-boundary-render.test.ts +++ b/tests/app-page-boundary-render.test.ts @@ -5,6 +5,7 @@ import { renderAppPageErrorBoundary, renderAppPageHttpAccessFallback, } from "../packages/vinext/src/server/app-page-boundary-render.js"; +import type { AppElements } from "../packages/vinext/src/server/app-elements.js"; function createStreamFromMarkup(markup: string): ReadableStream { return new ReadableStream({ @@ -15,10 +16,26 @@ function createStreamFromMarkup(markup: string): ReadableStream { }); } -function renderElementToStream(element: React.ReactNode): ReadableStream { +function renderElementToStream(element: React.ReactNode | AppElements): ReadableStream { + if (element !== null && typeof element === "object" && !React.isValidElement(element)) { + // Flat map payload — extract the route element and render it to HTML + // (mirrors what the real SSR entry does after deserializing the Flight stream) + const record = element as Record; + const routeId = record.__route; + if (typeof routeId === "string" && React.isValidElement(record[routeId])) { + return createStreamFromMarkup( + ReactDOMServer.renderToStaticMarkup(record[routeId] as React.ReactNode), + ); + } + return createStreamFromMarkup(JSON.stringify(element)); + } return createStreamFromMarkup(ReactDOMServer.renderToStaticMarkup(element)); } +function renderWirePayloadToStream(payload: unknown): ReadableStream { + return createStreamFromMarkup(JSON.stringify(payload)); +} + function createCommonOptions() { const clearRequestContext = vi.fn(); const loadSsrHandler = vi.fn(async () => ({ @@ -60,7 +77,7 @@ function createCommonOptions() { resolveChildSegments() { return []; }, - rootLayouts: [], + rootLayouts: EMPTY_ROOT_LAYOUTS, }; } @@ -122,6 +139,15 @@ const globalErrorModule = { default: GlobalErrorBoundary as React.ComponentType, }; +type TestModule = + | typeof rootLayoutModule + | typeof leafLayoutModule + | typeof notFoundModule + | typeof routeErrorModule + | typeof globalErrorModule; + +const EMPTY_ROOT_LAYOUTS: readonly TestModule[] = []; + describe("app page boundary render helpers", () => { it("returns null when no HTTP access fallback boundary exists", async () => { const common = createCommonOptions(); @@ -175,6 +201,35 @@ describe("app page boundary render helpers", () => { expect(html).toContain('content="noindex"'); }); + it("renders HTTP access fallback RSC responses as flat payloads", async () => { + const common = createCommonOptions(); + + const response = await renderAppPageHttpAccessFallback({ + ...common, + isRscRequest: true, + matchedParams: { slug: "missing" }, + renderToReadableStream: renderWirePayloadToStream, + rootLayouts: [rootLayoutModule], + route: { + layoutTreePositions: [0, 1], + layouts: [rootLayoutModule, leafLayoutModule], + notFound: notFoundModule, + params: { slug: "missing" }, + pattern: "/posts/[slug]", + routeSegments: ["posts", "[slug]"], + }, + statusCode: 404, + }); + + expect(response?.status).toBe(404); + expect(response?.headers.get("Content-Type")).toBe("text/x-component; charset=utf-8"); + + const payload = JSON.parse((await response?.text()) ?? "{}") as Record; + expect(payload.__route).toBe("route:/posts/missing"); + expect(payload.__rootLayout).toBe("/"); + expect(payload["route:/posts/missing"]).toBeTruthy(); + }); + it("renders route error boundaries with sanitized errors inside layouts", async () => { const common = createCommonOptions(); const sanitizeErrorForClient = vi.fn((error: Error) => new Error(`safe:${error.message}`)); @@ -202,6 +257,37 @@ describe("app page boundary render helpers", () => { expect(html).toContain("route:safe:secret"); }); + it("renders error boundary RSC responses as flat payloads", async () => { + const common = createCommonOptions(); + + const response = await renderAppPageErrorBoundary({ + ...common, + error: new Error("secret"), + isRscRequest: true, + matchedParams: { slug: "missing" }, + renderToReadableStream: renderWirePayloadToStream, + route: { + error: routeErrorModule, + layoutTreePositions: [0], + layouts: [rootLayoutModule], + params: { slug: "missing" }, + pattern: "/posts/[slug]", + routeSegments: ["posts", "[slug]"], + }, + sanitizeErrorForClient(error: Error) { + return new Error(`safe:${error.message}`); + }, + }); + + expect(response?.status).toBe(200); + expect(response?.headers.get("Content-Type")).toBe("text/x-component; charset=utf-8"); + + const payload = JSON.parse((await response?.text()) ?? "{}") as Record; + expect(payload.__route).toBe("route:/posts/missing"); + expect(payload.__rootLayout).toBe("/"); + expect(payload["route:/posts/missing"]).toBeTruthy(); + }); + it("renders global-error boundaries without layout wrapping", async () => { const common = createCommonOptions(); diff --git a/tests/app-page-request.test.ts b/tests/app-page-request.test.ts index 91d7b0ae..1c714372 100644 --- a/tests/app-page-request.test.ts +++ b/tests/app-page-request.test.ts @@ -64,8 +64,8 @@ describe("app page request helpers", () => { const setNavigationContext = vi.fn(); const buildPageElementMock = vi.fn(async () => ({ type: "intercept-element" })); const renderInterceptResponse = vi.fn(async () => new Response("intercepted")); - const currentRoute = { pattern: "/photos/[id]" }; - const sourceRoute = { pattern: "/feed" }; + const currentRoute = { params: ["id"], pattern: "/photos/[id]" }; + const sourceRoute = { params: [], pattern: "/feed" }; const result = await resolveAppPageIntercept({ buildPageElement: buildPageElementMock, @@ -79,16 +79,13 @@ describe("app page request helpers", () => { sourceRouteIndex: 0, }; }, - getRoutePattern(route) { - return route.pattern; + getRouteParamNames(route) { + return route.params; }, getSourceRoute() { return sourceRoute; }, isRscRequest: true, - matchSourceRouteParams() { - return {}; - }, renderInterceptResponse, searchParams: new URLSearchParams("from=feed"), setNavigationContext, @@ -122,7 +119,7 @@ describe("app page request helpers", () => { }); it("returns intercept opts when the source route is the current route", async () => { - const currentRoute = { pattern: "/photos/[id]" }; + const currentRoute = { params: ["id"], pattern: "/photos/[id]" }; const result = await resolveAppPageIntercept({ async buildPageElement() { @@ -138,16 +135,13 @@ describe("app page request helpers", () => { sourceRouteIndex: 0, }; }, - getRoutePattern(route) { - return route.pattern; + getRouteParamNames(route) { + return route.params; }, getSourceRoute() { return currentRoute; }, isRscRequest: true, - matchSourceRouteParams() { - return null; - }, async renderInterceptResponse() { throw new Error("should not render a separate intercept response"); }, diff --git a/tests/app-page-route-wiring.test.ts b/tests/app-page-route-wiring.test.ts index f46ab69a..e81c6781 100644 --- a/tests/app-page-route-wiring.test.ts +++ b/tests/app-page-route-wiring.test.ts @@ -1,9 +1,9 @@ -import { createElement, isValidElement, type ReactNode } from "react"; -import ReactDOMServer from "react-dom/server"; +import { Fragment, createElement, isValidElement, type ReactNode } from "react"; import { describe, expect, it } from "vite-plus/test"; import { useSelectedLayoutSegments } from "../packages/vinext/src/shims/navigation.js"; +import type { AppElements } from "../packages/vinext/src/server/app-elements.js"; import { - buildAppPageRouteElement, + buildAppPageElements, createAppPageLayoutEntries, resolveAppPageChildSegments, } from "../packages/vinext/src/server/app-page-route-wiring.js"; @@ -34,6 +34,63 @@ function readChildren(value: unknown): ReactNode { return null; } +async function readStream(stream: ReadableStream): Promise { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let text = ""; + + for (;;) { + const { done, value } = await reader.read(); + if (done) { + break; + } + text += decoder.decode(value, { stream: true }); + } + + return text + decoder.decode(); +} + +async function renderHtml(node: ReactNode): Promise { + const { renderToReadableStream } = await import("react-dom/server.edge"); + const stream = await renderToReadableStream(node, { + onError(error: unknown) { + throw error instanceof Error ? error : new Error(String(error)); + }, + }); + + return readStream(stream); +} + +async function renderRouteEntry(elements: AppElements, routeId: string): Promise { + const { ElementsContext, Slot } = await import("../packages/vinext/src/shims/slot.js"); + return renderHtml( + createElement( + ElementsContext.Provider, + { value: elements }, + createElement(Slot, { id: routeId }), + ), + ); +} + +async function withTimeout(promise: Promise, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + reject(new Error(`Timed out after ${timeoutMs}ms`)); + }, timeoutMs); + + promise.then( + (value) => { + clearTimeout(timeoutId); + resolve(value); + }, + (error: unknown) => { + clearTimeout(timeoutId); + reject(error); + }, + ); + }); +} + function RootLayout(props: Record) { const segments = useSelectedLayoutSegments(); return createElement( @@ -80,6 +137,10 @@ function PageProbe() { return createElement("main", { "data-page-segments": segments.join("|") }, "Page"); } +function LayoutWithoutChildren() { + return createElement("div", { "data-layout": "without-children" }, "Layout only"); +} + describe("app page route wiring helpers", () => { it("resolves child segments from tree positions and preserves route groups", () => { expect( @@ -90,29 +151,6 @@ describe("app page route wiring helpers", () => { ).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 }], @@ -125,8 +163,8 @@ describe("app page route wiring helpers", () => { expect(entries.map((entry) => entry.treePath)).toEqual(["/", "/(marketing)"]); }); - it("wires templates, slots, and layout segment providers from the route tree", () => { - const element = buildAppPageRouteElement({ + it("builds a flat elements map with route, layout, template, page, and slot entries", async () => { + const elements = buildAppPageElements({ element: createElement(PageProbe), makeThenableParams(params) { return Promise.resolve(params); @@ -151,10 +189,13 @@ describe("app page route wiring helpers", () => { layoutIndex: 0, loading: null, page: { default: SlotPage }, + routeSegments: ["members"], }, }, - templates: [null, { default: GroupTemplate }], + templateTreePositions: [1], + templates: [{ default: GroupTemplate }], }, + routePath: "/blog/post", rootNotFoundModule: null, slotOverrides: { sidebar: { @@ -165,12 +206,20 @@ describe("app page route wiring helpers", () => { }, }); - const html = ReactDOMServer.renderToStaticMarkup(element); + expect(elements.__route).toBe("route:/blog/post"); + expect(elements.__rootLayout).toBe("/"); + expect(elements["layout:/"]).toBeDefined(); + expect(elements["layout:/(marketing)"]).toBeDefined(); + expect(elements["template:/(marketing)"]).toBeDefined(); + expect(elements["page:/blog/post"]).toBeDefined(); + expect(elements["slot:sidebar:/"]).toBeDefined(); + expect(elements["route:/blog/post"]).toBeDefined(); + + const html = await renderRouteEntry(elements, "route:/blog/post"); expect(html).toContain('data-layout="root"'); expect(html).toContain('data-layout="group"'); expect(html).toContain('data-template="group"'); - // GroupTemplate must be inside GroupLayout, not RootLayout const groupLayoutPos = html.indexOf('data-layout="group"'); const groupTemplatePos = html.indexOf('data-template="group"'); expect(groupLayoutPos).toBeLessThan(groupTemplatePos); @@ -181,19 +230,163 @@ describe("app page route wiring helpers", () => { expect(html).toContain('data-segments="blog|post"'); }); - it("NotFoundBoundary is nested inside Template in the element tree (Layout > Template > NotFound > Page)", () => { - // Next.js nesting per segment (outer to inner): Layout > Template > Error > NotFound > Page - // NotFoundBoundary must be INSIDE Template so that when notFound() fires, the Template - // still wraps the not-found fallback. - // - // The bug: NotFoundBoundary was placed OUTSIDE Template (wrapping order was - // Layout > NotFound > Template > Error > Page), so when notFound() triggered, - // Template got replaced instead of wrapping the NotFound fallback. - // - // We verify this by inspecting the React element tree structure directly: - // walk from the root inward and assert that RootTemplate appears at a shallower - // depth than NotFoundBoundary's inner class component. + it("does not deadlock when a layout renders without children", async () => { + const elements = buildAppPageElements({ + element: createElement("main", null, "Page content"), + makeThenableParams(params) { + return Promise.resolve(params); + }, + matchedParams: {}, + resolvedMetadata: null, + resolvedViewport: {}, + route: { + error: null, + errors: [null], + layoutTreePositions: [0], + layouts: [{ default: LayoutWithoutChildren }], + loading: null, + notFound: null, + notFounds: [null], + routeSegments: [], + slots: null, + templateTreePositions: [], + templates: [], + }, + routePath: "/layout-only", + rootNotFoundModule: null, + }); + + const body = await withTimeout(renderRouteEntry(elements, "route:/layout-only"), 1_000); + + expect(body).toContain("Layout only"); + expect(body).not.toContain("Page content"); + }); + + it("preserves route subtree when a layout entry has no default export", async () => { + const elements = buildAppPageElements({ + element: createElement("main", null, "Page content"), + makeThenableParams(params) { + return Promise.resolve(params); + }, + matchedParams: {}, + resolvedMetadata: null, + resolvedViewport: {}, + route: { + error: null, + errors: [null, null], + layoutTreePositions: [0, 1], + layouts: [{ default: RootLayout }, null], + loading: null, + notFound: null, + notFounds: [null, null], + routeSegments: ["dashboard"], + slots: null, + templateTreePositions: [], + templates: [], + }, + routePath: "/dashboard", + rootNotFoundModule: null, + }); + + const body = await renderRouteEntry(elements, "route:/dashboard"); + + expect(body).toContain('data-layout="root"'); + expect(body).toContain("Page content"); + }); + + it("waits for template-only segments before serializing the page entry", async () => { + let activeLocale = "en"; + + async function AsyncTemplate(props: Record) { + await Promise.resolve(); + activeLocale = "de"; + return createElement("div", { "data-template": "async" }, readChildren(props.children)); + } + + function LocalePage() { + return createElement("main", null, `page:${activeLocale}`); + } + const elements = buildAppPageElements({ + element: createElement(LocalePage), + makeThenableParams(params) { + return Promise.resolve(params); + }, + matchedParams: {}, + resolvedMetadata: null, + resolvedViewport: {}, + route: { + error: null, + errors: [], + layoutTreePositions: [], + layouts: [], + loading: null, + notFound: null, + notFounds: [], + routeSegments: ["blog"], + slots: null, + templateTreePositions: [1], + templates: [{ default: AsyncTemplate }], + }, + routePath: "/blog", + rootNotFoundModule: null, + }); + + const body = await renderHtml( + createElement( + Fragment, + null, + readChildren(elements["template:/blog"]), + readChildren(elements["page:/blog"]), + ), + ); + + expect(body).toContain("page:de"); + expect(body).not.toContain("page:en"); + }); + + it("renders template-only segments in the route entry even without a matching layout", async () => { + function BlogTemplate(props: Record) { + return createElement("div", { "data-template": "blog" }, readChildren(props.children)); + } + + function BlogPage() { + return createElement("main", null, "Blog page"); + } + + const elements = buildAppPageElements({ + element: createElement(BlogPage), + makeThenableParams(params) { + return Promise.resolve(params); + }, + matchedParams: {}, + resolvedMetadata: null, + resolvedViewport: {}, + route: { + error: null, + errors: [null], + layoutTreePositions: [0], + layouts: [{ default: RootLayout }], + loading: null, + notFound: null, + notFounds: [null], + routeSegments: ["blog"], + slots: null, + templateTreePositions: [1], + templates: [{ default: BlogTemplate }], + }, + routePath: "/blog", + rootNotFoundModule: null, + }); + + const body = await renderRouteEntry(elements, "route:/blog"); + + expect(body).toContain('data-layout="root"'); + expect(body).toContain('data-template="blog"'); + expect(body).toContain("Blog page"); + }); + + it("nests per-segment NotFoundBoundary inside the template wrapper", () => { function RootNotFound() { return createElement("div", { "data-not-found": "root" }, "Not Found"); } @@ -202,7 +395,7 @@ describe("app page route wiring helpers", () => { return createElement("main", null, "Page"); } - const element = buildAppPageRouteElement({ + const elements = buildAppPageElements({ element: createElement(LeafPage), makeThenableParams(params) { return Promise.resolve(params); @@ -220,68 +413,60 @@ describe("app page route wiring helpers", () => { notFounds: [{ default: RootNotFound }], routeSegments: ["blog"], slots: {}, + templateTreePositions: [0], templates: [{ default: RootTemplate }], }, + routePath: "/blog", rootNotFoundModule: null, }); - // Walk the React element tree depth-first, recording the depth at which each - // component type first appears. We find the depth of RootTemplate and the depth - // of the NotFoundBoundary inner class (identified by looking for an element whose - // type has a displayName or name containing "NotFound"). function walkDepth(node: unknown, depth: number, found: Map): void { if (!isValidElement(node)) return; - const el = node as { type: unknown; props: Record }; + const element = node as { type: unknown; props: Record }; + + if (typeof element.props.id === "string" && element.props.id.startsWith("template:")) { + found.set(`template:${element.props.id}`, depth); + } const typeName = - typeof el.type === "function" - ? ((el.type as { displayName?: string; name?: string }).displayName ?? - (el.type as { name?: string }).name ?? + typeof element.type === "function" + ? ((element.type as { displayName?: string; name?: string }).displayName ?? + (element.type as { name?: string }).name ?? "") - : typeof el.type === "string" - ? el.type + : typeof element.type === "string" + ? element.type : ""; if (!found.has(typeName)) { found.set(typeName, depth); } - const { children, ...rest } = el.props; - for (const val of Object.values(rest)) { - walkDepth(val, depth + 1, found); + const { children, ...rest } = element.props; + for (const value of Object.values(rest)) { + walkDepth(value, depth + 1, found); } if (Array.isArray(children)) { - for (const child of children) walkDepth(child, depth + 1, found); + for (const child of children) { + walkDepth(child, depth + 1, found); + } } else { walkDepth(children, depth + 1, found); } } const depthMap = new Map(); - walkDepth(element, 0, depthMap); + walkDepth(elements["route:/blog"], 0, depthMap); - const templateDepth = depthMap.get("RootTemplate"); - // NotFoundBoundary renders as NotFoundBoundaryInner at the class level. - // We search for the class component by its name. + const templateDepth = depthMap.get("template:template:/"); const notFoundDepth = depthMap.get("NotFoundBoundaryInner") ?? depthMap.get("NotFoundBoundary"); expect(templateDepth).toBeDefined(); expect(notFoundDepth).toBeDefined(); - - // Template must be shallower (closer to root) than NotFoundBoundary. - // If this fails, NotFoundBoundary is outside Template — the bug. expect(templateDepth).toBeLessThan(notFoundDepth!); }); - it("interleaves templates with their corresponding layouts (Layout[i] > Template[i])", () => { - // Next.js nesting order per segment: Layout > Template > ErrorBoundary > children - // With two levels, the correct tree is: - // Layout[0] > Template[0] > Layout[1] > Template[1] > Page - // - // The bug was: Layout[0] > Layout[1] > Template[0] > Template[1] > Page - // (all templates grouped as a batch, then all layouts grouped separately) - - const element = buildAppPageRouteElement({ + it("interleaves templates with their corresponding layouts", async () => { + const elements = buildAppPageElements({ element: createElement(PageProbe), makeThenableParams(params) { return Promise.resolve(params); @@ -299,33 +484,29 @@ describe("app page route wiring helpers", () => { notFounds: [null, null], routeSegments: ["(marketing)", "blog", "[slug]"], slots: {}, + templateTreePositions: [0, 1], templates: [{ default: RootTemplate }, { default: GroupTemplate }], }, + routePath: "/blog/post", rootNotFoundModule: null, }); - const html = ReactDOMServer.renderToStaticMarkup(element); + const html = await renderRouteEntry(elements, "route:/blog/post"); - // Both layouts and templates must be present expect(html).toContain('data-layout="root"'); expect(html).toContain('data-layout="group"'); expect(html).toContain('data-template="root"'); expect(html).toContain('data-template="group"'); - // Verify interleaving order: Layout[0] > Template[0] > Layout[1] > Template[1] > Page const rootLayoutPos = html.indexOf('data-layout="root"'); const rootTemplatePos = html.indexOf('data-template="root"'); const groupLayoutPos = html.indexOf('data-layout="group"'); const groupTemplatePos = html.indexOf('data-template="group"'); const pagePos = html.indexOf("data-page-segments="); - // Root layout wraps root template expect(rootLayoutPos).toBeLessThan(rootTemplatePos); - // Root template wraps group layout (NOT: group layout wraps root template) expect(rootTemplatePos).toBeLessThan(groupLayoutPos); - // Group layout wraps group template expect(groupLayoutPos).toBeLessThan(groupTemplatePos); - // Group template wraps page expect(groupTemplatePos).toBeLessThan(pagePos); }); }); diff --git a/tests/app-render-dependency.test.ts b/tests/app-render-dependency.test.ts new file mode 100644 index 00000000..03f57da3 --- /dev/null +++ b/tests/app-render-dependency.test.ts @@ -0,0 +1,83 @@ +import { createElement } from "react"; +import { renderToReadableStream } from "react-dom/server.edge"; +import { describe, expect, it } from "vite-plus/test"; +import { + createAppRenderDependency, + renderAfterAppDependencies, + renderWithAppDependencyBarrier, +} from "../packages/vinext/src/server/app-render-dependency.js"; + +async function readStream(stream: ReadableStream): Promise { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let text = ""; + + for (;;) { + const { done, value } = await reader.read(); + if (done) { + break; + } + text += decoder.decode(value, { stream: true }); + } + + return text + decoder.decode(); +} + +async function renderHtml(element: React.ReactNode): Promise { + const stream = await renderToReadableStream(element, { + onError(error: unknown) { + throw error instanceof Error ? error : new Error(String(error)); + }, + }); + await stream.allReady; + return readStream(stream); +} + +describe("app render dependency helpers", () => { + it("documents that React can render a sync sibling before an async sibling completes", async () => { + let activeLocale = "en"; + + async function LocaleLayout() { + await Promise.resolve(); + activeLocale = "de"; + return createElement("div", null, "layout"); + } + + function LocalePage() { + return createElement("p", null, `page:${activeLocale}`); + } + + const body = await renderHtml( + createElement("div", null, createElement(LocaleLayout), createElement(LocalePage)), + ); + + expect(body).toContain("page:en"); + }); + + it("waits to serialize dependent entries until the barrier entry has rendered", async () => { + let activeLocale = "en"; + const layoutDependency = createAppRenderDependency(); + + async function LocaleLayout() { + await Promise.resolve(); + activeLocale = "de"; + return createElement("div", null, renderWithAppDependencyBarrier("layout", layoutDependency)); + } + + function LocalePage() { + return createElement("p", null, `page:${activeLocale}`); + } + + const body = await renderHtml( + createElement( + "div", + null, + createElement(LocaleLayout), + renderAfterAppDependencies(createElement(LocalePage), [layoutDependency]), + ), + ); + + expect(body).toContain("page:de"); + expect(body).not.toContain("page:en"); + }); +}); diff --git a/tests/app-router.test.ts b/tests/app-router.test.ts index 5f4012df..79dafd27 100644 --- a/tests/app-router.test.ts +++ b/tests/app-router.test.ts @@ -128,6 +128,24 @@ describe("App Router integration", () => { expect(text.length).toBeGreaterThan(0); }); + it("returns flat payload metadata for app route RSC responses", async () => { + const res = await fetch(`${baseUrl}/dashboard.rsc`, { + headers: { Accept: "text/x-component" }, + }); + const rscText = await res.text(); + if (res.status !== 200) { + throw new Error(rscText); + } + expect(res.headers.get("content-type")).toContain("text/x-component"); + expect(rscText).toContain("__route"); + expect(rscText).toContain("__rootLayout"); + expect(rscText).toContain("route:/dashboard"); + expect(rscText).toContain("layout:/"); + expect(rscText).toContain("layout:/dashboard"); + expect(rscText).toContain("slot:team:/dashboard"); + expect(rscText).toContain("slot:analytics:/dashboard"); + }); + it("wraps pages in the root layout", async () => { const res = await fetch(`${baseUrl}/about`); const html = await res.text(); @@ -4133,6 +4151,13 @@ describe("generateRscEntry ISR code generation", () => { const redirectEnd = code.indexOf('return new Response(""', redirectStart); const redirectBody = code.slice(redirectStart, redirectEnd); expect(redirectBody).toContain("mergeMiddlewareResponseHeaders"); + // Framework-owned redirect headers must be written after the middleware merge + // so middleware cannot clobber the target URL or redirect type. + const mergeIndex = redirectBody.indexOf("__mergeMiddlewareResponseHeaders"); + const redirectHeaderIndex = redirectBody.indexOf('redirectHeaders.set("x-action-redirect"'); + expect(mergeIndex).toBeGreaterThan(-1); + expect(redirectHeaderIndex).toBeGreaterThan(-1); + expect(mergeIndex).toBeLessThan(redirectHeaderIndex); }); // Ported from Next.js: packages/next/src/client/components/redirect.ts diff --git a/tests/e2e/app-router/instrumentation.spec.ts b/tests/e2e/app-router/instrumentation.spec.ts index 6c9ccdd0..68b78b52 100644 --- a/tests/e2e/app-router/instrumentation.spec.ts +++ b/tests/e2e/app-router/instrumentation.spec.ts @@ -62,9 +62,9 @@ test.describe("instrumentation.ts onRequestError", () => { const data = await stateRes.json(); expect(data.errors.length).toBeGreaterThanOrEqual(1); - const err = data.errors[data.errors.length - 1]; + const err = data.errors.find((e: { path: string }) => e.path === "/api/error-route"); + expect(err).toBeTruthy(); expect(err.message).toBe("Intentional route handler error"); - expect(err.path).toBe("/api/error-route"); expect(err.method).toBe("GET"); expect(err.routerKind).toBe("App Router"); expect(err.routeType).toBe("route"); diff --git a/tests/entry-templates.test.ts b/tests/entry-templates.test.ts index 424741b4..7b7f0267 100644 --- a/tests/entry-templates.test.ts +++ b/tests/entry-templates.test.ts @@ -48,6 +48,7 @@ const minimalAppRoutes: AppRoute[] = [ forbiddenPath: null, unauthorizedPath: null, routeSegments: [], + templateTreePositions: [], layoutTreePositions: [0], isDynamic: false, params: [], @@ -68,6 +69,7 @@ const minimalAppRoutes: AppRoute[] = [ forbiddenPath: null, unauthorizedPath: null, routeSegments: ["about"], + templateTreePositions: [], layoutTreePositions: [0], isDynamic: false, params: [], @@ -88,6 +90,7 @@ const minimalAppRoutes: AppRoute[] = [ forbiddenPath: null, unauthorizedPath: null, routeSegments: ["blog", ":slug"], + templateTreePositions: [], layoutTreePositions: [0, 1], isDynamic: true, params: ["slug"], @@ -108,6 +111,7 @@ const minimalAppRoutes: AppRoute[] = [ forbiddenPath: null, unauthorizedPath: null, routeSegments: ["dashboard"], + templateTreePositions: [1], layoutTreePositions: [0, 1], isDynamic: false, params: [], @@ -229,20 +233,24 @@ describe("App Router entry templates", () => { expect(stabilize(code)).toMatchSnapshot(); }); - it("generateRscEntry awaits buildPageElement in the server-action re-render path", () => { + it("generateRscEntry uses buildPageElements in the server-action re-render path", () => { const code = generateRscEntry("/tmp/test/app", minimalAppRoutes, null, [], null, "", false); - // The action re-render path must await buildPageElement so that: - // 1. redirect()/notFound() thrown inside generateMetadata() becomes an HTTP - // redirect instead of an RSC stream error. - // 2. getAndClearPendingCookies() runs after the page tree resolves, not - // before (which would miss cookies set during page building). + // PR 2c returns the flat elements payload instead of the monolithic page + // element, so the action re-render path should rebuild the keyed map. const actionRerenderIdx = code.indexOf( "// After the action, re-render the current page so the client", ); expect(actionRerenderIdx).toBeGreaterThan(-1); const rerenderSlice = code.slice(actionRerenderIdx, actionRerenderIdx + 700); - expect(rerenderSlice).toContain( - "element = await buildPageElement(actionRoute, actionParams, undefined, url.searchParams);", + expect(rerenderSlice).toContain("element = buildPageElements("); + }); + + it("generateRscEntry reuses the canonical tree-path helper for no-export page payloads", () => { + const code = generateRscEntry("/tmp/test/app", minimalAppRoutes, null, [], null, "", false); + + expect(code).toContain("createAppPageTreePath as __createAppPageTreePath"); + expect(code).toContain( + "_noExportRootLayout = __createAppPageTreePath(route.routeSegments, _tp);", ); }); diff --git a/tests/error-boundary.test.ts b/tests/error-boundary.test.ts index 330cc014..49fe957c 100644 --- a/tests/error-boundary.test.ts +++ b/tests/error-boundary.test.ts @@ -181,11 +181,6 @@ describe("ErrorBoundary digest classification (actual class)", () => { 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) { @@ -214,10 +209,6 @@ describe("ErrorBoundary digest classification (actual class)", () => { }); }); - // 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) { diff --git a/tests/slot.test.ts b/tests/slot.test.ts index 5a45ac1f..8704db18 100644 --- a/tests/slot.test.ts +++ b/tests/slot.test.ts @@ -1,11 +1,11 @@ -import React, { Suspense } from "react"; +import React from "react"; import { renderToReadableStream } from "react-dom/server.edge"; import { describe, expect, it, vi } from "vite-plus/test"; +import { UNMATCHED_SLOT } from "../packages/vinext/src/server/app-elements.js"; -type Deferred = { - promise: Promise; - resolve: (value: T) => void; -}; +vi.mock("next/navigation", () => ({ + usePathname: () => "/", +})); function createContextProvider( context: React.Context, @@ -15,20 +15,6 @@ function createContextProvider( return React.createElement(context.Provider, { value }, child); } -function createDeferred(): Deferred { - let resolvePromise: ((value: T) => void) | undefined; - const promise = new Promise((resolve) => { - resolvePromise = resolve; - }); - if (!resolvePromise) { - throw new Error("Deferred promise resolver was not created"); - } - return { - promise, - resolve: resolvePromise, - }; -} - async function readStream(stream: ReadableStream): Promise { const reader = stream.getReader(); const decoder = new TextDecoder(); @@ -58,7 +44,7 @@ describe("slot primitives", () => { expect(typeof mod.Slot).toBe("function"); expect(typeof mod.Children).toBe("function"); expect(typeof mod.ParallelSlot).toBe("function"); - expect(typeof mod.mergeElementsPromise).toBe("function"); + expect(typeof mod.mergeElements).toBe("function"); expect(mod.ElementsContext).toBeDefined(); expect(mod.ChildrenContext).toBeDefined(); expect(mod.ParallelSlotsContext).toBeDefined(); @@ -97,7 +83,7 @@ describe("slot primitives", () => { const slotElement = createContextProvider( mod.ElementsContext, - Promise.resolve({ "layout:/": React.createElement(LayoutShell) }), + { "layout:/": React.createElement(LayoutShell) }, React.createElement( mod.Slot, { @@ -121,7 +107,7 @@ describe("slot primitives", () => { const html = await renderHtml( createContextProvider( mod.ElementsContext, - Promise.resolve({}), + {}, React.createElement(mod.Slot, { id: "slot:modal:/" }), ), ); @@ -129,18 +115,60 @@ describe("slot primitives", () => { expect(html).toBe(""); }); - it("Slot throws the notFound signal for an unmatched slot sentinel", async () => { + it("warns in development when a non-slot entry is absent", async () => { const mod = await import("../packages/vinext/src/shims/slot.js"); - const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + try { + const html = await renderHtml( + createContextProvider( + mod.ElementsContext, + {}, + React.createElement(mod.Slot, { id: "route:/missing" }), + ), + ); + + expect(html).toBe(""); + expect(warn).toHaveBeenCalledWith( + "[vinext] Missing App Router element entry during render: route:/missing", + ); + } finally { + warn.mockRestore(); + } + }); + + it("does not warn when an absent parallel slot key is omitted on soft navigation", async () => { + const mod = await import("../packages/vinext/src/shims/slot.js"); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); try { - const renderPromise = renderHtml( + const html = await renderHtml( createContextProvider( mod.ElementsContext, - Promise.resolve({ "slot:modal:/": mod.UNMATCHED_SLOT }), + {}, React.createElement(mod.Slot, { id: "slot:modal:/" }), ), ); + + expect(html).toBe(""); + expect(warn).not.toHaveBeenCalled(); + } finally { + warn.mockRestore(); + } + }); + + it("Slot throws the notFound signal for an unmatched slot sentinel", async () => { + const mod = await import("../packages/vinext/src/shims/slot.js"); + const renderPromise = renderHtml( + createContextProvider( + mod.ElementsContext, + { "slot:modal:/": mod.UNMATCHED_SLOT }, + React.createElement(mod.Slot, { id: "slot:modal:/" }), + ), + ); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + + try { await expect(renderPromise).rejects.toMatchObject({ digest: "NEXT_HTTP_ERROR_FALLBACK;404" }); } finally { consoleError.mockRestore(); @@ -154,7 +182,7 @@ describe("slot primitives", () => { const stream = await renderToReadableStream( createContextProvider( mod.ElementsContext, - Promise.resolve({ "slot:modal:/": null }), + { "slot:modal:/": null }, React.createElement(mod.Slot, { id: "slot:modal:/" }), ), { @@ -173,94 +201,103 @@ describe("slot primitives", () => { expect(errors).toEqual([]); }); - it("mergeElementsPromise shallow-merges previous and next elements", async () => { - const { mergeElementsPromise } = await import("../packages/vinext/src/shims/slot.js"); + it("normalizes the server unmatched-slot marker to the client sentinel", async () => { + const { normalizeAppElements, APP_UNMATCHED_SLOT_WIRE_VALUE } = + await import("../packages/vinext/src/server/app-elements.js"); + const mod = await import("../packages/vinext/src/shims/slot.js"); - const merged = await mergeElementsPromise( - Promise.resolve({ + const normalized = normalizeAppElements({ + __rootLayout: "/", + __route: "route:/dashboard", + "slot:modal:/": APP_UNMATCHED_SLOT_WIRE_VALUE, + }); + + expect(normalized["slot:modal:/"]).toBe(mod.UNMATCHED_SLOT); + }); + + it("mergeElements shallow-merges previous and next elements", async () => { + const { mergeElements } = await import("../packages/vinext/src/shims/slot.js"); + + const merged = mergeElements( + { "layout:/": React.createElement("div", null, "layout"), "slot:modal:/": React.createElement("div", null, "previous slot"), - }), - Promise.resolve({ + }, + { "page:/blog/hello": React.createElement("div", null, "page"), "slot:modal:/": React.createElement("div", null, "next slot"), - }), + }, ); expect(Object.keys(merged)).toEqual(["layout:/", "slot:modal:/", "page:/blog/hello"]); expect(merged["layout:/"]).toBeDefined(); expect(merged["page:/blog/hello"]).toBeDefined(); - // {…prev, …next} means next wins for duplicate keys - const modalSlot = merged["slot:modal:/"]; - if (!React.isValidElement(modalSlot)) { - throw new Error("Expected ReactElement for slot:modal:/"); - } - const html = await renderHtml(modalSlot); - expect(html).toContain("next slot"); + expect(merged["slot:modal:/"]).not.toBeNull(); }); - it("mergeElementsPromise caches by input promise pair", async () => { - const { mergeElementsPromise } = await import("../packages/vinext/src/shims/slot.js"); + it("mergeElements preserves previous slot content when next marks it unmatched", async () => { + const { mergeElements } = await import("../packages/vinext/src/shims/slot.js"); - const previous = Promise.resolve({ "layout:/": React.createElement("div", null, "layout") }); - const next = Promise.resolve({ "page:/blog/hello": React.createElement("div", null, "page") }); + const previousSlotContent = React.createElement("div", null, "previous modal"); + const merged = mergeElements( + { + "layout:/": React.createElement("div", null, "layout"), + "slot:modal:/": previousSlotContent, + "page:/dashboard": React.createElement("div", null, "dashboard"), + }, + { + "page:/blog": React.createElement("div", null, "blog page"), + "slot:modal:/": UNMATCHED_SLOT, + }, + ); + + // The slot should keep its previous content, not become UNMATCHED_SLOT. + // This matches Next.js soft navigation behavior: unmatched parallel slots + // preserve their previous subtree instead of showing 404. + expect(merged["slot:modal:/"]).toBe(previousSlotContent); + expect(merged["page:/blog"]).toBeDefined(); + expect(merged["layout:/"]).toBeDefined(); + }); - const first = mergeElementsPromise(previous, next); - const second = mergeElementsPromise(previous, next); - const third = mergeElementsPromise(previous, Promise.resolve({})); + it("mergeElements allows UNMATCHED_SLOT for slots absent from previous state", async () => { + const { mergeElements } = await import("../packages/vinext/src/shims/slot.js"); - expect(first).toBe(second); - expect(first).not.toBe(third); + const merged = mergeElements( + { + "layout:/": React.createElement("div", null, "layout"), + "page:/": React.createElement("div", null, "home"), + }, + { + "page:/blog": React.createElement("div", null, "blog"), + "slot:modal:/": UNMATCHED_SLOT, + }, + ); + + // No previous value to preserve — the sentinel passes through. + expect(merged["slot:modal:/"]).toBe(UNMATCHED_SLOT); }); - it("Slot suspends on the elements promise and streams the Suspense fallback first", async () => { + it("Slot renders element from resolved context", async () => { const mod = await import("../packages/vinext/src/shims/slot.js"); - const deferred = createDeferred>>(); const stream = await renderToReadableStream( - React.createElement( - Suspense, - { fallback: React.createElement("p", null, "loading slot") }, - createContextProvider( - mod.ElementsContext, - deferred.promise, - React.createElement(mod.Slot, { id: "layout:/" }), - ), + createContextProvider( + mod.ElementsContext, + { "layout:/": React.createElement("div", null, "resolved slot") }, + React.createElement(mod.Slot, { id: "layout:/" }), ), ); const reader = stream.getReader(); const decoder = new TextDecoder(); - const firstChunkPromise = reader.read(); - - // Verify the stream is suspended — reader.read() should not resolve synchronously - // because React.use() on the unresolved deferred throws to trigger Suspense. - // NOTE: Promise.race between two microtasks is engine-dependent, but reliable here - // because renderToReadableStream won't enqueue any chunk while the component is suspended. - const firstReadState = await Promise.race([ - firstChunkPromise.then(() => "resolved"), - Promise.resolve("pending"), - ]); - expect(firstReadState).toBe("pending"); - - // Resolve the deferred so the stream can flush - deferred.resolve({ - "layout:/": React.createElement("div", null, "resolved slot"), - }); - - const firstChunk = await firstChunkPromise; - const firstHtml = decoder.decode(firstChunk.value, { stream: true }); - - let rest = ""; + let html = ""; for (;;) { const { done, value } = await reader.read(); - if (done) { - break; - } - rest += decoder.decode(value, { stream: true }); + if (done) break; + html += decoder.decode(value, { stream: true }); } - rest += decoder.decode(); + html += decoder.decode(); - expect(firstHtml + rest).toContain("resolved slot"); - }, 10000); + expect(html).toContain("resolved slot"); + }); });