From c0774122bedf7f9b3641e21449cdada9a84f7f67 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 25 Sep 2026 22:18:57 +0100 Subject: [PATCH 1/7] fix(app-router): classify and render an intercepted page by its intercepting tree A direct intercepted RSC response, and its regeneration, Server Action rerender and params gate, take their segment config from the intercepting route's own tree, as Next.js does, instead of the source or matched route's. Next.js's isDynamicRoute classifies an intercepting path by the route its folders intercept. Squashed from: - fix(app-router): classify and render an intercepted page by its intercepting tree - fix(app-router): keep a sibling intercept's folders between its source and the marker - fix(app-router): gate an intercepting tree's own dynamic segments under a static route - fix(app-router): gate an intercepting tree's omitted optional catch-all - fix(app-router): classify a promoted interception-only target by its branch's dynamic segments - fix(app-router): skip loading a slot intercept its source has no slot for - fix(app-router): load a slot intercept's owner default for a rerender - fix(app-router): reduce an intercepted response's two config trees separately - fix(app-router): classify an intercepting tree by its own segments, not the matched target - fix(app-router): resolve an intercepted action rerender's config from the intercepting tree - fix(app-router): classify an intercepting route by the route its folders intercept --- .../vinext/src/build/layout-classification.ts | 2 +- packages/vinext/src/entries/app-rsc-entry.ts | 302 ++- .../vinext/src/entries/app-rsc-manifest.ts | 3 + .../vinext/src/routing/app-route-graph.ts | 126 +- packages/vinext/src/server/app-page-cache.ts | 16 +- .../vinext/src/server/app-page-dispatch.ts | 474 +++- .../src/server/app-page-element-builder.ts | 24 +- .../server/app-page-interception-markers.ts | 7 + packages/vinext/src/server/app-page-params.ts | 29 + .../src/server/app-page-prefetch-plan.ts | 210 ++ packages/vinext/src/server/app-page-probe.ts | 410 ++- .../vinext/src/server/app-page-request.ts | 217 +- .../src/server/app-page-route-wiring.tsx | 164 +- .../src/server/app-route-module-loader.ts | 22 +- .../src/server/app-rsc-route-matching.ts | 10 + .../vinext/src/server/app-segment-config.ts | 319 ++- .../src/server/app-server-action-execution.ts | 44 +- tests/app-page-cache.test.ts | 37 +- tests/app-page-dispatch.test.ts | 2375 +++++++++++++++-- tests/app-page-element-builder.test.ts | 85 + tests/app-page-probe.test.ts | 654 ++++- tests/app-page-request.test.ts | 356 +++ tests/app-page-route-wiring.test.ts | 6 +- tests/app-route-graph.test.ts | 76 + tests/app-route-module-loader.test.ts | 31 + tests/app-router-dev-server.test.ts | 85 + tests/app-router-isr-codegen.test.ts | 593 +++- tests/app-segment-config.test.ts | 753 ++++++ tests/app-server-action-execution.test.ts | 84 + tests/entry-templates.test.ts | 2 +- 30 files changed, 6900 insertions(+), 616 deletions(-) create mode 100644 packages/vinext/src/server/app-page-interception-markers.ts create mode 100644 packages/vinext/src/server/app-page-prefetch-plan.ts diff --git a/packages/vinext/src/build/layout-classification.ts b/packages/vinext/src/build/layout-classification.ts index d1b4c9a533..2b3a6b8c43 100644 --- a/packages/vinext/src/build/layout-classification.ts +++ b/packages/vinext/src/build/layout-classification.ts @@ -15,7 +15,7 @@ import { classifyLayoutSegmentConfig } from "./report.js"; import { AppElementsWire } from "../server/app-elements.js"; -import { createAppPageTreePath } from "../server/app-page-route-wiring.js"; +import { createAppPageTreePath } from "../server/app-page-params.js"; import type { ClassificationReason, LayoutBuildClassification, diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index c3df056e14..6f4df9f85c 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -821,7 +821,11 @@ import { resolveAppPageChildSegments as __resolveAppPageChildSegments, } from ${JSON.stringify(appPageRouteWiringPath)}; import { buildPageElements as __buildPageElements } from ${JSON.stringify(appPageElementBuilderPath)}; -import { buildAppPageProbes as __buildAppPageProbes } from ${JSON.stringify(appPageProbePath)}; +import { + buildAppPageInterceptSourceProbes as __buildAppPageInterceptSourceProbes, + buildAppPageProbes as __buildAppPageProbes, + resolveAppPageProbeIntercept as __resolveAppPageProbeIntercept, +} from ${JSON.stringify(appPageProbePath)}; import { dispatchAppPage as __dispatchAppPage, } from ${JSON.stringify(appPageDispatchPath)}; @@ -838,10 +842,15 @@ import { } from ${JSON.stringify(appPageRequestPath)}; import { collectAppPageStaticGenerationRuntimes as __collectAppPageStaticGenerationRuntimes, + hasAppPageAnyGenerateStaticParams as __hasAppPageAnyGenerateStaticParams, hasAppPageGenerateStaticParamsAtLastDynamicSegment as __hasAppPageGenerateStaticParamsAtLastDynamicSegment, + isAppPageInterceptTargetDynamic as __isAppPageInterceptTargetDynamic, + isAppPageInterceptAttached as __isAppPageInterceptAttached, isAppPageStaticEligible as __isAppPageStaticEligible, isEdgeRuntime as __isEdgeRuntime, resolveAppPageFetchCacheMode as __resolveAppPageFetchCacheMode, + resolveAppPageInterceptSegmentConfig as __resolveAppPageInterceptSegmentConfig, + resolveAppPageInterceptTree as __resolveAppPageInterceptTree, resolveAppPageSegmentConfig as __resolveAppPageSegmentConfig, resolveAppPageStaticGenerationRuntime as __resolveAppPageStaticGenerationRuntime, } from ${JSON.stringify(appSegmentConfigPath)}; @@ -882,7 +891,7 @@ import { suppressHookWarningAls } from ${JSON.stringify(appHookWarningSuppressio import { clearAppRequestContext as __clearRequestContext, setAppNavigationContext as setNavigationContext } from ${JSON.stringify(appRequestContextPath)}; __configureMemoryCacheHandler({ cacheMaxMemorySize: ${JSON.stringify(cacheMaxMemorySize)} }); import { createAppPrerenderStaticParamsResolver as __createAppPrerenderStaticParamsResolver } from ${JSON.stringify(appPrerenderStaticParamsPath)}; -import { ensureAppRouteModulesLoaded as __ensureRouteLoaded, loadAppInterceptPage as __loadAppInterceptPage } from ${JSON.stringify(appRouteModuleLoaderPath)}; +import { ensureAppRouteModulesLoaded as __ensureRouteLoaded, loadAppInterceptLayouts as __loadAppInterceptLayouts, loadAppInterceptPage as __loadAppInterceptPage } from ${JSON.stringify(appRouteModuleLoaderPath)}; import { getRenderedConcreteUrlPathsForRoute as __getRenderedConcreteUrlPathsForRoute, initPregeneratedPathsFromGlobals as __initPregeneratedPathsFromGlobals, @@ -920,7 +929,12 @@ const __classDebug = process.env.VINEXT_DEBUG_CLASSIFICATION } : undefined; -function __resolveRouteFetchCacheMode(route) { +// With an intercept, the tree a direct intercepted RSC response renders, from +// __resolveRouteInterceptSegmentConfig. +function __resolveRouteFetchCacheMode(route, intercept) { + if (intercept) { + return __resolveRouteInterceptSegmentConfig(route, intercept).fetchCache ?? null; + } return __resolveAppPageFetchCacheMode({ layouts: route.layouts, page: route.page, @@ -932,7 +946,12 @@ function __resolveRouteFetchCacheMode(route) { }); } -function __resolveRouteDynamicConfig(route) { +// With an intercept, the tree a direct intercepted RSC response renders, from +// __resolveRouteInterceptSegmentConfig. +function __resolveRouteDynamicConfig(route, intercept) { + if (intercept) { + return __resolveRouteInterceptSegmentConfig(route, intercept).dynamicConfig ?? null; + } return __resolveAppPageSegmentConfig({ layouts: route.layouts, page: route.page, @@ -944,7 +963,12 @@ function __resolveRouteDynamicConfig(route) { }).dynamicConfig ?? null; } -function __resolveRouteRevalidateSeconds(route) { +// With an intercept, the tree a direct intercepted RSC response renders, from +// __resolveRouteInterceptSegmentConfig. +function __resolveRouteRevalidateSeconds(route, intercept) { + if (intercept) { + return __resolveRouteInterceptSegmentConfig(route, intercept).revalidateSeconds; + } return __resolveAppPageSegmentConfig({ layouts: route.layouts, page: route.page, @@ -956,6 +980,16 @@ function __resolveRouteRevalidateSeconds(route) { }).revalidateSeconds; } +// With an intercept, the tree a direct intercepted RSC response renders, from +// __resolveRouteInterceptSegmentConfig. +function __resolveRouteDynamicStaleTimeSeconds(route, intercept) { + if (intercept) { + return __resolveRouteInterceptSegmentConfig(route, intercept).dynamicStaleTimeSeconds; + } + return __resolveRouteSegmentConfig(route, __resolveRouteSegmentConfigBranches(route)) + .dynamicStaleTimeSeconds; +} + function __resolveRouteRuntime(route) { return __resolveAppPageSegmentConfig({ layouts: route.layouts, @@ -973,6 +1007,7 @@ function __resolveRouteSegmentConfigBranches(route) { layout: slot.layout, configLayouts: slot.configLayouts, configLayoutTreePositions: slot.configLayoutTreePositions, + default: slot.default, isDefault: !slot.page, name: slot.name, ownerTreePosition: slot.ownerTreePosition, @@ -1019,15 +1054,165 @@ function __resolveRouteStaticGeneration(route, segmentConfigBranches) { }; } +// The tree a direct intercepted RSC response renders: the source route with +// the intercepting branch in the intercepted slot, or in place of the source's +// page for a sibling-page intercept. +function __resolveRouteInterceptTree(route, intercept, keepActiveSiblings = false) { + const tree = __resolveAppPageInterceptTree( + __resolveRouteInterceptTreeOptions(route, intercept, keepActiveSiblings), + ); + return { branches: tree.parallelBranches, route: { ...route, ...tree } }; +} + +function __resolveRouteInterceptTreeOptions(route, intercept, keepActiveSiblings = false) { + return { + childrenSlot: route.childrenSlot, + interceptBranchSegments: intercept.interceptBranchSegments, + interceptLayoutSegments: intercept.interceptLayoutSegments, + interceptLayouts: intercept.interceptLayouts, + interceptOwnerDefault: intercept.interceptOwnerDefault, + interceptPage: intercept.interceptPage, + isSiblingPageIntercept: intercept.interceptSlotKey === __SIBLING_PAGE_INTERCEPT_SLOT_KEY, + keepActiveSiblings, + layouts: route.layouts, + layoutTreePositions: route.layoutTreePositions, + page: route.page, + parallelBranches: __resolveRouteSegmentConfigBranches(route), + routeSegments: route.routeSegments, + slotIndex: Object.keys(route.slots ?? {}).indexOf(intercept.interceptSlotKey), + }; +} + +// The segment config of a direct intercepted RSC response: Next.js's tree for +// the intercepting route, which swaps the other slots, and a slot intercept's +// children, for their defaults, merged with the active pages vinext renders +// in their place, so the render and its cache entry cover both. +function __resolveRouteInterceptSegmentConfig(route, intercept) { + const [interceptTree, renderedTree] = [false, true].map((keepActiveSiblings) => { + const tree = __resolveRouteInterceptTree(route, intercept, keepActiveSiblings); + return { + layouts: tree.route.layouts, + page: tree.route.page, + parallelBranches: tree.branches, + }; + }); + return __resolveAppPageInterceptSegmentConfig(interceptTree, renderedTree); +} + // Whether Next.js classifies a route as static or SSG, from the same inputs -// dispatch reads for the matched route. -function __resolveRouteStaticEligible(route) { - const segmentConfigBranches = __resolveRouteSegmentConfigBranches(route); - const segmentConfig = __resolveRouteSegmentConfig(route, segmentConfigBranches); +// dispatch reads for the matched route. With an intercept, the route is the +// source a direct intercepted RSC response renders, and the intercepting +// branch takes the intercepted slot, or the source's page for a sibling-page +// intercept: Next.js classifies the intercepting route's own tree, which is +// dynamic when the route it intercepts, built from its folders, is. Next.js's tree +// swaps the other slots, and a slot intercept's children, for their defaults, +// but vinext renders their active pages, so both trees must be static. A slot +// intercept whose slot the source lacks renders the source unchanged, so the +// source alone classifies it. +function __resolveRouteStaticEligible(route, intercept) { + if (!intercept) return __isRouteTreeStaticEligible(route, null, null); + if (!__isAppPageInterceptAttached(__resolveRouteInterceptTreeOptions(route, intercept))) { + return __isRouteTreeStaticEligible(route, null, null); + } + return [false, true].every((keepActiveSiblings) => + __isRouteTreeStaticEligible( + route, + __resolveRouteInterceptTree(route, intercept, keepActiveSiblings), + intercept, + ), + ); +} + +// Whether any segment of the tree a direct intercepted RSC response renders +// exports generateStaticParams, which sets that tree's revalidate default like +// the matched route's hasAnyGenerateStaticParams. Next.js calls the generators +// of the intercepting route's own tree, not those of the active sibling pages +// vinext renders where that tree has their defaults. +function __resolveRouteHasAnyGenerateStaticParams(route, intercept) { + const tree = __resolveRouteInterceptTree(route, intercept); + return __hasAppPageAnyGenerateStaticParams({ + childrenSlot: tree.route.childrenSlot, + layouts: tree.route.layouts, + layoutTreePositions: tree.route.layoutTreePositions, + page: tree.route.page, + parallelBranches: tree.branches, + routeSegments: tree.route.routeSegments, + }); +} + +// The segment config of the intercepting route's own tree, for the +// generated-param gate of a direct intercepted RSC response. Next.js builds +// that route's prerender manifest entry from its own loader tree, not the +// branch it replaces or the active sibling pages vinext renders where that +// tree has their defaults. +function __resolveRouteInterceptTreeSegmentConfig(route, intercept) { + const tree = __resolveRouteInterceptTree(route, intercept); + return __resolveAppPageSegmentConfig({ + layouts: tree.route.layouts, + layoutTreePositions: tree.route.layoutTreePositions, + page: tree.route.page, + parallelBranches: tree.branches, + routeSegments: tree.route.routeSegments, + }); +} + +// Only a segment of the intercepting route's own tree turns its fallback off. +function __resolveRouteDynamicParamsConfig(route, intercept) { + return __resolveRouteInterceptTreeSegmentConfig(route, intercept).dynamicParamsConfig; +} + +// Only the intercepting route's own force-dynamic leaves it out of the +// prerender manifest, and so out of the generated-param gate, in production. +function __resolveRouteInterceptTreeDynamicConfig(route, intercept) { + return __resolveRouteInterceptTreeSegmentConfig(route, intercept).dynamicConfig ?? null; +} + +// The generateStaticParams sources a route's generated-param gate checks. +// With an intercept, those of the tree a direct intercepted RSC response +// renders: Next.js calls the generators of the intercepting route's own tree. +// A slot intercept whose slot the source lacks renders the source unchanged. +function __resolveRouteGenerateStaticParams(route, intercept) { + if ( + intercept && + __isAppPageInterceptAttached(__resolveRouteInterceptTreeOptions(route, intercept)) + ) { + const tree = __resolveRouteInterceptTree(route, intercept); + return __resolveAppPageGenerateStaticParamsSources({ + layouts: tree.route.layouts, + layoutTreePositions: tree.route.layoutTreePositions, + page: tree.route.page, + parallelBranches: tree.branches, + routeSegments: tree.route.routeSegments, + }); + } + return __resolveAppPageGenerateStaticParamsSources({ + layouts: route.layouts, + layoutTreePositions: route.layoutTreePositions, + page: route.page, + parallelBranches: Object.values(route.slots ?? {}).map((slot) => ({ + layout: slot.layout, + configLayouts: slot.configLayouts, + configLayoutTreePositions: slot.configLayoutTreePositions, + page: slot.page ?? slot.default, + paramNames: slot.slotParamNames, + patternParts: slot.slotPatternParts, + routeSegments: slot.routeSegments, + })), + routePatternParts: route.patternParts, + routeSegments: route.routeSegments, + }); +} + +function __isRouteTreeStaticEligible(route, tree, intercept) { + const effectiveRoute = tree?.route ?? route; + const segmentConfigBranches = tree?.branches ?? __resolveRouteSegmentConfigBranches(route); + const segmentConfig = __resolveRouteSegmentConfig(effectiveRoute, segmentConfigBranches); return __isAppPageStaticEligible({ - ...__resolveRouteStaticGeneration(route, segmentConfigBranches), + ...__resolveRouteStaticGeneration(effectiveRoute, segmentConfigBranches), dynamicConfig: segmentConfig.dynamicConfig, - isDynamicRoute: route.isDynamic, + isDynamicRoute: intercept + ? __isAppPageInterceptTargetDynamic(intercept.interceptTargetPatternParts) + : route.isDynamic, revalidateSeconds: segmentConfig.revalidateSeconds, }); } @@ -1342,22 +1527,7 @@ ${responseStageOnly ? "const __responseStageOptions = {" : "const __appRscHandle const __segmentConfigBranches = __resolveRouteSegmentConfigBranches(route); const __segmentConfig = __resolveRouteSegmentConfig(route, __segmentConfigBranches); const __staticGeneration = __resolveRouteStaticGeneration(route, __segmentConfigBranches); - const __generateStaticParams = __resolveAppPageGenerateStaticParamsSources({ - layouts: route.layouts, - layoutTreePositions: route.layoutTreePositions, - page: route.page, - parallelBranches: Object.values(route.slots ?? {}).map((slot) => ({ - layout: slot.layout, - configLayouts: slot.configLayouts, - configLayoutTreePositions: slot.configLayoutTreePositions, - page: slot.page ?? slot.default, - paramNames: slot.slotParamNames, - patternParts: slot.slotPatternParts, - routeSegments: slot.routeSegments, - })), - routePatternParts: route.patternParts, - routeSegments: route.routeSegments, - }); + const __generateStaticParams = __resolveRouteGenerateStaticParams(route); const _asyncRouteParams = makeThenableParams(params); return __dispatchAppPage({ basePath: __basePath, @@ -1465,15 +1635,16 @@ ${responseStageOnly ? "const __responseStageOptions = {" : "const __appRscHandle }); }, async probePage(probeSearchParams = searchParams) { - const __probeIntercept = findIntercept( - interceptionPathname, - interceptionContext, - interceptionId, + // An intercept the route has no slot for renders nothing, so it is + // neither loaded nor probed, as on the render path. + const __probeIntercept = __resolveAppPageProbeIntercept( + route, + findIntercept(interceptionPathname, interceptionContext, interceptionId), ); // The intercepting-route page module is lazy (page: null + __pageLoader). // Resolve it before probing so buildAppPageProbes inspects the real page // component for dynamic bailout — matching the render path, which also - // hydrates it (resolveAppPageInterceptState). Without this the intercept + // hydrates it (resolveAppPageIntercept). Without this the intercept // probe branch silently inspects an undefined component and never // observes the page's searchParams/headers access. Shared loader, so // the import is isolated from the request context here too. @@ -1489,6 +1660,29 @@ ${responseStageOnly ? "const __responseStageOptions = {" : "const __appRscHandle makeThenableParams, })); }, + async probeInterceptSource(sourceRoute, sourceParams, sourceSearchParams) { + const __probeIntercept = findIntercept( + interceptionPathname, + interceptionContext, + interceptionId, + ); + if (__probeIntercept) { + await Promise.all([ + __loadAppInterceptPage(__probeIntercept), + __loadAppInterceptLayouts(__probeIntercept), + ]); + } + return Promise.all(__buildAppPageInterceptSourceProbes({ + route: sourceRoute, + pageComponent: sourceRoute.page?.default, + intercept: __probeIntercept, + sourceParams, + searchParams: sourceSearchParams, + mountedSlotsHeader, + renderMode, + makeThenableParams, + })); + }, renderErrorBoundaryPage(renderErr, errorOrigin) { const __activeIntercept = findIntercept( interceptionPathname, @@ -1521,17 +1715,32 @@ ${responseStageOnly ? "const __responseStageOptions = {" : "const __appRscHandle request, revalidateSeconds: __segmentConfig.revalidateSeconds, renderedPathAndSearch, - resolveRouteFetchCacheMode(targetRoute) { - return __resolveRouteFetchCacheMode(targetRoute); + resolveRouteFetchCacheMode(targetRoute, intercept) { + return __resolveRouteFetchCacheMode(targetRoute, intercept); + }, + resolveRouteRevalidateSeconds(targetRoute, intercept) { + return __resolveRouteRevalidateSeconds(targetRoute, intercept); + }, + resolveRouteDynamicConfig(targetRoute, intercept) { + return __resolveRouteDynamicConfig(targetRoute, intercept); + }, + resolveRouteDynamicStaleTimeSeconds(targetRoute, intercept) { + return __resolveRouteDynamicStaleTimeSeconds(targetRoute, intercept); + }, + resolveRouteDynamicParamsConfig(targetRoute, intercept) { + return __resolveRouteDynamicParamsConfig(targetRoute, intercept); + }, + resolveRouteInterceptTreeDynamicConfig(targetRoute, intercept) { + return __resolveRouteInterceptTreeDynamicConfig(targetRoute, intercept); }, - resolveRouteRevalidateSeconds(targetRoute) { - return __resolveRouteRevalidateSeconds(targetRoute); + resolveRouteGenerateStaticParams(targetRoute, intercept) { + return __resolveRouteGenerateStaticParams(targetRoute, intercept); }, - resolveRouteDynamicConfig(targetRoute) { - return __resolveRouteDynamicConfig(targetRoute); + resolveRouteHasAnyGenerateStaticParams(targetRoute, intercept) { + return __resolveRouteHasAnyGenerateStaticParams(targetRoute, intercept); }, - resolveRouteStaticEligible(targetRoute) { - return __resolveRouteStaticEligible(targetRoute); + resolveRouteStaticEligible(targetRoute, intercept) { + return __resolveRouteStaticEligible(targetRoute, intercept); }, rootForbiddenModule, rootNotFoundModule, @@ -1836,14 +2045,14 @@ ${responseStageOnly ? "const __responseStageOptions = {" : "const __appRscHandle readFormDataWithLimit: __readFormDataWithLimit, renderToReadableStream, reportRequestError: _reportRequestError, - resolveRouteFetchCacheMode(targetRoute) { - return __resolveRouteFetchCacheMode(targetRoute); + resolveRouteFetchCacheMode(targetRoute, intercept) { + return __resolveRouteFetchCacheMode(targetRoute, intercept); }, - resolveRouteRevalidateSeconds(targetRoute) { - return __resolveRouteRevalidateSeconds(targetRoute); + resolveRouteRevalidateSeconds(targetRoute, intercept) { + return __resolveRouteRevalidateSeconds(targetRoute, intercept); }, - resolveRouteDynamicConfig(targetRoute) { - return __resolveRouteDynamicConfig(targetRoute); + resolveRouteDynamicConfig(targetRoute, intercept) { + return __resolveRouteDynamicConfig(targetRoute, intercept); }, resolveRouteRuntime: __resolveRouteRuntime, request, @@ -1866,6 +2075,7 @@ ${responseStageOnly ? "const __responseStageOptions = {" : "const __appRscHandle interceptNotFoundBranchSegments: intercept.interceptNotFoundBranchSegments, interceptNotFound: intercept.notFound, interceptNotFoundTreePosition: intercept.notFoundTreePosition, + interceptOwnerDefault: intercept.ownerDefault, interceptSlotId: intercept.slotId, interceptSlotKey: intercept.slotKey, interceptSourceMatchedUrl: interceptionContext, diff --git a/packages/vinext/src/entries/app-rsc-manifest.ts b/packages/vinext/src/entries/app-rsc-manifest.ts index 6e1397a35b..497bf7406f 100644 --- a/packages/vinext/src/entries/app-rsc-manifest.ts +++ b/packages/vinext/src/entries/app-rsc-manifest.ts @@ -208,6 +208,7 @@ function registerRouteModules(routes: AppRoute[], imports: ImportAllocator): voi for (const ir of slot.interceptingRoutes) { imports.getLazyLoaderVar(ir.pagePath); if (ir.notFoundPath) imports.getLazyLoaderVar(ir.notFoundPath); + if (slot.ownerDefaultPath) imports.getLazyLoaderVar(slot.ownerDefaultPath); for (const layoutPath of ir.layoutPaths) { imports.getLazyLoaderVar(layoutPath); } @@ -309,6 +310,8 @@ function buildRouteEntries(routes: AppRoute[], imports: ImportAllocator): string notFound: null, __loadNotFound: ${ir.notFoundPath ? imports.getLazyLoaderVar(ir.notFoundPath) : "null"}, notFoundTreePosition: ${ir.notFoundTreePosition ?? "null"}, + ownerDefault: null, + __loadOwnerDefault: ${slot.ownerDefaultPath ? imports.getLazyLoaderVar(slot.ownerDefaultPath) : "null"}, params: ${JSON.stringify(ir.params)}, }`, ); diff --git a/packages/vinext/src/routing/app-route-graph.ts b/packages/vinext/src/routing/app-route-graph.ts index f7f5f2255f..a362af495c 100644 --- a/packages/vinext/src/routing/app-route-graph.ts +++ b/packages/vinext/src/routing/app-route-graph.ts @@ -39,7 +39,11 @@ type InterceptingRoute = { pagePath: string; /** Filesystem segments from app/ root to the intercepting page directory. */ sourcePageSegments?: string[]; - /** Absolute layout paths inside the intercepting route tree, outermost to innermost */ + /** + * Absolute layout paths inside the intercepting route tree, outermost to + * innermost. A slot intercept's chain starts with the layouts of the slot's + * folders above the marker, below the slot root. + */ layoutPaths: string[]; /** Normalized branch segments accumulated at each intercept layout. */ layoutSegments?: string[][]; @@ -83,6 +87,11 @@ type ParallelSlot = { pagePath: string | null; /** Absolute path to the slot's default.tsx fallback */ defaultPath: string | null; + /** + * Absolute path to the owner directory's default.tsx, which Next.js puts in + * place of the owner's children when this slot intercepts. + */ + ownerDefaultPath?: string | null; /** Absolute path to the slot's layout component (wraps slot content) */ layoutPath: string | null; /** Nested active-branch layouts whose exports contribute route config. */ @@ -1576,6 +1585,51 @@ function findSlotConfigLayoutTreePositions( }); } +/** + * The layouts of the folders from below an intercept's branch root (a slot's + * root, or a sibling-page intercept's source page folder) down to the folder + * that holds its interception marker, with the root-relative segments + * accumulated at each. Next.js builds the intercepting route's loader tree + * from every folder on the intercepting page's path, each with its layout, so + * these wrap the marker's branch, and its page and metadata. + * https://github.com/vercel/next.js/blob/v16.2.7/crates/next-core/src/app_structure.rs#L1182-L1260 + */ +function findInterceptAncestorLayoutEntries( + branchRootDir: string, + interceptParentDir: string, + matcher: ValidFileMatcher, +): { path: string; segments: string[] }[] { + const segments = path.relative(branchRootDir, interceptParentDir).split(path.sep).filter(Boolean); + const layouts: { path: string; segments: string[] }[] = []; + let currentDir = branchRootDir; + for (const [index, segment] of segments.entries()) { + currentDir = path.join(currentDir, segment); + const layoutPath = findFile(currentDir, "layout", matcher); + if (layoutPath) layouts.push({ path: layoutPath, segments: segments.slice(0, index + 1) }); + } + return layouts; +} + +/** + * The loading boundaries of the same folders, at their root-relative tree + * positions. + */ +function findInterceptAncestorLoadingEntries( + branchRootDir: string, + interceptParentDir: string, + matcher: ValidFileMatcher, +): { path: string; treePosition: number }[] { + const segments = path.relative(branchRootDir, interceptParentDir).split(path.sep).filter(Boolean); + const loadings: { path: string; treePosition: number }[] = []; + let currentDir = branchRootDir; + for (const [index, segment] of segments.entries()) { + currentDir = path.join(currentDir, segment); + const loadingPath = findFile(currentDir, "loading", matcher); + if (loadingPath) loadings.push({ path: loadingPath, treePosition: index + 1 }); + } + return loadings; +} + function findSlotLoadingEntries( slotDir: string, pagePath: string | null, @@ -2468,6 +2522,7 @@ function discoverParallelSlots( hasPage: pagePath !== null, pagePath, defaultPath, + ownerDefaultPath: findFile(dir, "default", matcher), layoutPath: findFile(slotDir, "layout", matcher), configLayoutPaths, configLayoutTreePositions: findSlotConfigLayoutTreePositions(slotDir, configLayoutPaths), @@ -2586,6 +2641,23 @@ function discoverSiblingInterceptingRoutes( // Collect all intercept targets from the marker subtree. const restOfName = entry.name.slice(marker.prefix.length); const parentDir = dir; // directory that owns the marker (the "intercepting route" dir) + // Find the route that serves the parentDir. Fall back to scanning all + // routes that live under parentDir (handles the case where the route + // pattern is a catch-all like /templates/:catchAll+ rather than /templates). + const owner = findOwnerRouteForDir(parentDir, appDir, routes, routesByDir); + // The intercepting page continues the main tree below its source + // page's folder, so the folders between that folder and the marker, + // such as a route group, are on its path too. + const ownerFilePath = owner ? (owner.pagePath ?? owner.routePath) : null; + const ownerDir = ownerFilePath ? path.dirname(ownerFilePath) : null; + const ownerRelativeParentDir = ownerDir ? path.relative(ownerDir, parentDir) : ""; + const siblingSourceDir = + ownerDir && + ownerRelativeParentDir && + !ownerRelativeParentDir.startsWith("..") && + !path.isAbsolute(ownerRelativeParentDir) + ? ownerDir + : null; const results: InterceptingRoute[] = []; collectInterceptingPages( childDir, @@ -2598,6 +2670,12 @@ function discoverSiblingInterceptingRoutes( null, results, matcher, + [], + siblingSourceDir + ? findInterceptAncestorLoadingEntries(siblingSourceDir, parentDir, matcher) + : [], + siblingSourceDir ? ownerRelativeParentDir.split(path.sep).filter(Boolean).length : 0, + siblingSourceDir, ); for (const ir of results) { ir.slotId = createAppRouteGraphSiblingInterceptSlotId(ir.sourceMatchPattern); @@ -2606,10 +2684,6 @@ function discoverSiblingInterceptingRoutes( ir.sourceMatchPattern, ir.targetPattern, ); - // Find the route that serves the parentDir. Fall back to scanning all - // routes that live under parentDir (handles the case where the route - // pattern is a catch-all like /templates/:catchAll+ rather than /templates). - const owner = findOwnerRouteForDir(parentDir, appDir, routes, routesByDir); if (owner) { owner.siblingIntercepts.push(ir); } @@ -2794,6 +2868,11 @@ function collectInterceptingPages( parentLayoutPaths: readonly string[] = [], parentLoadingEntries: readonly { path: string; treePosition: number }[] = [], treePositionOffset = 0, + /** + * The source page's folder of a sibling-page intercept whose marker sits + * below it; null for slot intercepts and markers in the source's folder. + */ + siblingSourceDir: string | null = null, ): void { const currentLayoutPath = findFile(currentDir, "layout", matcher); const layoutPaths = currentLayoutPath @@ -2827,29 +2906,39 @@ function collectInterceptingPages( page, matcher, ); - const slotParentSegments = slotRootDir - ? path.relative(slotRootDir, interceptParentDir).split(path.sep).filter(Boolean) + const branchRootDir = slotRootDir ?? siblingSourceDir; + const parentSegments = branchRootDir + ? path.relative(branchRootDir, interceptParentDir).split(path.sep).filter(Boolean) : []; const branchSegments = [ - ...slotParentSegments, + ...parentSegments, interceptSegment, ...path.relative(interceptRoot, path.dirname(page)).split(path.sep).filter(Boolean), ]; + const ancestorLayouts = branchRootDir + ? findInterceptAncestorLayoutEntries(branchRootDir, interceptParentDir, matcher) + : []; results.push({ branchSegments, convention, - layoutPaths: [...layoutPaths], - layoutSegments: layoutPaths.map((layoutPath) => { - const relativeDir = path.relative(interceptRoot, path.dirname(layoutPath)); - return [ - ...slotParentSegments, - interceptSegment, - ...relativeDir.split(path.sep).filter(Boolean), - ]; - }), + layoutPaths: [...ancestorLayouts.map((layout) => layout.path), ...layoutPaths], + layoutSegments: [ + ...ancestorLayouts.map((layout) => layout.segments), + ...layoutPaths.map((layoutPath) => { + const relativeDir = path.relative(interceptRoot, path.dirname(layoutPath)); + return [ + ...parentSegments, + interceptSegment, + ...relativeDir.split(path.sep).filter(Boolean), + ]; + }), + ], loadingPaths: loadingEntries.map((loading) => loading.path), loadingTreePositions: loadingEntries.map((loading) => loading.treePosition), - notFoundBranchSegments: branchSegments, + // A sibling's not-found position counts from its marker. + notFoundBranchSegments: slotRootDir + ? branchSegments + : branchSegments.slice(parentSegments.length), notFoundPath: notFoundBoundary.path, notFoundTreePosition: notFoundBoundary.treePosition === null @@ -2887,6 +2976,7 @@ function collectInterceptingPages( layoutPaths, loadingEntries, treePositionOffset, + siblingSourceDir, ); } } diff --git a/packages/vinext/src/server/app-page-cache.ts b/packages/vinext/src/server/app-page-cache.ts index 560f4b92c3..262b1e92ed 100644 --- a/packages/vinext/src/server/app-page-cache.ts +++ b/packages/vinext/src/server/app-page-cache.ts @@ -73,9 +73,10 @@ type AppPageCacheRenderResult = { rscData: ArrayBuffer; rscRenderObservation: RenderObservation; /** - * The route-level revalidate of the route this render regenerated, or null - * when it has none and the render's cacheLife sets it. Undefined keeps the - * matched route's read seed, as when the render regenerated that route. + * The route-level revalidate of the tree this render regenerated, or null + * when it has none and the render's cacheLife sets it. An intercepted entry + * regenerates a different tree from the matched route's, so its write takes + * this instead of the matched route's. */ revalidateSeconds?: number | null; tags: string[]; @@ -568,9 +569,9 @@ export async function readAppPageCacheResponse( const cacheControl = resolveRegeneratedAppPageCacheControl({ expireSeconds: options.expireSeconds, renderCacheControl: revalidatedPage.cacheControl, - // The route's read seed is 0 only when it has no route-level - // revalidate, since a `revalidate = 0` route is never read from the - // cache. + // The matched route's read seed is 0 only when it has no + // route-level revalidate, since a `revalidate = 0` route is never + // read from the cache. routeRevalidateSeconds: revalidatedPage.revalidateSeconds === undefined ? options.revalidateSeconds || null @@ -578,7 +579,8 @@ export async function readAppPageCacheResponse( }); // Like Next.js, a regeneration whose render turned dynamic fails // without PPR, whose shell expects it: a dynamic API use, or an - // effective revalidate of 0 from its fetches or its cacheLife. + // effective revalidate of 0 from the regenerated tree's config, its + // fetches or its cacheLife. // https://github.com/vercel/next.js/blob/v16.2.7/packages/next/src/build/templates/app-page.ts if ( options.isRoutePPREnabled !== true && diff --git a/packages/vinext/src/server/app-page-dispatch.ts b/packages/vinext/src/server/app-page-dispatch.ts index f3f4f25019..5a1d243f6a 100644 --- a/packages/vinext/src/server/app-page-dispatch.ts +++ b/packages/vinext/src/server/app-page-dispatch.ts @@ -44,6 +44,7 @@ import { } from "./app-page-boundary.js"; import { buildAppPageSpecialErrorResponse, + probeAppPageComponent, probeAppPageThrownError, resolveAppPageSpecialError, type AppPageFontPreload, @@ -52,10 +53,16 @@ import { } from "./app-page-execution.js"; import { buildRscRedirectFlightStream } from "./app-rsc-redirect-flight.js"; import { resolveAppPageMethodResponse } from "./app-page-method.js"; -import { isAppPageStaticEligible } from "./app-segment-config.js"; +import { + hasAppPageInterceptDynamicSegment, + isAppPageStaticEligible, +} from "./app-segment-config.js"; import { resolveAppPageNavigationParams } from "./app-page-element-builder.js"; import { buildAppPageElement, + isAppPageInterceptAttachedToRoute, + loadAppPageCurrentRouteIntercept, + matchAppPageCurrentRouteIntercept, resolveAppPageInterceptionRerenderTarget, resolveAppPageIntercept, validateAppPageDynamicParams, @@ -83,7 +90,7 @@ import { type AppRscRenderMode, } from "./app-rsc-render-mode.js"; import { shouldServeStreamingMetadata } from "./streaming-metadata.js"; -import { createAppPageTreePath } from "./app-page-route-wiring.js"; +import { createAppPageTreePath, getAppPageSegmentParamName } from "./app-page-params.js"; import { createAppPageRscErrorTracker, type AppPageSsrHandler } from "./app-page-stream.js"; import { VINEXT_INTERCEPTION_ID_HEADER, VINEXT_PRERENDER_SPECULATIVE_HEADER } from "./headers.js"; import type { ClientReuseManifestParseResult } from "./client-reuse-manifest.js"; @@ -147,6 +154,7 @@ type AppPageDispatchIntercept = { interceptNotFoundBranchSegments?: readonly string[] | null; notFound?: unknown; notFoundTreePosition?: number | null; + ownerDefault?: unknown; matchedParams: AppPageParams; sourceMatchedParams?: AppPageParams; page: TPage; @@ -170,6 +178,7 @@ type AppPageDispatchInterceptOptions = { interceptNotFoundBranchSegments?: readonly string[] | null; interceptNotFound?: unknown; interceptNotFoundTreePosition?: number | null; + interceptOwnerDefault?: unknown; interceptPage: TPage; interceptParams: AppPageParams; interceptSlotId?: string | null; @@ -180,6 +189,22 @@ type AppPageDispatchInterceptOptions = { interceptTargetRouteGraphId?: string | null; }; +/** + * The intercepting branch a direct intercepted RSC response renders in place + * of the source route's slot (or page), and the pattern of the route it + * intercepts. + */ +export type AppPageStaticEligibilityIntercept = Pick< + AppPageDispatchInterceptOptions, + | "interceptBranchSegments" + | "interceptLayoutSegments" + | "interceptLayouts" + | "interceptOwnerDefault" + | "interceptPage" + | "interceptSlotKey" + | "interceptTargetPatternParts" +>; + type AppPageModule = { default?: unknown; dynamic?: unknown; @@ -405,6 +430,16 @@ export type DispatchAppPageOptions = { rootParams?: RootParams; probeLayoutAt: (layoutIndex: number, layoutParamAccess?: AppLayoutParamAccessTracker) => unknown; probePage: (searchParams?: URLSearchParams) => unknown; + /** + * Probes what a direct intercepted RSC response renders ahead of its loading + * boundaries: the source route with the intercepting branch, for this + * request's render mode (`buildAppPageInterceptSourceProbes`). + */ + probeInterceptSource?: ( + route: TRoute, + params: AppPageParams, + searchParams: URLSearchParams, + ) => unknown; expireSeconds?: number; renderErrorBoundaryPage: ( error: unknown, @@ -432,16 +467,76 @@ export type DispatchAppPageOptions = { request: Request; revalidateSeconds: number | null; renderedPathAndSearch?: string | null; - resolveRouteFetchCacheMode?: (route: TRoute) => FetchCacheMode | null; - resolveRouteRevalidateSeconds?: (route: TRoute) => number | null; - resolveRouteDynamicConfig?: (route: TRoute) => string | null | undefined; + /** + * With an intercept, these resolve the tree a direct intercepted RSC + * response renders: the source route with the intercepting branch in place + * of what it intercepts, as `resolveRouteStaticEligible` classifies it, + * merged with the active sibling pages vinext renders where Next.js's tree + * has their defaults. + */ + resolveRouteFetchCacheMode?: ( + route: TRoute, + intercept?: AppPageStaticEligibilityIntercept, + ) => FetchCacheMode | null; + resolveRouteRevalidateSeconds?: ( + route: TRoute, + intercept?: AppPageStaticEligibilityIntercept, + ) => number | null; + resolveRouteDynamicConfig?: ( + route: TRoute, + intercept?: AppPageStaticEligibilityIntercept, + ) => string | null | undefined; + resolveRouteDynamicStaleTimeSeconds?: ( + route: TRoute, + intercept?: AppPageStaticEligibilityIntercept, + ) => number | undefined; + /** + * `dynamicParamsConfig` for the tree a direct intercepted RSC response + * renders, from the segments of the intercepting route's own tree. + */ + resolveRouteDynamicParamsConfig?: ( + route: TRoute, + intercept: AppPageStaticEligibilityIntercept, + ) => boolean | undefined; + /** + * The `dynamic` config of the intercepting route's own tree, without the + * active sibling pages `resolveRouteDynamicConfig` merges in: whether + * Next.js's production build leaves that route out of its prerender + * manifest as `force-dynamic`. + */ + resolveRouteInterceptTreeDynamicConfig?: ( + route: TRoute, + intercept: AppPageStaticEligibilityIntercept, + ) => string | null | undefined; + /** + * `generateStaticParams` for the tree a direct intercepted RSC response + * renders: the generators of the intercepting route's own tree. + */ + resolveRouteGenerateStaticParams?: ( + route: TRoute, + intercept: AppPageStaticEligibilityIntercept, + ) => ValidateAppPageDynamicParamsOptions["generateStaticParams"]; + /** + * `hasAnyGenerateStaticParams` for the tree a direct intercepted RSC + * response renders: whether any segment of the intercepting route's own + * tree exports `generateStaticParams`. + */ + resolveRouteHasAnyGenerateStaticParams?: ( + route: TRoute, + intercept: AppPageStaticEligibilityIntercept, + ) => boolean; /** * `isAppPageStaticEligible` for another route, from that route's own segment * config, `generateStaticParams`, dynamism and runtime. A direct intercepted - * RSC response renders its source route, so it takes the source's - * cacheability. + * RSC response renders its source route with the intercepting branch in the + * intercepted slot, so it passes that branch and the pattern of the route it + * intercepts: + * Next.js classifies the intercepting route's own loader tree. */ - resolveRouteStaticEligible: (route: TRoute) => boolean; + resolveRouteStaticEligible: ( + route: TRoute, + intercept?: AppPageStaticEligibilityIntercept, + ) => boolean; rootForbiddenModule?: AppPageModule | null; rootNotFoundModule?: AppPageModule | null; rootUnauthorizedModule?: AppPageModule | null; @@ -558,18 +653,26 @@ export function shouldReadAppPageCache(options: { ); } -function resolveAppPageCacheReadRevalidateSeconds(options: { +type AppPageRouteRevalidateOptions = { isDynamicError: boolean; isForceStatic: boolean; revalidateSeconds: number | null; -}): number { +}; + +/** A route's own revalidate, or null when its render's cacheLife sets it. */ +function resolveAppPageRouteRevalidateSeconds( + options: AppPageRouteRevalidateOptions, +): number | null { if (options.revalidateSeconds === null && (options.isForceStatic || options.isDynamicError)) { return Infinity; } + return options.revalidateSeconds; +} +function resolveAppPageCacheReadRevalidateSeconds(options: AppPageRouteRevalidateOptions): number { // cacheLife-only routes discover their actual revalidate during the fresh // render; this seed only gets them into the cache read path. - return options.revalidateSeconds ?? 0; + return resolveAppPageRouteRevalidateSeconds(options) ?? 0; } export function hasSearchParams(searchParams: URLSearchParams | null | undefined): boolean { @@ -631,6 +734,24 @@ async function runAppPageRevalidationContext< } } +/** + * The intercepting branch a direct intercepted RSC response renders, for the + * route config resolvers. + */ +export function toRouteConfigIntercept( + interceptOpts: AppPageDispatchInterceptOptions, +): AppPageStaticEligibilityIntercept { + return { + interceptBranchSegments: interceptOpts.interceptBranchSegments, + interceptLayoutSegments: interceptOpts.interceptLayoutSegments, + interceptLayouts: interceptOpts.interceptLayouts, + interceptOwnerDefault: interceptOpts.interceptOwnerDefault, + interceptPage: interceptOpts.interceptPage, + interceptSlotKey: interceptOpts.interceptSlotKey, + interceptTargetPatternParts: interceptOpts.interceptTargetPatternParts, + }; +} + function toInterceptOptions( interceptionContext: string | null, intercept: AppPageDispatchIntercept, @@ -647,6 +768,7 @@ function toInterceptOptions( interceptNotFoundBranchSegments: intercept.interceptNotFoundBranchSegments, interceptNotFound: intercept.notFound, interceptNotFoundTreePosition: intercept.notFoundTreePosition, + interceptOwnerDefault: intercept.ownerDefault, interceptPage: intercept.page, interceptParams: intercept.matchedParams, interceptSlotId: intercept.slotId ?? null, @@ -658,6 +780,37 @@ function toInterceptOptions( }; } +/** + * Probe the source route that a direct intercepted RSC response renders, since + * that response picks its headers before its render, and report whether it + * read a dynamic API while its element was built (viewport and metadata + * resolution) or probed. The caller discards earlier usage, which doesn't + * belong to the source, before building the element. + */ +async function probeAppPageInterceptSourceDynamicUsage( + options: DispatchAppPageOptions, + route: TRoute, + params: AppPageParams, + searchParams: URLSearchParams, +): Promise { + const probeInterceptSource = options.probeInterceptSource; + if (probeInterceptSource) { + // Special errors and other probe failures surface through the intercepted + // response's own render, as before. + await probeAppPageComponent({ + awaitAsyncResult: true, + async onError() { + return null; + }, + probePage: () => probeInterceptSource(route, params, searchParams), + runWithSuppressedHookWarning(probe) { + return options.runWithSuppressedHookWarning(probe); + }, + }); + } + return consumeDynamicUsage(); +} + /** * Workers Cache never admits a path its deploy manifest gives no state: a * listed path that used a dynamic API, or an unlisted path of a route without @@ -703,7 +856,65 @@ async function dispatchAppPageInner( options.clearRequestContext(); return new Response(null, { status: 204 }); } - const dynamicConfig = options.dynamicConfig; + const isDraftMode = isDraftModeRequest(options.request, options.draftModeSecret); + + // The lookup this request's interception resolution shares with its + // current-route match, so an RSC request doesn't match its path twice. + let requestIntercept: AppPageDispatchIntercept | null | undefined; + const findIntercept = (pathname: string) => { + if (pathname !== options.cleanPathname) return options.findIntercept(pathname); + if (requestIntercept === undefined) requestIntercept = options.findIntercept(pathname); + return requestIntercept; + }; + const routeHasSlot = (sourceRoute: TRoute, slotKey: string) => + !!sourceRoute.slots && Object.hasOwn(sourceRoute.slots, slotKey); + + // A route renders an interception whose source is the route itself with the + // intercepting branch in its slot (or page). Next.js serves that + // intercepting route as its own app path, so its own segment config, not + // this route's, decides how it renders and whether the response may be read + // from or written to the cache, in every render: dev, draft mode, actions + // and cacheComponents builds included. Matching loads nothing, so a request + // without the interception does no extra work. Loading it is like loading + // the route's own modules: generated params are still checked only once the + // cache misses. + const matchedCurrentRouteIntercept = await matchAppPageCurrentRouteIntercept({ + cleanPathname: options.cleanPathname, + currentRoute: route, + findIntercept, + getSourceRoute: options.getSourceRoute, + isRscRequest: options.isRscRequest, + }); + if (matchedCurrentRouteIntercept) { + await loadAppPageCurrentRouteIntercept(matchedCurrentRouteIntercept, route, routeHasSlot); + } + // An intercept the route has no slot for renders nothing, so the route + // renders with its own params. + const attachedCurrentRouteIntercept = + matchedCurrentRouteIntercept && + isAppPageInterceptAttachedToRoute(matchedCurrentRouteIntercept, route, routeHasSlot) + ? matchedCurrentRouteIntercept + : undefined; + const currentRouteIntercept = matchedCurrentRouteIntercept + ? toRouteConfigIntercept( + toInterceptOptions(options.interceptionContext, matchedCurrentRouteIntercept), + ) + : undefined; + // With an intercept, a null from these resolvers means the intercepting + // tree has no such config: this route's config belongs to the branch the + // intercept replaced. + const dynamicConfig = currentRouteIntercept + ? (options.resolveRouteDynamicConfig?.(route, currentRouteIntercept) ?? undefined) + : options.dynamicConfig; + const configRevalidateSeconds = currentRouteIntercept + ? (options.resolveRouteRevalidateSeconds?.(route, currentRouteIntercept) ?? null) + : options.revalidateSeconds; + const dynamicStaleTimeSeconds = currentRouteIntercept + ? options.resolveRouteDynamicStaleTimeSeconds?.(route, currentRouteIntercept) + : options.dynamicStaleTimeSeconds; + const dynamicParamsConfig = currentRouteIntercept + ? options.resolveRouteDynamicParamsConfig?.(route, currentRouteIntercept) + : options.dynamicParamsConfig; const interceptionId = options.isRscRequest ? options.request.headers.get(VINEXT_INTERCEPTION_ID_HEADER) : null; @@ -714,13 +925,15 @@ async function dispatchAppPageInner( // full-page cache candidates. Every other route renders per request and is // never stored, whatever its revalidate or cacheLife. cacheComponents builds // (PPR fallback shells) follow a different model and keep their own rules. - const isNextStaticEligible = isAppPageStaticEligible({ - dynamicConfig, - hasGenerateStaticParams: options.hasGenerateStaticParams, - isDynamicRoute: route.isDynamic, - isStaticGenerationEdgeRuntime: options.isStaticGenerationEdgeRuntime === true, - revalidateSeconds: options.revalidateSeconds, - }); + const isNextStaticEligible = currentRouteIntercept + ? options.resolveRouteStaticEligible(route, currentRouteIntercept) + : isAppPageStaticEligible({ + dynamicConfig, + hasGenerateStaticParams: options.hasGenerateStaticParams, + isDynamicRoute: route.isDynamic, + isStaticGenerationEdgeRuntime: options.isStaticGenerationEdgeRuntime === true, + revalidateSeconds: configRevalidateSeconds, + }); const isStaticEligible = options.pprRuntime !== undefined || isNextStaticEligible; // Next.js defaults every static or SSG route to `revalidate = false`, so a // render that uses no dynamic API is stored until it is revalidated. This @@ -728,15 +941,22 @@ async function dispatchAppPageInner( // paths, on the first on-demand render of an unknown path. Dev has no ISR. // https://github.com/vercel/next.js/blob/canary/packages/next/src/build/index.ts // Any segment's generator still sets this default, which the fetch shim and - // cacheComponents fallback shells read. + // cacheComponents fallback shells read. An intercepting tree, like its + // regeneration, takes it from its own classification and generators. const hasStaticRevalidateDefault = options.isProduction && options.pprRuntime === undefined && isNextStaticEligible; + const hasAnyGenerateStaticParams = currentRouteIntercept + ? options.resolveRouteHasAnyGenerateStaticParams?.(route, currentRouteIntercept) === true + : options.hasAnyGenerateStaticParams; const currentRevalidateSeconds = - options.revalidateSeconds ?? - (hasStaticRevalidateDefault || options.hasAnyGenerateStaticParams ? Infinity : null); + configRevalidateSeconds ?? + (hasStaticRevalidateDefault || hasAnyGenerateStaticParams ? Infinity : null); if (isRouteCacheabilityProbe()) { // A route that isn't static or SSG is never stored, so its probe reports - // the whole pattern dynamic, whichever of its paths discovery listed. + // the whole pattern dynamic, whichever of its paths discovery listed. A + // pattern-wide decision comes from the route's own config: an intercepting + // tree's config covers only the requests that render it. + const isRouteForceDynamic = options.dynamicConfig === "force-dynamic"; const isRouteStaticEligible = options.pprRuntime !== undefined || isAppPageStaticEligible({ @@ -746,9 +966,9 @@ async function dispatchAppPageInner( isStaticGenerationEdgeRuntime: options.isStaticGenerationEdgeRuntime === true, revalidateSeconds: options.revalidateSeconds ?? null, }); - const patternDynamicReason = isForceDynamic + const patternDynamicReason = isRouteForceDynamic ? 'dynamic = "force-dynamic"' - : currentRevalidateSeconds === 0 + : options.revalidateSeconds === 0 ? "revalidate = 0" : !isRouteStaticEligible ? "route is not statically generated" @@ -767,7 +987,6 @@ async function dispatchAppPageInner( const placeGeneratedMetadataInBody = (!isPrerender || options.pprFallbackShell !== undefined) && serveStreamingMetadata; const isPrefetchDynamicShell = options.renderMode === APP_RSC_RENDER_MODE_PREFETCH_DYNAMIC_SHELL; - const isDraftMode = isDraftModeRequest(options.request, options.draftModeSecret); const requestHeadersContext = getHeadersContext(); const shouldUseEmptySearchParams = isForceStatic || isPrefetchDynamicShell; const hasRequestSearchParams = @@ -780,7 +999,11 @@ async function dispatchAppPageInner( const hasActiveLoadingBoundary = activeLoadingTreePositions.length > 0; setCurrentFetchSoftTags(buildAppPageTags(options.cleanPathname, [], route.routeSegments)); - setCurrentFetchCacheMode(options.fetchCache ?? null); + setCurrentFetchCacheMode( + currentRouteIntercept + ? (options.resolveRouteFetchCacheMode?.(route, currentRouteIntercept) ?? null) + : (options.fetchCache ?? null), + ); setCurrentFetchRevalidate(currentRevalidateSeconds); setCurrentForceDynamicFetchDefault(isForceDynamic); @@ -841,6 +1064,13 @@ async function dispatchAppPageInner( }); } + const resolveInterceptStaticEligible = ( + sourceRoute: TRoute, + interceptOpts: AppPageDispatchInterceptOptions, + ) => + options.pprRuntime !== undefined || + options.resolveRouteStaticEligible(sourceRoute, toRouteConfigIntercept(interceptOpts)); + const isCacheEligibleRender = options.bypassInterceptionContextCache !== true && shouldReadAppPageCache({ @@ -871,6 +1101,11 @@ async function dispatchAppPageInner( isPrerender, }); const { readAppPageCacheResponse } = await import("./app-page-cache.js"); + const cacheReadRevalidateSeconds = resolveAppPageCacheReadRevalidateSeconds({ + isDynamicError, + isForceStatic, + revalidateSeconds: currentRevalidateSeconds, + }); const reportedSsrRevalidationErrors = new Set(); let revalidationRscErrorTracker: ReturnType | null = null; // The route, params and intercept a render for this request's cache entry @@ -888,6 +1123,7 @@ async function dispatchAppPageInner( return options.getSourceRoute(sourceRouteIndex); }, isRscRequest: options.isRscRequest, + routeHasSlot, toInterceptOpts(intercept) { return toInterceptOptions(options.interceptionContext, intercept); }, @@ -933,41 +1169,70 @@ async function dispatchAppPageInner( renderedPathAndSearch: options.renderedPathAndSearch, renderMode: options.renderMode, expireSeconds: options.expireSeconds, - revalidateSeconds: resolveAppPageCacheReadRevalidateSeconds({ - isDynamicError, - isForceStatic, - revalidateSeconds: currentRevalidateSeconds, - }), + revalidateSeconds: cacheReadRevalidateSeconds, renderFreshPageForCache: async () => { const revalidationTarget = await resolveCacheRenderTarget(); + // A stale intercepted entry regenerates the tree its direct render + // rendered, so it takes that tree's config, not the source route's. + const revalidationConfigIntercept = revalidationTarget.interceptOpts + ? toRouteConfigIntercept(revalidationTarget.interceptOpts) + : undefined; + // With an intercept, a null from these resolvers means the intercepted + // tree has no such config, even when the intercept is on the matched + // route itself: the matched tree's config belongs to the branch the + // intercept replaced. + const usesMatchedRouteConfig = + revalidationConfigIntercept === undefined && revalidationTarget.route === route; const revalidationDynamicConfig = - options.resolveRouteDynamicConfig?.(revalidationTarget.route) ?? - (revalidationTarget.route === route ? dynamicConfig : undefined); + options.resolveRouteDynamicConfig?.( + revalidationTarget.route, + revalidationConfigIntercept, + ) ?? (usesMatchedRouteConfig ? dynamicConfig : undefined); const revalidationConfigRevalidateSeconds = - options.resolveRouteRevalidateSeconds?.(revalidationTarget.route) ?? - (revalidationTarget.route === route ? currentRevalidateSeconds : null); - // A stale intercepted entry regenerates its source route, so the entry - // takes that route's revalidate, not the matched route's read seed. A - // static route without one keeps `revalidate = false`. + options.resolveRouteRevalidateSeconds?.( + revalidationTarget.route, + revalidationConfigIntercept, + ) ?? (usesMatchedRouteConfig ? currentRevalidateSeconds : null); + // The regenerated entry stores the rendered tree's revalidate, lowered + // by what the render collects, like Next.js' prerender store: it starts + // at the tree's segment config, and a static tree without one, or one + // with a generator, keeps `revalidate = false`. + // https://github.com/vercel/next.js/blob/v16.2.7/packages/next/src/server/app-render/create-component-tree.tsx const revalidationRouteRevalidateSeconds = - revalidationTarget.route === route - ? undefined - : (revalidationConfigRevalidateSeconds ?? - (revalidationDynamicConfig === "force-static" || - revalidationDynamicConfig === "error" || - (options.isProduction && - options.pprRuntime === undefined && - options.resolveRouteStaticEligible(revalidationTarget.route)) - ? Infinity - : null)); + revalidationConfigIntercept === undefined + ? resolveAppPageRouteRevalidateSeconds({ + isDynamicError, + isForceStatic, + revalidateSeconds: currentRevalidateSeconds, + }) + : resolveAppPageRouteRevalidateSeconds({ + isDynamicError: revalidationDynamicConfig === "error", + isForceStatic: revalidationDynamicConfig === "force-static", + revalidateSeconds: + revalidationConfigRevalidateSeconds ?? + ((options.isProduction && + options.pprRuntime === undefined && + options.resolveRouteStaticEligible( + revalidationTarget.route, + revalidationConfigIntercept, + )) || + options.resolveRouteHasAnyGenerateStaticParams?.( + revalidationTarget.route, + revalidationConfigIntercept, + ) === true + ? Infinity + : null), + }); return runAppPageRevalidationContext( { cleanPathname: options.cleanPathname, displayPathname: options.displayPathname, currentFetchCacheMode: - options.resolveRouteFetchCacheMode?.(revalidationTarget.route) ?? - (revalidationTarget.route === route ? (options.fetchCache ?? null) : null), + options.resolveRouteFetchCacheMode?.( + revalidationTarget.route, + revalidationConfigIntercept, + ) ?? (usesMatchedRouteConfig ? (options.fetchCache ?? null) : null), currentFetchRevalidate: revalidationConfigRevalidateSeconds, draftModeSecret: options.draftModeSecret, dynamicConfig: revalidationDynamicConfig, @@ -1066,24 +1331,57 @@ async function dispatchAppPageInner( // Next.js' production force-dynamic routes are absent from the prerender // manifest, so they never enter its generated-path fallback gate. Dev still // resolves and exact-matches generateStaticParams for the same route. - if (options.skipStaticParamsValidation !== true && !(options.isProduction && isForceDynamic)) { + // A current-route interception serves the intercepting route, whose own + // tree's dynamicParams and generators gate the params it renders with. Only + // that tree's own force-dynamic leaves it out of the manifest: the active + // sibling pages vinext also renders make its render dynamic, not its route. + const isManifestForceDynamic = currentRouteIntercept + ? options.resolveRouteInterceptTreeDynamicConfig?.(route, currentRouteIntercept) === + "force-dynamic" + : isForceDynamic; + if ( + options.skipStaticParamsValidation !== true && + !(options.isProduction && isManifestForceDynamic) + ) { + // The intercepting tree's own dynamic segments make it dynamic under a + // static route, and its optional catch-alls are part of its params. + const interceptBranchSegments = attachedCurrentRouteIntercept?.interceptBranchSegments ?? []; const dynamicParamsResponse = await validateAppPageDynamicParams({ - enforceStaticParamsOnly: options.dynamicParamsConfig === false, - generateStaticParams: options.generateStaticParams, - isDynamicRoute: route.isDynamic, - params: options.staticParamsValidationParams ?? options.params, + enforceStaticParamsOnly: dynamicParamsConfig === false, + generateStaticParams: currentRouteIntercept + ? options.resolveRouteGenerateStaticParams?.(route, currentRouteIntercept) + : options.generateStaticParams, + isDynamicRoute: route.isDynamic || hasAppPageInterceptDynamicSegment(interceptBranchSegments), + optionalCatchAllParamNames: attachedCurrentRouteIntercept + ? [...route.routeSegments, ...interceptBranchSegments] + .filter((segment) => segment.startsWith("[[...")) + .flatMap((segment) => getAppPageSegmentParamName(segment) ?? []) + : undefined, + params: + options.staticParamsValidationParams ?? + attachedCurrentRouteIntercept?.matchedParams ?? + options.params, }); if (dynamicParamsResponse) { // A generated-param miss belongs to a matched App route, so render the // route's not-found boundary just like a page-level notFound() signal. // The plain response remains a defensive fallback if boundary rendering // is unavailable, but the normal path must include Next.js's canonical - // not-found markup (and custom not-found.tsx when present). + // not-found markup (and custom not-found.tsx when present). A + // current-route interception's miss belongs to the intercepting tree, so + // it renders through that tree like a notFound() from its page does. const renderedNotFound = await options.renderHttpAccessFallbackPage( 404, - { matchedParams: options.params }, + { + intercept: attachedCurrentRouteIntercept + ? toInterceptOptions(options.interceptionContext, attachedCurrentRouteIntercept) + : undefined, + matchedParams: options.params, + }, options.middlewareContext, ); + // The current-route interception classified above decides this + // response's cacheability too, like the render it replaces. const cachePolicy = { isDraftMode, isDynamicError, @@ -1121,8 +1419,9 @@ async function dispatchAppPageInner( let interceptDynamicConfig: string | null | undefined; let interceptDynamicConfigResolved = false; - // Whether the source route that the intercepted response renders is - // force-dynamic or revalidate = 0, from the config activated for its render. + // Whether the tree that the intercepted response renders (the source route + // with the intercepting branch) is force-dynamic or revalidate = 0, from the + // config activated for its render, or read a dynamic API while probed. let isInterceptSourceKnownDynamic = false; const interceptResult = await resolveAppPageIntercept< TRoute, @@ -1143,9 +1442,12 @@ async function dispatchAppPageInner( // The intercept route's fetch defaults must also stay active past this // call — its server components fetch lazily during the // renderToReadableStream in renderInterceptResponse below. + const routeConfigIntercept = interceptOpts + ? toRouteConfigIntercept(interceptOpts) + : undefined; const sourceDynamicConfig = interceptDynamicConfigResolved ? interceptDynamicConfig - : options.resolveRouteDynamicConfig?.(interceptRoute); + : options.resolveRouteDynamicConfig?.(interceptRoute, routeConfigIntercept); if (sourceDynamicConfig === "force-static" || sourceDynamicConfig === "error") { const { createStaticGenerationHeadersContext } = await import("./app-static-generation.js"); setHeadersContext( @@ -1161,13 +1463,17 @@ async function dispatchAppPageInner( setHeadersContext(requestHeadersContext); } const sourceRevalidateSeconds = - options.resolveRouteRevalidateSeconds?.(interceptRoute) ?? null; + options.resolveRouteRevalidateSeconds?.(interceptRoute, routeConfigIntercept) ?? null; isInterceptSourceKnownDynamic = sourceDynamicConfig === "force-dynamic" || sourceRevalidateSeconds === 0; - setCurrentFetchCacheMode(options.resolveRouteFetchCacheMode?.(interceptRoute) ?? null); + setCurrentFetchCacheMode( + options.resolveRouteFetchCacheMode?.(interceptRoute, routeConfigIntercept) ?? null, + ); setCurrentFetchRevalidate(sourceRevalidateSeconds); setCurrentForceDynamicFetchDefault(sourceDynamicConfig === "force-dynamic"); - return options.buildPageElement( + // Usage recorded so far belongs to the matched target, not the source. + consumeDynamicUsage(); + const interceptElement = await options.buildPageElement( interceptRoute, interceptParams, interceptOpts, @@ -1180,12 +1486,21 @@ async function dispatchAppPageInner( serveStreamingMetadata: placeGeneratedMetadataInBody, }, ); + if ( + await probeAppPageInterceptSourceDynamicUsage( + options, + interceptRoute, + interceptParams, + interceptSearchParams, + ) + ) { + isInterceptSourceKnownDynamic = true; + } + return interceptElement; }, cleanPathname: options.cleanPathname, currentRoute: route, - findIntercept(pathname) { - return options.findIntercept(pathname); - }, + findIntercept, getRouteParamNames(sourceRoute) { return sourceRoute.params; }, @@ -1197,7 +1512,8 @@ async function dispatchAppPageInner( resolveNavigationParams(sourceRoute, navigationParams, pathname, interceptOpts) { return resolveAppPageNavigationParams(sourceRoute, navigationParams, pathname, interceptOpts); }, - renderInterceptResponse(sourceRoute, interceptElement) { + routeHasSlot, + renderInterceptResponse(sourceRoute, interceptElement, interceptOpts) { const interceptOnError = options.createRscOnErrorHandler( options.cleanPathname, sourceRoute.pattern, @@ -1211,13 +1527,12 @@ async function dispatchAppPageInner( "Content-Type": VINEXT_RSC_CONTENT_TYPE, Vary: VINEXT_RSC_VARY_HEADER, }); - // This response renders the source route, so it takes the source's - // cacheability and dynamic config, not the matched target's. A source - // that can't be static, a render known dynamic before it starts, or a - // draft-mode request is never cacheable, like the source's own render. - // Middleware's policy still wins, merged after, as in the RSC builder. - const isSourceStaticEligible = - options.pprRuntime !== undefined || options.resolveRouteStaticEligible(sourceRoute); + // This response renders the source route with the intercepting branch, + // so it takes that tree's cacheability and the source's dynamic config, + // not the matched target's. A render known dynamic before it starts is + // never cacheable, like the source's own render. Middleware's policy + // still wins, merged after, as in the RSC builder. + const isSourceStaticEligible = resolveInterceptStaticEligible(sourceRoute, interceptOpts); if (!isSourceStaticEligible || isDraftMode || isInterceptSourceKnownDynamic) { interceptHeaders.set("Cache-Control", resolveUncacheableCacheControl(options.isProduction)); } @@ -1229,9 +1544,12 @@ async function dispatchAppPageInner( headers: interceptHeaders, }); }, - async resolveSearchParams(sourceRoute, searchParams) { + async resolveSearchParams(sourceRoute, searchParams, interceptOpts) { await options.ensureRouteLoaded?.(sourceRoute); - interceptDynamicConfig = options.resolveRouteDynamicConfig?.(sourceRoute); + interceptDynamicConfig = options.resolveRouteDynamicConfig?.( + sourceRoute, + toRouteConfigIntercept(interceptOpts), + ); interceptDynamicConfigResolved = true; return interceptDynamicConfig === "force-static" ? new URLSearchParams() : searchParams; }, @@ -1439,7 +1757,7 @@ async function dispatchAppPageInner( return runWithIsolatedDynamicUsage(fn); }, }, - dynamicStaleTimeSeconds: options.dynamicStaleTimeSeconds, + dynamicStaleTimeSeconds, revalidateSeconds: currentRevalidateSeconds, mountedSlotsHeader: options.mountedSlotsHeader, renderMode: options.renderMode ?? APP_RSC_RENDER_MODE_NAVIGATION, diff --git a/packages/vinext/src/server/app-page-element-builder.ts b/packages/vinext/src/server/app-page-element-builder.ts index feca94ad25..8f673e7a06 100644 --- a/packages/vinext/src/server/app-page-element-builder.ts +++ b/packages/vinext/src/server/app-page-element-builder.ts @@ -15,7 +15,6 @@ import { SIBLING_PAGE_INTERCEPT_SLOT_KEY } from "./app-rsc-route-matching.js"; import { buildAppPageElements, createAppPageSourcePage, - createAppPageTreePath, resolveAppPageLoadingModuleAtOrAbove, type AppPageErrorModule, type AppPageModule, @@ -49,7 +48,12 @@ import { makeObservedAppPageSearchParamsThenable, } from "./app-page-search-params-observation.js"; import { shouldServeStreamingMetadata } from "./streaming-metadata.js"; -import { resolveAppPageBranchParams, resolveAppPageSegmentParams } from "./app-page-params.js"; +import { + createAppPageTreePath, + resolveAppPageBranchParams, + resolveAppPageSegmentParams, + resolveInterceptLayoutParams, +} from "./app-page-params.js"; import { createAppPageRenderDependency, invokeAppComponent, @@ -60,14 +64,7 @@ import { type AppPageRenderDependency, } from "./app-render-dependency.js"; import { isPromiseLike } from "../utils/promise.js"; - -function resolveInterceptLayoutParams( - branchSegments: readonly string[], - layoutSegments: readonly string[], - params: AppPageParams, -): AppPageParams { - return resolveAppPageBranchParams(branchSegments, layoutSegments.length, params, layoutSegments); -} +import { APP_PAGE_INTERCEPTION_MARKER_TRAVERSALS } from "./app-page-interception-markers.js"; function traceAppPageLayoutModules( modules: readonly (AppPageModule | null | undefined)[], @@ -857,13 +854,6 @@ function buildSlotOverrides 0 ? overrides : null; } -export const APP_PAGE_INTERCEPTION_MARKER_TRAVERSALS = [ - { prefix: "(...)", levels: Number.POSITIVE_INFINITY }, - { prefix: "(..)(..)", levels: 2 }, - { prefix: "(..)", levels: 1 }, - { prefix: "(.)", levels: 0 }, -] as const; - function resolveInterceptedSlotSource( sourcePageSegments: readonly string[] | null | undefined, slotKey: string, diff --git a/packages/vinext/src/server/app-page-interception-markers.ts b/packages/vinext/src/server/app-page-interception-markers.ts new file mode 100644 index 0000000000..c5ca358cdb --- /dev/null +++ b/packages/vinext/src/server/app-page-interception-markers.ts @@ -0,0 +1,7 @@ +// Intercepting route folder markers and how many segments each climbs. +export const APP_PAGE_INTERCEPTION_MARKER_TRAVERSALS = [ + { prefix: "(...)", levels: Number.POSITIVE_INFINITY }, + { prefix: "(..)(..)", levels: 2 }, + { prefix: "(..)", levels: 1 }, + { prefix: "(.)", levels: 0 }, +] as const; diff --git a/packages/vinext/src/server/app-page-params.ts b/packages/vinext/src/server/app-page-params.ts index 248d8fa960..fcb5c96101 100644 --- a/packages/vinext/src/server/app-page-params.ts +++ b/packages/vinext/src/server/app-page-params.ts @@ -21,6 +21,17 @@ export function getAppPageSegmentParamName(segment: string): string | null { return null; } +export function createAppPageTreePath( + routeSegments: readonly string[] | null | undefined, + treePosition: number, +): string { + const treePathSegments = routeSegments?.slice(0, treePosition) ?? []; + if (treePathSegments.length === 0) { + return "/"; + } + return `/${treePathSegments.join("/")}`; +} + function isEmptyOptionalCatchAll(segment: string, paramValue: string | string[]): boolean { return segment.startsWith("[[...") && Array.isArray(paramValue) && paramValue.length === 0; } @@ -93,3 +104,21 @@ export function resolveAppPageBranchParams( ); return scopedParams; } + +/** Params of a layout at `treePosition` in a parallel slot's branch. */ +export function resolveSlotLayoutParams( + routeSegments: readonly string[], + treePosition: number, + params: AppPageParams, +): AppPageParams { + return resolveAppPageBranchParams(routeSegments, treePosition, params); +} + +/** Params of a sibling-page intercepting layout at `layoutSegments`. */ +export function resolveInterceptLayoutParams( + branchSegments: readonly string[], + layoutSegments: readonly string[], + params: AppPageParams, +): AppPageParams { + return resolveAppPageBranchParams(branchSegments, layoutSegments.length, params, layoutSegments); +} diff --git a/packages/vinext/src/server/app-page-prefetch-plan.ts b/packages/vinext/src/server/app-page-prefetch-plan.ts new file mode 100644 index 0000000000..fbb9b88885 --- /dev/null +++ b/packages/vinext/src/server/app-page-prefetch-plan.ts @@ -0,0 +1,210 @@ +import { + APP_RSC_RENDER_MODE_NAVIGATION, + APP_RSC_RENDER_MODE_PREFETCH_EMPTY, + APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL, + type AppRscRenderMode, +} from "./app-rsc-render-mode.js"; + +// Which parts of a route an App page render mode includes, shared by route +// wiring (which builds the payload) and the probes that run ahead of a +// response's headers. This module has no runtime dependency on either, so both +// can import it. + +type AppPagePrefetchModule = Readonly<{ default?: unknown }>; + +export type AppPageLoadingEntry = { + loadingModule?: TModule | null | undefined; + treePosition: number; +}; + +type AppPagePrefetchRouteLoadings = Readonly<{ + loading?: TModule | null; + loadings?: readonly (TModule | null | undefined)[] | null; + loadingTreePositions?: readonly number[] | null; +}>; + +type AppPagePrefetchSlot = Readonly<{ + loading?: TModule | null; + loadings?: readonly (TModule | null | undefined)[] | null; + loadingTreePositions?: readonly number[] | null; + name?: string; + ownerTreePosition?: number | null; +}>; + +type AppPagePrefetchSlotOverride = Readonly<{ + loadingModules?: readonly (TModule | null | undefined)[] | null; + loadingTreePositions?: readonly number[] | null; +}>; + +type AppPagePrefetchRoute = + AppPagePrefetchRouteLoadings & + Readonly<{ + routeSegments?: readonly string[] | null; + slots?: Readonly | null | undefined>> | null; + }>; + +export function createAppPageLoadingEntries( + route: AppPagePrefetchRouteLoadings, +): AppPageLoadingEntry[] { + return (route.loadings ?? []).flatMap((loadingModule, index) => { + if (!loadingModule) return []; + const treePosition = route.loadingTreePositions?.[index]; + if (treePosition === undefined) return []; + return [{ loadingModule, treePosition }]; + }); +} + +function getPrefetchLoadingEntry( + route: AppPagePrefetchRouteLoadings & + Readonly<{ routeSegments?: readonly string[] | null }>, +): AppPageLoadingEntry | null { + let rootEntry: AppPageLoadingEntry | null = null; + let firstNestedEntry: AppPageLoadingEntry | null = null; + for (const [index, loadingModule] of (route.loadings ?? []).entries()) { + if (!loadingModule?.default) continue; + const treePosition = route.loadingTreePositions?.[index]; + if (treePosition === undefined) continue; + if (treePosition === 0) { + rootEntry ??= { loadingModule, treePosition }; + } else if (firstNestedEntry === null || treePosition < firstNestedEntry.treePosition) { + firstNestedEntry = { loadingModule, treePosition }; + } + } + // The root layout is already shared for a client-side prefetch. Prefer the + // first loading boundary below it, falling back to the root loading UI only + // when no nested boundary exists. + if (firstNestedEntry) return firstNestedEntry; + if (rootEntry) return rootEntry; + + // Legacy/eager route fixtures may only expose the leaf loading field. + return route.loading?.default + ? { loadingModule: route.loading, treePosition: route.routeSegments?.length ?? 0 } + : null; +} + +export function createAppPageSlotLoadingEntries( + slot: AppPagePrefetchSlot, + override: AppPagePrefetchSlotOverride | null, +): AppPageLoadingEntry[] { + const entries: AppPageLoadingEntry[] = []; + const slotLoadingModules = + (slot.loadings?.length ?? 0) > 0 ? slot.loadings! : slot.loading ? [slot.loading] : []; + const slotLoadingTreePositions = + (slot.loadingTreePositions?.length ?? 0) > 0 ? slot.loadingTreePositions! : [0]; + + for (const [index, loadingModule] of slotLoadingModules.entries()) { + const treePosition = slotLoadingTreePositions[index]; + if (!loadingModule?.default || treePosition === undefined) continue; + // An interception replaces the slot's normal active branch. Only the slot + // root is necessarily shared; nested normal-branch loadings belong to a + // sibling subtree and must not wrap the intercepting page. + if (override && treePosition !== 0) continue; + entries.push({ loadingModule, treePosition }); + } + + for (const [index, loadingModule] of (override?.loadingModules ?? []).entries()) { + const treePosition = override?.loadingTreePositions?.[index]; + if (!loadingModule?.default || treePosition === undefined) continue; + entries.push({ loadingModule, treePosition }); + } + + return entries; +} + +export function getFirstLoadingEntry( + entries: readonly AppPageLoadingEntry[], +): AppPageLoadingEntry | null { + return entries.reduce | null>( + (first, entry) => (first === null || entry.treePosition < first.treePosition ? entry : first), + null, + ); +} + +export type AppPagePrefetchPlan = Readonly<{ + /** A `prefetch-empty` render includes no layout, template, page or slot. */ + isPrefetchEmpty: boolean; + isPrefetchLoadingShell: boolean; + /** The route loading boundary a loading-shell prefetch stops at. */ + prefetchLoadingEntry: AppPageLoadingEntry | null; + prefetchSlotLoadingEntries: readonly { ownerTreePosition: number }[]; + /** Whether a loading-shell prefetch includes the route segment at this position. */ + includesTreePosition(treePosition: number): boolean; + /** Whether the payload includes a parallel slot at all. */ + includesSlot(ownerTreePosition: number, targetTreePosition: number): boolean; + /** + * The loading boundary a loading-shell prefetch renders for an included slot + * in place of its page, or null when the shell omits the slot. A slot owned + * at the route's cutoff renders the route's loading UI and none of its + * branch layouts. + */ + resolveSlotLoadingEntry( + ownerTreePosition: number, + slotLoadingEntries: readonly AppPageLoadingEntry[], + ): { entry: AppPageLoadingEntry | null; isOwnedAtRoutePrefetchCutoff: boolean }; +}>; + +export function resolveAppPagePrefetchPlan(options: { + renderMode: AppRscRenderMode | undefined; + resolveSlotOverride: ( + slotKey: string, + slotName: string, + ) => AppPagePrefetchSlotOverride | null | undefined; + route: AppPagePrefetchRoute; +}): AppPagePrefetchPlan { + const renderMode = options.renderMode ?? APP_RSC_RENDER_MODE_NAVIGATION; + const isPrefetchEmpty = renderMode === APP_RSC_RENDER_MODE_PREFETCH_EMPTY; + const isPrefetchLoadingShell = renderMode === APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL; + const prefetchLoadingEntry = isPrefetchLoadingShell + ? getPrefetchLoadingEntry(options.route) + : null; + const prefetchSlotLoadingEntries = isPrefetchLoadingShell + ? Object.entries(options.route.slots ?? {}).flatMap(([slotKey, slot]) => { + if (!slot) return []; + const override = options.resolveSlotOverride(slotKey, slot.name ?? slotKey) ?? null; + const firstLoadingEntry = getFirstLoadingEntry( + createAppPageSlotLoadingEntries(slot, override), + ); + return firstLoadingEntry ? [{ ownerTreePosition: slot.ownerTreePosition ?? 0 }] : []; + }) + : []; + // The children spine must reach every slot owner whose branch has a loading + // boundary. A loading on the spine itself stops traversal first, matching + // Next.js's per-parallel-route pre-PPR component-tree walk. + const prefetchCutoffTreePosition = isPrefetchLoadingShell + ? (prefetchLoadingEntry?.treePosition ?? + prefetchSlotLoadingEntries.reduce( + (deepest, entry) => Math.max(deepest, entry.ownerTreePosition), + 0, + )) + : null; + const includesTreePosition = (treePosition: number): boolean => + prefetchCutoffTreePosition === null || treePosition <= prefetchCutoffTreePosition; + return { + isPrefetchEmpty, + isPrefetchLoadingShell, + prefetchLoadingEntry, + prefetchSlotLoadingEntries, + includesTreePosition, + includesSlot(ownerTreePosition, targetTreePosition) { + if (isPrefetchEmpty) return false; + if (!isPrefetchLoadingShell) return true; + return prefetchLoadingEntry + ? ownerTreePosition <= prefetchLoadingEntry.treePosition + : includesTreePosition(targetTreePosition); + }, + resolveSlotLoadingEntry(ownerTreePosition, slotLoadingEntries) { + const isOwnedAtRoutePrefetchCutoff = + isPrefetchLoadingShell && + prefetchLoadingEntry !== null && + ownerTreePosition === prefetchLoadingEntry.treePosition; + return { + entry: isOwnedAtRoutePrefetchCutoff + ? prefetchLoadingEntry + : isPrefetchLoadingShell + ? getFirstLoadingEntry(slotLoadingEntries) + : null, + isOwnedAtRoutePrefetchCutoff, + }; + }, + }; +} diff --git a/packages/vinext/src/server/app-page-probe.ts b/packages/vinext/src/server/app-page-probe.ts index eefb0cf16a..fafc2babba 100644 --- a/packages/vinext/src/server/app-page-probe.ts +++ b/packages/vinext/src/server/app-page-probe.ts @@ -1,4 +1,4 @@ -import { Fragment, isValidElement, type ReactElement, type ReactNode } from "react"; +import { createElement, Fragment, isValidElement, type ReactElement, type ReactNode } from "react"; import { markAppPagePropsForUseCache, withUseCachePageMarker, @@ -13,7 +13,24 @@ import { type LayoutFlags, } from "./app-page-execution.js"; import { makeObservedAppPageSearchParamsThenable } from "./app-page-search-params-observation.js"; +import { SIBLING_PAGE_INTERCEPT_SLOT_KEY } from "./app-rsc-route-matching.js"; import { isPromiseLike } from "../utils/promise.js"; +import type { AppPageParams } from "./app-page-boundary.js"; +import { + createAppPageTreePath, + resolveAppPageSegmentParams, + resolveInterceptLayoutParams, + resolveSlotLayoutParams, +} from "./app-page-params.js"; +import { createAppElementsWireSlotId } from "./app-elements-wire-key.js"; +import { + createAppPageLoadingEntries, + createAppPageSlotLoadingEntries, + getFirstLoadingEntry, + resolveAppPagePrefetchPlan, + type AppPageLoadingEntry, +} from "./app-page-prefetch-plan.js"; +import type { AppRscRenderMode } from "./app-rsc-render-mode.js"; const DEFAULT_SUBTREE_PROBE_MAX_DEPTH = 32; const DEFAULT_SUBTREE_PROBE_MAX_NODES = 1000; @@ -287,23 +304,37 @@ type AppPageProbeModule = Readonly<{ default?: unknown }> | null | undefined; type AppPageProbeSlot = | Readonly<{ + name?: string; + default?: AppPageProbeModule; + layout?: AppPageProbeModule; + layoutIndex?: number; + ownerTreePosition?: number | null; page?: AppPageProbeModule; loading?: AppPageProbeModule; loadings?: readonly AppPageProbeModule[] | null; loadingTreePositions?: readonly number[] | null; + configLayouts?: readonly AppPageProbeModule[] | null; + configLayoutTreePositions?: readonly number[] | null; + routeSegments?: readonly string[] | null; }> | null | undefined; type AppPageProbeRoute = Readonly<{ + layoutTreePositions?: readonly number[] | null; + routeSegments?: readonly string[] | null; slots?: Readonly> | null; }>; type AppPageProbeIntercept = | Readonly<{ page?: AppPageProbeModule; + interceptBranchSegments?: readonly string[] | null; + interceptLayouts?: readonly AppPageProbeModule[] | null; + interceptLayoutSegments?: readonly (readonly string[])[] | null; interceptLoadings?: readonly AppPageProbeModule[] | null; - matchedParams?: unknown; + interceptLoadingTreePositions?: readonly number[] | null; + matchedParams?: AppPageParams; /** * Key of the parallel-route slot this interception overrides. At render * time the matched route's `slots[slotKey].page` is replaced by the @@ -315,6 +346,46 @@ type AppPageProbeIntercept = | null | undefined; +/** + * The interception a request's probes cover, or null when it renders nothing. + * A slot intercept whose slot the route doesn't have leaves the route's tree + * unchanged (the renderer has no slot to put it in), so its page is neither + * loaded nor probed. A sibling-page intercept replaces the route's page. + */ +export function resolveAppPageProbeIntercept( + route: AppPageProbeRoute, + intercept: TIntercept, +): TIntercept | null { + const slotKey = intercept?.slotKey; + if ( + slotKey && + slotKey !== SIBLING_PAGE_INTERCEPT_SLOT_KEY && + !(route.slots && Object.hasOwn(route.slots, slotKey)) + ) { + return null; + } + return intercept ?? null; +} + +/** + * Where an active interception renders, as the element builder places it: in + * place of the page for a sibling-page intercept, or in the slot it names. + * Null when the route has no such slot, which keeps its own tree unchanged + * (buildSlotOverrides, resolveAppPageInterceptTree). + */ +function resolveAppPageInterceptPlacement( + route: AppPageProbeRoute, + intercept: AppPageProbeIntercept, +): + | { kind: "page" } + | { kind: "slot"; slotKey: string; slot: NonNullable } + | null { + if (!intercept?.slotKey) return null; + if (intercept.slotKey === SIBLING_PAGE_INTERCEPT_SLOT_KEY) return { kind: "page" }; + const slot = route.slots?.[intercept.slotKey]; + return slot ? { kind: "slot", slotKey: intercept.slotKey, slot } : null; +} + /** * Fan out the per-request page probes for the App Router dispatch lifecycle. * @@ -328,32 +399,27 @@ type AppPageProbeIntercept = * Entry Modules Should Stay Thin"). Returns a list of resolved promises so the * caller can `Promise.all` them. * - * The fan-out is scoped to the page components that render for this request: + * The fan-out is scoped to the page components that render for this request, + * as the element builder and route wiring select them: * - * - **Interception override:** when an interception matches it replaces the - * page of the slot named by `intercept.slotKey` (the element builder sets - * `overrides[slotKey].pageModule` to the interception page, which wins over - * `slot.page` in `app-page-route-wiring.tsx`). We probe the interception page - * in place of that slot's own page rather than probing both — probing the - * overridden slot page would mark an otherwise-static request dynamic for a - * component that never renders. - * - **Non-overridden slots:** `slot.page?.default` is exactly what renders. - * `app-page-route-wiring.tsx` resolves a slot to `overrideOrPageComponent ?? - * defaultComponent`, so whenever a slot has a `page.tsx` that page renders. - * When a slot has only a `default.tsx` (including the soft-nav case at - * `app-page-route-wiring.tsx:741` that skips an already-mounted slot), there - * is no `slot.page?.default`, so `probeAppPage` short-circuits to `null` and - * probes nothing — a no-op, not an over-bail. + * - **Interception:** a matched interception renders in place of the page for + * a sibling-page intercept, or replaces the page of the slot named by + * `intercept.slotKey` (`buildSlotOverrides`). We probe the interception page + * in place of what it replaces rather than probing both, since probing a + * component that never renders would mark an otherwise-static request + * dynamic. A route without the named slot renders unchanged, so its + * interception isn't probed. + * - **Other slots:** `slot.page?.default` is exactly what renders when the + * slot has a page. A default-only slot probes nothing — a no-op, not an + * over-bail. * * Interception only fires for RSC navigations (`resolveAppPageInterceptState` - * returns `kind: "none"` when `!isRscRequest`, app-page-request.ts:324), so the - * interception handling here is gated on `isRscRequest`. For non-RSC (HTML) - * requests the matched route renders normally, so we probe every slot's own - * page and skip the interception probe entirely. The remaining "source-route" - * interception case (where a *different* route renders, app-page-request.ts:342) - * never reaches this probe: `dispatchAppPage` returns the intercepted response - * before calling `probePage`, so by the time this runs any matched interception - * is the current-route override case above. + * returns `kind: "none"` when `!isRscRequest`), so the interception handling + * here is gated on `isRscRequest`. For non-RSC (HTML) requests the matched + * route renders normally, so we probe every slot's own page and skip the + * interception probe entirely. The matched route's probe sees the + * current-route override case; a direct intercepted RSC response probes the + * source route that it renders with `buildAppPageInterceptSourceProbes`. * * A `default.tsx` that itself awaits `searchParams` is not probed here, but the * real render still observes that access and skips the query-invariant cache @@ -379,13 +445,18 @@ export function buildAppPageProbes(options: { // Interception only fires for RSC navigations; on HTML requests the matched // route renders normally, so ignore any interception match entirely. const intercept = options.isRscRequest ? options.intercept : null; + const placement = resolveAppPageInterceptPlacement(route, intercept); - const probes: unknown[] = [probeAppPage({ pageComponent, asyncRouteParams, searchParams })]; + // A sibling-page intercept's page (probed below) renders in place of this one. + const probes: unknown[] = + placement?.kind === "page" + ? [] + : [probeAppPage({ pageComponent, asyncRouteParams, searchParams })]; // A slot whose page is replaced by an active interception override does not // render its own `page.tsx`; the interception page (probed below) renders in // its place, so skip the overridden slot to avoid a false dynamic bailout. - const overriddenSlotKey = intercept?.slotKey ?? null; + const overriddenSlotKey = placement?.kind === "slot" ? placement.slotKey : null; for (const [slotKey, slot] of Object.entries(route.slots ?? {})) { if (overriddenSlotKey !== null && slotKey === overriddenSlotKey) { @@ -403,7 +474,7 @@ export function buildAppPageProbes(options: { ); } - const interceptedSlot = intercept?.slotKey ? route.slots?.[intercept.slotKey] : null; + const interceptedSlot = placement?.kind === "slot" ? placement.slot : null; const interceptedSlotHasRootLoading = Boolean( interceptedSlot?.loading?.default || interceptedSlot?.loadings?.some( @@ -414,7 +485,7 @@ export function buildAppPageProbes(options: { intercept?.interceptLoadings?.some((loading) => loading?.default) || interceptedSlotHasRootLoading, ); - if (intercept && !interceptHasLoadingBoundary) { + if (intercept && placement && !interceptHasLoadingBoundary) { probes.push( probeAppPage({ pageComponent: intercept.page?.default, @@ -427,6 +498,287 @@ export function buildAppPageProbes(options: { return probes.map((probe) => Promise.resolve(probe)); } +type AppPageInterceptSourceProbeRoute = AppPageProbeRoute & + Readonly<{ + layouts?: readonly AppPageProbeModule[] | null; + loading?: AppPageProbeModule; + loadings?: readonly AppPageProbeModule[] | null; + loadingTreePositions?: readonly number[] | null; + templates?: readonly AppPageProbeModule[] | null; + templateTreePositions?: readonly number[] | null; + }>; + +type AppPageProbedLayout = { + layoutModule: AppPageProbeModule; + /** Null for a template, which gets only its children. */ + params: AppPageParams | null; + treePosition: number; +}; + +function ignoreProbeOutcome(): void {} + +/** + * Probes what a direct intercepted RSC response renders ahead of its loading + * boundaries, since it sets its headers before its render: the source route's + * layouts and templates, its page (or a sibling-page intercept's layouts and + * page), and each parallel slot's layout chain and page or default, with the + * intercepting branch in the slot it intercepts. What renders follows route + * wiring (app-page-route-wiring.tsx), including the render mode's prefetch + * plan (a loading-shell prefetch's selected route and slot loading components + * take the pages' place) and the default-only slots the client keeps mounted, + * and each component gets the params the rendered tree passes it. + * + * Each probe settles without rejecting: the response's own render surfaces + * special errors and other failures. + */ +export function buildAppPageInterceptSourceProbes(options: { + route: AppPageInterceptSourceProbeRoute; + pageComponent: unknown; + intercept?: AppPageProbeIntercept; + /** The source route's params, which the intercepted render passes its tree. */ + sourceParams: AppPageParams; + searchParams: URLSearchParams | null | undefined; + mountedSlotsHeader: string | null | undefined; + renderMode: AppRscRenderMode | undefined; + makeThenableParams: (params: unknown) => unknown; +}): Promise[] { + const { route, intercept, sourceParams, searchParams, makeThenableParams } = options; + const placement = resolveAppPageInterceptPlacement(route, intercept); + const interceptedSlotOverride = + placement?.kind === "slot" + ? { + loadingModules: intercept?.interceptLoadings, + loadingTreePositions: intercept?.interceptLoadingTreePositions, + } + : null; + const resolveSlotOverride = (slotKey: string) => + placement?.kind === "slot" && slotKey === placement.slotKey ? interceptedSlotOverride : null; + const prefetchPlan = resolveAppPagePrefetchPlan({ + renderMode: options.renderMode, + resolveSlotOverride, + route, + }); + if (prefetchPlan.isPrefetchEmpty) return []; + + const probes: Promise[] = []; + const probe = (run: () => unknown) => { + probes.push(Promise.resolve().then(run).then(ignoreProbeOutcome, ignoreProbeOutcome)); + }; + // Like the layout probes, stop at a loading boundary: what's below it + // streams behind its fallback, after the response headers. + const probeLayoutsAbove = ( + layouts: readonly AppPageProbedLayout[], + loadingTreePosition: number, + ) => { + for (const { layoutModule, params, treePosition } of layouts) { + const LayoutComponent = layoutModule?.default; + if (typeof LayoutComponent !== "function" || treePosition > loadingTreePosition) continue; + probe(() => + probeReactServerSubtree( + createElement( + LayoutComponent as (props: { params?: unknown }) => ReactNode, + params ? { params: makeThenableParams(params) } : null, + createElement(Fragment), + ), + ), + ); + } + }; + const firstLoadingTreePosition = (entries: readonly AppPageLoadingEntry[]) => + getFirstLoadingEntry(entries)?.treePosition ?? Infinity; + // A loading-shell prefetch renders its selected loading components directly, + // in place of the page or slot page they'd wrap. + const probeLoading = (entry: AppPageLoadingEntry | null) => { + const LoadingComponent = entry?.loadingModule?.default; + if (typeof LoadingComponent !== "function") return; + probe(() => probeReactServerSubtree(createElement(LoadingComponent as () => ReactNode))); + }; + + const routeLoadingEntries = createAppPageLoadingEntries(route).filter( + (entry) => entry.loadingModule?.default, + ); + // Legacy/eager route fixtures may only expose the leaf loading field. + if (routeLoadingEntries.length === 0 && route.loading?.default) { + routeLoadingEntries.push({ + loadingModule: route.loading, + treePosition: route.routeSegments?.length ?? 0, + }); + } + const routeLoadingTreePosition = firstLoadingTreePosition(routeLoadingEntries); + const routeLayouts: AppPageProbedLayout[] = []; + for (const [index, layoutModule] of (route.layouts ?? []).entries()) { + const treePosition = route.layoutTreePositions?.[index] ?? 0; + routeLayouts.push({ + layoutModule, + params: resolveAppPageSegmentParams(route.routeSegments, treePosition, sourceParams), + treePosition, + }); + } + for (const [index, layoutModule] of (route.templates ?? []).entries()) { + routeLayouts.push({ + layoutModule, + params: null, + treePosition: route.templateTreePositions?.[index] ?? 0, + }); + } + // A loading-shell prefetch renders no route Suspense boundary, only the + // layouts down to its cutoff and the loading UI selected there. + probeLayoutsAbove( + routeLayouts.filter(({ treePosition }) => prefetchPlan.includesTreePosition(treePosition)), + prefetchPlan.isPrefetchLoadingShell ? Infinity : routeLoadingTreePosition, + ); + probeLoading(prefetchPlan.prefetchLoadingEntry); + + // Every route loading boundary wraps the page, which a loading-shell + // prefetch omits. + if (routeLoadingTreePosition === Infinity && !prefetchPlan.isPrefetchLoadingShell) { + if (placement?.kind === "page") { + // The intercepting page and its layouts take the source page's place. + const interceptParams = intercept?.matchedParams ?? sourceParams; + const interceptLoadingEntries = (intercept?.interceptLoadings ?? []).flatMap( + (loadingModule, index) => { + const treePosition = intercept?.interceptLoadingTreePositions?.[index]; + return loadingModule?.default && treePosition !== undefined + ? [{ loadingModule, treePosition }] + : []; + }, + ); + probeLayoutsAbove( + (intercept?.interceptLayouts ?? []).map((layoutModule, index) => { + const layoutSegments = intercept?.interceptLayoutSegments?.[index] ?? []; + return { + layoutModule, + params: resolveInterceptLayoutParams( + intercept?.interceptBranchSegments ?? layoutSegments, + layoutSegments, + interceptParams, + ), + treePosition: layoutSegments.length, + }; + }), + firstLoadingTreePosition(interceptLoadingEntries), + ); + if (interceptLoadingEntries.length === 0) { + probe(() => + probeAppPage({ + pageComponent: intercept?.page?.default, + asyncRouteParams: makeThenableParams(interceptParams), + searchParams, + }), + ); + } + } else { + probe(() => + probeAppPage({ + pageComponent: options.pageComponent, + asyncRouteParams: makeThenableParams(sourceParams), + searchParams, + }), + ); + } + } + + const mountedSlotIds = options.mountedSlotsHeader + ? new Set(options.mountedSlotsHeader.split(" ")) + : null; + const layoutTreePositions = route.layoutTreePositions ?? []; + for (const [slotKey, slot] of Object.entries(route.slots ?? {})) { + if (!slot) continue; + const targetIndex = + (slot.layoutIndex ?? -1) >= 0 ? slot.layoutIndex! : layoutTreePositions.length - 1; + const targetTreePosition = layoutTreePositions[targetIndex] ?? 0; + const ownerTreePosition = slot.ownerTreePosition ?? targetTreePosition; + if (!prefetchPlan.includesSlot(ownerTreePosition, targetTreePosition)) continue; + // A route loading boundary at or above the slot's owner wraps the whole + // slot, except in a loading-shell prefetch. + if (!prefetchPlan.isPrefetchLoadingShell && routeLoadingTreePosition <= ownerTreePosition) { + continue; + } + const isIntercepted = placement?.kind === "slot" && placement.slotKey === slotKey; + const pageComponent = + (isIntercepted ? intercept?.page?.default : undefined) ?? slot.page?.default; + const defaultComponent = slot.default?.default; + const slotId = + slot.name === undefined + ? null + : createAppElementsWireSlotId( + slot.name, + createAppPageTreePath(route.routeSegments, targetTreePosition), + ); + // The payload leaves out a default-only slot the client keeps mounted. + if (!pageComponent && (!defaultComponent || (slotId && mountedSlotIds?.has(slotId)))) { + continue; + } + + const slotLoadingEntries = createAppPageSlotLoadingEntries( + slot, + isIntercepted ? interceptedSlotOverride : null, + ); + let slotLoadingTreePosition = firstLoadingTreePosition(slotLoadingEntries); + if (prefetchPlan.isPrefetchLoadingShell) { + // The shell renders a loading boundary in place of the slot's page. + const shellLoading = prefetchPlan.resolveSlotLoadingEntry( + ownerTreePosition, + slotLoadingEntries, + ); + if (!shellLoading.entry) continue; + slotLoadingTreePosition = shellLoading.isOwnedAtRoutePrefetchCutoff + ? -1 + : shellLoading.entry.treePosition; + // The route's loading UI, when the slot is owned at its cutoff, is + // already probed above. + if (!shellLoading.isOwnedAtRoutePrefetchCutoff) probeLoading(shellLoading.entry); + } + + const slotOwnerParams = resolveAppPageSegmentParams( + route.routeSegments, + targetTreePosition, + sourceParams, + ); + const slotParams = isIntercepted ? (intercept?.matchedParams ?? sourceParams) : sourceParams; + // The slot's own layout wraps its page, not its default. + const slotLayouts: AppPageProbedLayout[] = pageComponent + ? [{ layoutModule: slot.layout, params: slotOwnerParams, treePosition: 0 }] + : []; + if (isIntercepted) { + const branchSegments = intercept?.interceptBranchSegments ?? []; + for (const [index, layoutModule] of (intercept?.interceptLayouts ?? []).entries()) { + const treePosition = + intercept?.interceptLayoutSegments?.[index]?.length ?? branchSegments.length; + slotLayouts.push({ + layoutModule, + params: resolveSlotLayoutParams(branchSegments, treePosition, slotParams), + treePosition, + }); + } + } else { + for (const [index, layoutModule] of (slot.configLayouts ?? []).entries()) { + const treePosition = slot.configLayoutTreePositions?.[index] ?? 0; + slotLayouts.push({ + layoutModule, + params: { + ...slotOwnerParams, + ...resolveSlotLayoutParams(slot.routeSegments ?? [], treePosition, slotParams), + }, + treePosition, + }); + } + } + probeLayoutsAbove(slotLayouts, slotLoadingTreePosition); + if (slotLoadingTreePosition === Infinity) { + probe(() => + probeAppPage({ + pageComponent: pageComponent ?? defaultComponent, + asyncRouteParams: makeThenableParams(slotParams), + searchParams, + }), + ); + } + } + + return probes; +} + type ProbeAppPageBeforeRenderResult = { response: Response | null; layoutFlags: LayoutFlags; diff --git a/packages/vinext/src/server/app-page-request.ts b/packages/vinext/src/server/app-page-request.ts index d6fe14ab99..44e0810a15 100644 --- a/packages/vinext/src/server/app-page-request.ts +++ b/packages/vinext/src/server/app-page-request.ts @@ -1,14 +1,17 @@ import type { AppPageSpecialError } from "./app-page-execution.js"; import { runWithFetchDedupe } from "vinext/shims/fetch-cache"; import { getAppPageSegmentParamName } from "./app-page-params.js"; +import { APP_PAGE_INTERCEPTION_MARKER_TRAVERSALS } from "./app-page-interception-markers.js"; import { matchRoutePattern } from "../routing/route-pattern.js"; import { notFoundResponse } from "./http-error-responses.js"; import type { AppLayoutParamAccessTracker } from "./app-layout-param-observation.js"; import { loadAppInterceptLayouts, loadAppInterceptNotFound, + loadAppInterceptOwnerDefault, loadAppInterceptPage, } from "./app-route-module-loader.js"; +import { SIBLING_PAGE_INTERCEPT_SLOT_KEY } from "./app-rsc-route-matching.js"; type AppPageParams = Record; type GenerateStaticParams = (args: { params: AppPageParams }) => unknown; @@ -95,11 +98,15 @@ type AppPageInterceptMatch = { notFound?: unknown; __loadNotFound?: (() => Promise) | null; notFoundTreePosition?: number | null; + ownerDefault?: unknown; + __loadOwnerDefault?: (() => Promise) | null; __loadState?: { page: TPage; pageLoading: Promise | null; notFound?: unknown; notFoundLoading?: Promise | null; + ownerDefault?: unknown; + ownerDefaultLoading?: Promise | null; interceptLayoutsLoading: Promise | null; }; slotId?: string | null; @@ -115,16 +122,22 @@ type AppPageInterceptState = | { kind: "current-route"; intercept: AppPageInterceptMatch } | { kind: "source-route"; intercept: AppPageInterceptMatch; sourceRoute: TRoute }; -type ResolveAppPageInterceptStateOptions = { +type MatchAppPageInterceptOptions = { cleanPathname: string; currentRoute: TRoute; findIntercept: (pathname: string) => AppPageInterceptMatch | null; - getRouteParamNames: (route: TRoute) => readonly string[]; getSourceRoute: (sourceRouteIndex: number) => Awaitable; isRscRequest: boolean; - toInterceptOpts: (intercept: AppPageInterceptMatch) => TInterceptOpts; }; +type ResolveAppPageInterceptStateOptions = + MatchAppPageInterceptOptions & { + getRouteParamNames: (route: TRoute) => readonly string[]; + /** When given, an intercept the rendered route has no slot for isn't loaded. */ + routeHasSlot?: (route: TRoute, slotKey: string) => boolean; + toInterceptOpts: (intercept: AppPageInterceptMatch) => TInterceptOpts; + }; + type ResolveAppPageInterceptionRerenderTargetOptions = { cleanPathname: string; currentParams: AppPageParams; @@ -133,6 +146,8 @@ type ResolveAppPageInterceptionRerenderTargetOptions readonly string[]; getSourceRoute: (sourceRouteIndex: number) => Awaitable; isRscRequest: boolean; + /** When given, an intercept the rendered route has no slot for isn't loaded. */ + routeHasSlot?: (route: TRoute, slotKey: string) => boolean; toInterceptOpts: (intercept: AppPageInterceptMatch) => TInterceptOpts; }; @@ -174,10 +189,17 @@ type ResolveAppPageInterceptOptions = { pathname: string, interceptOpts: TInterceptOpts, ) => AppPageParams; - renderInterceptResponse: (route: TRoute, element: TElement) => Promise | Response; + /** Whether `route` has the parallel slot keyed `slotKey`. */ + routeHasSlot: (route: TRoute, slotKey: string) => boolean; + renderInterceptResponse: ( + route: TRoute, + element: TElement, + interceptOpts: TInterceptOpts, + ) => Promise | Response; resolveSearchParams?: ( route: TRoute, searchParams: URLSearchParams, + interceptOpts: TInterceptOpts, ) => Awaitable; searchParams: URLSearchParams; setNavigationContext: (context: { @@ -249,6 +271,18 @@ function remapRouteParams( return params; } +/** + * The param a loader-tree segment names. An intercepting route's tree keeps + * its interception markers, and `(.)[photo]` names the `photo` param. + * https://github.com/vercel/next.js/blob/v16.2.7/packages/next/src/shared/lib/router/utils/get-segment-param.tsx + */ +function getGenerateStaticParamsSegmentParamName(segment: string): string | null { + const marker = APP_PAGE_INTERCEPTION_MARKER_TRAVERSALS.find(({ prefix }) => + segment.startsWith(prefix), + ); + return getAppPageSegmentParamName(marker ? segment.slice(marker.prefix.length) : segment); +} + function collectParentParamNames( routeSegments: readonly string[], boundaryPosition: number, @@ -257,7 +291,7 @@ function collectParentParamNames( const names: string[] = []; for (const segment of routeSegments.slice(0, limit)) { - const name = getAppPageSegmentParamName(segment); + const name = getGenerateStaticParamsSegmentParamName(segment); if (name && !names.includes(name)) { names.push(name); } @@ -295,7 +329,7 @@ function getParallelParentParamNames( const branchParamNames = collectParentParamNames(branch.routeSegments ?? [], boundaryPosition); const branchParamNameSet = new Set( (branch.routeSegments ?? []).flatMap((segment) => { - const name = getAppPageSegmentParamName(segment); + const name = getGenerateStaticParamsSegmentParamName(segment); return name ? [name] : []; }), ); @@ -336,7 +370,7 @@ export function resolveAppPageGenerateStaticParamsSources( } const routeParamNames = options.routeSegments.flatMap((segment) => { - const name = getAppPageSegmentParamName(segment); + const name = getGenerateStaticParamsSegmentParamName(segment); return name ? [name] : []; }); for (const [independentChain, parallelBranch] of (options.parallelBranches ?? []).entries()) { @@ -587,6 +621,14 @@ export async function validateAppPageDynamicParams( if (generateStaticParamsSources.length === 0) { return notFoundResponse(); } + // Matching leaves an omitted optional catch-all out of the params, but + // Next.js generates that path only from an explicit empty value, so its + // key is still compared. + const paramKeys = + options.requiredParamNames ?? + Array.from( + new Set([...Object.keys(options.params), ...(options.optionalCatchAllParamNames ?? [])]), + ); const chainedSources = generateStaticParamsSources.filter((source) => source.chained); let chainedStaticParams: Record[] | null = null; @@ -620,7 +662,7 @@ export async function validateAppPageDynamicParams( options.params, result.staticParams, options.requiredParamNames === undefined, - options.requiredParamNames, + paramKeys, options.optionalCatchAllParamNames, ) ) { @@ -638,7 +680,15 @@ export async function validateAppPageDynamicParams( // results are validated against those combinations above; without a // parallel result, the primary chain itself must match exactly. // https://github.com/vercel/next.js/blob/v16.2.7/packages/next/src/build/static-paths/app.ts - if (!areStaticParamsAllowed(options.params, chainedStaticParams)) { + if ( + !areStaticParamsAllowed( + options.params, + chainedStaticParams, + false, + paramKeys, + options.optionalCatchAllParamNames, + ) + ) { return notFoundResponse(); } } @@ -646,8 +696,8 @@ export async function validateAppPageDynamicParams( return null; } -async function resolveAppPageInterceptState( - options: ResolveAppPageInterceptStateOptions, +async function matchAppPageInterceptState( + options: MatchAppPageInterceptOptions, ): Promise> { if (!options.isRscRequest) { return { kind: "none" }; @@ -658,12 +708,6 @@ async function resolveAppPageInterceptState( return { kind: "none" }; } - await loadAppInterceptPage(intercept); - await loadAppInterceptNotFound(intercept); - if (intercept.__loadInterceptLayouts || intercept.__loadInterceptLoadings) { - await loadAppInterceptLayouts(intercept); - } - const sourceRoute = await options.getSourceRoute(intercept.sourceRouteIndex); if (!sourceRoute) { return { kind: "none" }; @@ -676,6 +720,104 @@ async function resolveAppPageInterceptState( return { kind: "source-route", intercept, sourceRoute }; } +async function resolveAppPageInterceptState( + options: ResolveAppPageInterceptStateOptions, +): Promise> { + const interceptState = await matchAppPageInterceptState(options); + if (interceptState.kind === "none") return interceptState; + const renderRoute = + interceptState.kind === "source-route" ? interceptState.sourceRoute : options.currentRoute; + if (!options.routeHasSlot) { + await loadAppPageInterceptModules(interceptState.intercept); + return interceptState; + } + if ( + isAppPageInterceptAttachedToRoute(interceptState.intercept, renderRoute, options.routeHasSlot) + ) { + await loadAppPageInterceptModules(interceptState.intercept); + // A rerender classifies the intercepting route's tree like the render it + // repeats, so that tree's owner default is loaded too. + await loadAppPageInterceptOwnerDefault( + interceptState.intercept, + renderRoute, + options.routeHasSlot, + ); + } + return interceptState; +} + +async function loadAppPageInterceptModules( + intercept: AppPageInterceptMatch, +): Promise { + await loadAppInterceptPage(intercept); + await loadAppInterceptNotFound(intercept); + if (intercept.__loadInterceptLayouts || intercept.__loadInterceptLoadings) { + await loadAppInterceptLayouts(intercept); + } +} + +/** + * The interception an RSC request renders its own matched route with, when + * that route is also the interception's source. Nothing is loaded yet. + */ +export async function matchAppPageCurrentRouteIntercept( + options: MatchAppPageInterceptOptions, +): Promise | null> { + const interceptState = await matchAppPageInterceptState(options); + return interceptState.kind === "current-route" ? interceptState.intercept : null; +} + +/** + * Whether an interception puts its branch into `route`'s tree. A sibling-page + * intercept always replaces the route's page; a slot intercept needs the route + * to have the intercepted slot, and otherwise the route renders unchanged, as + * `isAppPageInterceptAttached` classifies it. + */ +export function isAppPageInterceptAttachedToRoute( + intercept: AppPageInterceptMatch, + route: TRoute, + routeHasSlot: (route: TRoute, slotKey: string) => boolean, +): boolean { + return ( + intercept.slotKey === SIBLING_PAGE_INTERCEPT_SLOT_KEY || routeHasSlot(route, intercept.slotKey) + ); +} + +/** + * Load the modules of a current-route interception's intercepting tree, as + * `resolveAppPageIntercept` does before rendering it. An intercept the route + * has no slot for renders nothing, so none of its modules are evaluated. + */ +export async function loadAppPageCurrentRouteIntercept( + intercept: AppPageInterceptMatch, + currentRoute: TRoute, + routeHasSlot: (route: TRoute, slotKey: string) => boolean, +): Promise { + if (!isAppPageInterceptAttachedToRoute(intercept, currentRoute, routeHasSlot)) return; + await loadAppPageInterceptModules(intercept); + await loadAppPageInterceptOwnerDefault(intercept, currentRoute, routeHasSlot); +} + +/** + * Only the intercepting route's tree holds the default of the folder that + * owns a slot intercept, in place of that folder's children. That tree exists + * only when the concrete source has the intercepted slot: a sibling-page + * intercept replaces the source's page instead, and a source variant without + * the slot renders unchanged, so neither evaluates the owner's default. + */ +async function loadAppPageInterceptOwnerDefault( + intercept: AppPageInterceptMatch, + sourceRoute: TRoute, + routeHasSlot: (route: TRoute, slotKey: string) => boolean, +): Promise { + if ( + intercept.slotKey !== SIBLING_PAGE_INTERCEPT_SLOT_KEY && + routeHasSlot(sourceRoute, intercept.slotKey) + ) { + await loadAppInterceptOwnerDefault(intercept); + } +} + export async function resolveAppPageInterceptionRerenderTarget( options: ResolveAppPageInterceptionRerenderTargetOptions, ): Promise> { @@ -686,6 +828,7 @@ export async function resolveAppPageInterceptionRerenderTarget( options: ResolveAppPageInterceptOptions, ): Promise> { - const interceptState = await resolveAppPageInterceptState({ + const interceptState = await matchAppPageInterceptState({ cleanPathname: options.cleanPathname, currentRoute: options.currentRoute, findIntercept: options.findIntercept, - getRouteParamNames: options.getRouteParamNames, getSourceRoute: options.getSourceRoute, isRscRequest: options.isRscRequest, - toInterceptOpts: options.toInterceptOpts, }); if (interceptState.kind === "source-route") { const renderRoute = interceptState.sourceRoute; + // A slot intercept the source has no slot for renders the source + // unchanged, so none of its modules are evaluated. + if ( + isAppPageInterceptAttachedToRoute(interceptState.intercept, renderRoute, options.routeHasSlot) + ) { + await loadAppPageInterceptModules(interceptState.intercept); + } + // Only a rendered interception classifies the intercepting route's tree. + await loadAppPageInterceptOwnerDefault( + interceptState.intercept, + renderRoute, + options.routeHasSlot, + ); const interceptOpts = options.toInterceptOpts(interceptState.intercept); const sourceMatchedParams = interceptState.intercept.sourceMatchedParams ?? interceptState.intercept.matchedParams; @@ -746,7 +900,7 @@ export async function resolveAppPageIntercept( diff --git a/packages/vinext/src/server/app-page-route-wiring.tsx b/packages/vinext/src/server/app-page-route-wiring.tsx index 7c3d681589..222cc7e46f 100644 --- a/packages/vinext/src/server/app-page-route-wiring.tsx +++ b/packages/vinext/src/server/app-page-route-wiring.tsx @@ -46,9 +46,10 @@ import { type AppRenderDependency, } from "./app-render-dependency.js"; import { - resolveAppPageBranchParams, + createAppPageTreePath, resolveAppPageSegmentParamScopeKeys, resolveAppPageSegmentParams, + resolveSlotLayoutParams, } from "./app-page-params.js"; import { probeReactServerSubtree } from "./app-page-probe.js"; import { @@ -65,6 +66,12 @@ import { type AppPageSemanticSegment, } from "./app-page-segment-state.js"; import { createAppPageSegmentPlan } from "./app-page-segment-plan.js"; +import { + createAppPageLoadingEntries, + createAppPageSlotLoadingEntries, + resolveAppPagePrefetchPlan, + type AppPageLoadingEntry, +} from "./app-page-prefetch-plan.js"; import type { AppPageRenderIdentity } from "./app-page-render-identity.js"; import { resolveAppPageModuleTraceSegment, @@ -87,14 +94,6 @@ const APP_PAGE_LAYOUT_PROBE_CHILD = ; const DEFAULT_GLOBAL_ERROR_COMPONENT = DefaultGlobalError as AppPageErrorComponent; const DEFAULT_NOT_FOUND_COMPONENT = DefaultNotFound as AppPageComponent; -function resolveSlotLayoutParams( - routeSegments: readonly string[], - treePosition: number, - params: AppPageParams, -): AppPageParams { - return resolveAppPageBranchParams(routeSegments, treePosition, params); -} - export type AppPageModule = Record & { default?: AppPageComponent | null | undefined; }; @@ -289,11 +288,6 @@ type AppPageErrorEntry = { - loadingModule?: TModule | null | undefined; - treePosition: number; -}; - function getDefaultExport( module: TModule | null | undefined, ): AppPageComponent | null { @@ -306,17 +300,6 @@ function getErrorBoundaryExport( return module?.default ?? null; } -export function createAppPageTreePath( - routeSegments: readonly string[] | null | undefined, - treePosition: number, -): string { - const treePathSegments = routeSegments?.slice(0, treePosition) ?? []; - if (treePathSegments.length === 0) { - return "/"; - } - return `/${treePathSegments.join("/")}`; -} - function readFiniteRevalidateSeconds(module: AppPageModule | null | undefined): number | null { const revalidate = module?.revalidate; return typeof revalidate === "number" && Number.isFinite(revalidate) && revalidate > 0 @@ -467,17 +450,6 @@ function createAppPageErrorEntries( }); } -function createAppPageLoadingEntries( - route: Pick, "loadings" | "loadingTreePositions">, -): AppPageLoadingEntry[] { - return (route.loadings ?? []).flatMap((loadingModule, index) => { - if (!loadingModule) return []; - const treePosition = route.loadingTreePositions?.[index]; - if (treePosition === undefined) return []; - return [{ loadingModule, treePosition }]; - }); -} - export function resolveAppPageLoadingModuleAtOrAbove( route: Pick, "loading" | "loadings" | "loadingTreePositions">, treePosition: number, @@ -499,74 +471,6 @@ export function resolveAppPageLoadingModuleAtOrAbove( - route: Pick< - AppPageRouteWiringRoute, - "loading" | "loadings" | "loadingTreePositions" | "routeSegments" - >, -): AppPageLoadingEntry | null { - let rootEntry: AppPageLoadingEntry | null = null; - let firstNestedEntry: AppPageLoadingEntry | null = null; - for (const [index, loadingModule] of (route.loadings ?? []).entries()) { - if (!getDefaultExport(loadingModule)) continue; - const treePosition = route.loadingTreePositions?.[index]; - if (treePosition === undefined) continue; - if (treePosition === 0) { - rootEntry ??= { loadingModule, treePosition }; - } else if (firstNestedEntry === null || treePosition < firstNestedEntry.treePosition) { - firstNestedEntry = { loadingModule, treePosition }; - } - } - // The root layout is already shared for a client-side prefetch. Prefer the - // first loading boundary below it, falling back to the root loading UI only - // when no nested boundary exists. - if (firstNestedEntry) return firstNestedEntry; - if (rootEntry) return rootEntry; - - // Legacy/eager route fixtures may only expose the leaf loading field. - return getDefaultExport(route.loading) - ? { loadingModule: route.loading, treePosition: route.routeSegments?.length ?? 0 } - : null; -} - -function createAppPageSlotLoadingEntries( - slot: Pick, "loading" | "loadings" | "loadingTreePositions">, - override: Pick, "loadingModules" | "loadingTreePositions"> | null, -): AppPageLoadingEntry[] { - const entries: AppPageLoadingEntry[] = []; - const slotLoadingModules = - (slot.loadings?.length ?? 0) > 0 ? slot.loadings! : slot.loading ? [slot.loading] : []; - const slotLoadingTreePositions = - (slot.loadingTreePositions?.length ?? 0) > 0 ? slot.loadingTreePositions! : [0]; - - for (const [index, loadingModule] of slotLoadingModules.entries()) { - const treePosition = slotLoadingTreePositions[index]; - if (!getDefaultExport(loadingModule) || treePosition === undefined) continue; - // An interception replaces the slot's normal active branch. Only the slot - // root is necessarily shared; nested normal-branch loadings belong to a - // sibling subtree and must not wrap the intercepting page. - if (override && treePosition !== 0) continue; - entries.push({ loadingModule, treePosition }); - } - - for (const [index, loadingModule] of (override?.loadingModules ?? []).entries()) { - const treePosition = override?.loadingTreePositions?.[index]; - if (!getDefaultExport(loadingModule) || treePosition === undefined) continue; - entries.push({ loadingModule, treePosition }); - } - - return entries; -} - -function getFirstLoadingEntry( - entries: readonly AppPageLoadingEntry[], -): AppPageLoadingEntry | null { - return entries.reduce | null>( - (first, entry) => (first === null || entry.treePosition < first.treePosition ? entry : first), - null, - ); -} - function createAppPageParallelSlotEntries< TModule extends AppPageModule, TErrorModule extends AppPageErrorModule, @@ -873,9 +777,6 @@ export function buildAppPageElements< // Loading-shell prefetches intentionally omit the page, so they cannot wait // on a dependency that only the page invocation can release. const pageRenderDependency = isPrefetchLoadingShell ? null : options.pageRenderDependency; - const prefetchLoadingEntry = isPrefetchLoadingShell - ? getPrefetchLoadingEntry(options.route) - : null; const metadataPlacement = options.metadataPlacement ?? "head"; const layoutEntriesByTreePosition = new Map>(); const templateEntriesByTreePosition = new Map>(); @@ -944,35 +845,17 @@ export function buildAppPageElements< routePath: options.routePath, }, ); - const prefetchSlotLoadingEntries = isPrefetchLoadingShell - ? Object.entries(options.route.slots ?? {}).flatMap(([slotKey, slot]) => { - const override = resolveSlotOverride(slotKey, slot.name) ?? null; - const firstLoadingEntry = getFirstLoadingEntry( - createAppPageSlotLoadingEntries(slot, override), - ); - return firstLoadingEntry ? [{ ownerTreePosition: slot.ownerTreePosition ?? 0 }] : []; - }) - : []; - // The children spine must reach every slot owner whose branch has a loading - // boundary. A loading on the spine itself stops traversal first, matching - // Next.js's per-parallel-route pre-PPR component-tree walk. - const prefetchCutoffTreePosition = isPrefetchLoadingShell - ? (prefetchLoadingEntry?.treePosition ?? - prefetchSlotLoadingEntries.reduce( - (deepest, entry) => Math.max(deepest, entry.ownerTreePosition), - 0, - )) - : null; - const includesPrefetchTreePosition = (treePosition: number): boolean => - prefetchCutoffTreePosition === null || treePosition <= prefetchCutoffTreePosition; + // Shared with the probes a direct intercepted RSC response runs before its + // headers, so they include what this render does. + const prefetchPlan = resolveAppPagePrefetchPlan({ + renderMode, + resolveSlotOverride, + route: options.route, + }); + const { prefetchLoadingEntry, prefetchSlotLoadingEntries } = prefetchPlan; + const includesPrefetchTreePosition = prefetchPlan.includesTreePosition; const segmentPlan = createAppPageSegmentPlan({ - includeSlot(ownerTreePosition, targetTreePosition) { - if (isPrefetchEmpty) return false; - if (!isPrefetchLoadingShell) return true; - return prefetchLoadingEntry - ? ownerTreePosition <= prefetchLoadingEntry.treePosition - : includesPrefetchTreePosition(targetTreePosition); - }, + includeSlot: prefetchPlan.includesSlot, interception, layoutEntries, matchedParams: options.matchedParams, @@ -1232,10 +1115,6 @@ export function buildAppPageElements< slotOverride, targetIndex, } = slotPlan; - const isOwnedAtRoutePrefetchCutoff = - isPrefetchLoadingShell && - prefetchLoadingEntry !== null && - ownerTreePosition === prefetchLoadingEntry.treePosition; if (!includedInPayload) continue; const slotOwnerParams = resolveAppPageSegmentParams( options.route.routeSegments, @@ -1248,11 +1127,8 @@ export function buildAppPageElements< slot, hasSlotTreeOverride ? (slotOverride ?? null) : null, ); - const prefetchSlotLoadingEntry = isOwnedAtRoutePrefetchCutoff - ? prefetchLoadingEntry - : isPrefetchLoadingShell - ? getFirstLoadingEntry(slotLoadingEntries) - : null; + const { entry: prefetchSlotLoadingEntry, isOwnedAtRoutePrefetchCutoff } = + prefetchPlan.resolveSlotLoadingEntry(ownerTreePosition, slotLoadingEntries); if (isPrefetchLoadingShell && prefetchSlotLoadingEntry === null) { continue; } diff --git a/packages/vinext/src/server/app-route-module-loader.ts b/packages/vinext/src/server/app-route-module-loader.ts index a914eff9d0..0d9cefc4a1 100644 --- a/packages/vinext/src/server/app-route-module-loader.ts +++ b/packages/vinext/src/server/app-route-module-loader.ts @@ -42,6 +42,8 @@ type LazyLoadableIntercept = { __pageLoader?: LazyModuleThunk | null; notFound?: unknown; __loadNotFound?: LazyModuleThunk | null; + ownerDefault?: unknown; + __loadOwnerDefault?: LazyModuleThunk | null; interceptLayouts?: readonly unknown[] | null; __loadInterceptLayouts?: LazyModuleLoaderArray | null; interceptLoadings?: readonly unknown[] | null; @@ -51,6 +53,8 @@ type LazyLoadableIntercept = { pageLoading?: Promise | null; notFound?: unknown; notFoundLoading?: Promise | null; + ownerDefault?: unknown; + ownerDefaultLoading?: Promise | null; interceptLayoutsLoading: Promise | null; }; }; @@ -166,8 +170,8 @@ function pushArrayLoads( async function hydrateInterceptModule( intercept: LazyLoadableIntercept, loader: LazyModuleThunk | null | undefined, - field: "page" | "notFound", - loadingField: "pageLoading" | "notFoundLoading", + field: "page" | "notFound" | "ownerDefault", + loadingField: "pageLoading" | "notFoundLoading" | "ownerDefaultLoading", ): Promise { const loadState = intercept.__loadState; const cached = loadState?.[field]; @@ -207,6 +211,20 @@ export function loadAppInterceptNotFound(intercept: LazyLoadableIntercept): Prom return hydrateInterceptModule(intercept, intercept.__loadNotFound, "notFound", "notFoundLoading"); } +/** + * Hydrate the `default` module of the folder that owns a slot intercept onto + * `intercept.ownerDefault`. Only the intercepting route's tree holds it, in + * place of that folder's children, so a direct request never evaluates it. + */ +export function loadAppInterceptOwnerDefault(intercept: LazyLoadableIntercept): Promise { + return hydrateInterceptModule( + intercept, + intercept.__loadOwnerDefault, + "ownerDefault", + "ownerDefaultLoading", + ); +} + export function loadAppInterceptLayouts( intercept: LazyLoadableIntercept, ): Promise { diff --git a/packages/vinext/src/server/app-rsc-route-matching.ts b/packages/vinext/src/server/app-rsc-route-matching.ts index 9d67f82e43..c714538304 100644 --- a/packages/vinext/src/server/app-rsc-route-matching.ts +++ b/packages/vinext/src/server/app-rsc-route-matching.ts @@ -57,6 +57,8 @@ type AppRscInterceptForMatching = { notFound?: unknown; __loadNotFound?: (() => Promise) | null; notFoundTreePosition?: number | null; + ownerDefault?: unknown; + __loadOwnerDefault?: (() => Promise) | null; params: readonly string[]; }; @@ -114,6 +116,8 @@ type AppRscInterceptLoadState = { pageLoading: Promise | null; notFound: unknown; notFoundLoading: Promise | null; + ownerDefault?: unknown; + ownerDefaultLoading?: Promise | null; interceptLayoutsLoading: Promise | null; }; @@ -140,6 +144,8 @@ type AppRscInterceptLookupEntry = { notFound: unknown; __loadNotFound?: (() => Promise) | null; notFoundTreePosition?: number | null; + ownerDefault?: unknown; + __loadOwnerDefault?: (() => Promise) | null; __loadState: AppRscInterceptLoadState; params: readonly string[]; slotId: string | null; @@ -488,11 +494,15 @@ function createInterceptLookup( notFound: intercept.notFound, __loadNotFound: intercept.__loadNotFound, notFoundTreePosition: intercept.notFoundTreePosition, + ownerDefault: intercept.ownerDefault ?? null, + __loadOwnerDefault: intercept.__loadOwnerDefault, __loadState: { page: intercept.page, pageLoading: null, notFound: intercept.notFound, notFoundLoading: null, + ownerDefault: intercept.ownerDefault ?? null, + ownerDefaultLoading: null, interceptLayoutsLoading: null, }, params: intercept.params, diff --git a/packages/vinext/src/server/app-segment-config.ts b/packages/vinext/src/server/app-segment-config.ts index 146c3490c5..687e4c186b 100644 --- a/packages/vinext/src/server/app-segment-config.ts +++ b/packages/vinext/src/server/app-segment-config.ts @@ -1,5 +1,7 @@ import type { FetchCacheMode } from "vinext/shims/fetch-cache"; +import { APP_PAGE_INTERCEPTION_MARKER_TRAVERSALS } from "./app-page-interception-markers.js"; import { isEdgeApiRuntime } from "./edge-api-runtime.js"; +import { getAppPageSegmentParamName } from "./app-page-params.js"; type AppRouteSegmentDynamic = "auto" | "error" | "force-dynamic" | "force-static"; @@ -25,6 +27,11 @@ type EffectiveAppPageSegmentConfig = { type ParallelAppPageSegmentConfigBranch = { configLayouts?: readonly (AppRouteSegmentConfigModule | null | undefined)[] | null; configLayoutTreePositions?: readonly number[] | null; + /** + * The slot's `default` module, which replaces an active slot page when a + * sibling branch intercepts. + */ + default?: AppRouteSegmentConfigModule | null; /** Whether the slot renders its `default` module instead of a matched page. */ isDefault?: boolean; layout?: AppRouteSegmentConfigModule | null; @@ -111,7 +118,13 @@ function resolveDynamicStaleTimeSeconds( } function isDynamicSegment(segment: string): boolean { - return segment.startsWith("[") && segment.endsWith("]"); + // An intercepting folder keeps its marker: `(.)[photo]` is the `photo` param. + // https://github.com/vercel/next.js/blob/v16.2.7/packages/next/src/shared/lib/router/utils/get-segment-param.tsx + const marker = APP_PAGE_INTERCEPTION_MARKER_TRAVERSALS.find(({ prefix }) => + segment.startsWith(prefix), + ); + const name = marker ? segment.slice(marker.prefix.length) : segment; + return name.startsWith("[") && name.endsWith("]"); } function resolveSegmentConfigOwnerPosition( @@ -273,6 +286,12 @@ function describeFetchCacheConflict(value: FetchCacheMode): string { */ export function resolveAppPageSegmentConfig( options: ResolveAppPageSegmentConfigOptions, +): EffectiveAppPageSegmentConfig { + return applyAppPageFetchCacheDefault(reduceAppPageSegmentConfig(options)); +} + +function reduceAppPageSegmentConfig( + options: ResolveAppPageSegmentConfigOptions, ): EffectiveAppPageSegmentConfig { const segments = [...(options.layouts ?? []), options.page]; const parallelSegments = getParallelSegments(options); @@ -398,6 +417,12 @@ export function resolveAppPageSegmentConfig( config.revalidateSeconds = 0; } + return config; +} + +function applyAppPageFetchCacheDefault( + config: EffectiveAppPageSegmentConfig, +): EffectiveAppPageSegmentConfig { // Static-only dynamic modes supply fetchCache defaults unless a segment does. // `dynamic = "force-dynamic"` is handled at the fetch decision layer: it // defaults no-config fetches to no-store but must not override explicit @@ -515,6 +540,277 @@ function mergeParallelRuntime( return isEdgeRuntime(current) ? current : sibling; } +/** + * The modules and shape of an App page's loader tree, as the static + * generation helpers read it. + */ +export type AppPageSegmentConfigTree = Pick< + ResolveAppPageSegmentConfigOptions, + "layoutTreePositions" | "layouts" | "page" | "parallelBranches" | "routeSegments" +> & { childrenSlot?: AppPageChildrenSlot | null }; + +type AppPageInterceptAttachment = AppPageSegmentConfigTree & { + /** Whether the intercept replaces the source's page rather than a slot. */ + isSiblingPageIntercept: boolean; + /** Index of the intercepted slot's branch in `parallelBranches`, or -1. */ + slotIndex: number; +}; + +function resolveAppPageInterceptedSlot( + options: AppPageInterceptAttachment, +): ParallelAppPageSegmentConfigBranch | null { + return options.slotIndex === -1 ? null : (options.parallelBranches?.[options.slotIndex] ?? null); +} + +/** + * Whether an attached intercept's branch adds a dynamic segment to the source + * route's tree, whose params the intercepting tree then renders. + */ +export function hasAppPageInterceptDynamicSegment( + interceptBranchSegments: readonly string[] | null | undefined, +): boolean { + return (interceptBranchSegments ?? []).some( + (segment) => getAppPageSegmentParamName(segment) !== null, + ); +} + +/** + * Whether an intercepting route is dynamic. Next.js's `isDynamicRoute` + * classifies an intercepting app path by the route it intercepts, built from + * its folders (`interceptTargetPatternParts`), not by its source route's + * segments. + * https://github.com/vercel/next.js/blob/v16.2.7/packages/next/src/shared/lib/router/utils/is-dynamic.ts + */ +export function isAppPageInterceptTargetDynamic( + interceptTargetPatternParts: readonly string[] | null | undefined, +): boolean { + return (interceptTargetPatternParts ?? []).some((part) => part.startsWith(":")); +} + +/** + * Whether an intercept puts its branch into the source route's tree. A + * sibling-page intercept always replaces the source's page; a slot intercept + * needs the source to have the intercepted slot, and otherwise the source + * renders unchanged (see `resolveAppPageInterceptTree`). + */ +export function isAppPageInterceptAttached(options: AppPageInterceptAttachment): boolean { + return options.isSiblingPageIntercept || resolveAppPageInterceptedSlot(options) !== null; +} + +/** + * A direct intercepted RSC response renders the source route with the + * intercepting branch in place of what it intercepts. Next.js serves it from + * the intercepting route, an app path of its own whose loader tree holds the + * source's layouts and that branch, and classifies that tree. + * + * - A slot intercept replaces the intercepted slot's branch, which holds the + * layouts of the slot's folders above the marker, then those at and below + * it. The children of the slot's folder are replaced too, by the folder's + * `default` module (or the built-in `default-null`) as a `__DEFAULT__` + * leaf, which drops the source's page and every folder below. vinext still + * renders the source's page, so `keepActiveSiblings` keeps it. + * - A slot intercept whose slot the source route doesn't have renders nothing + * in its place: vinext renders the source route unchanged, so its tree stays. + * - A sibling-page intercept replaces the source's page: its folders, layouts + * and page continue the main tree below the source page's folder. + * - At each folder on the intercepting branch's path, every other slot is + * replaced by its `default` module alone, as a `__DEFAULT__` leaf without + * the slot's layouts. vinext still renders those slots' active pages, so + * `keepActiveSiblings` resolves the tree it renders instead. + * https://github.com/vercel/next.js/blob/v16.2.7/crates/next-core/src/app_structure.rs#L1270-L1290 + * https://github.com/vercel/next.js/blob/v16.2.7/crates/next-core/src/app_structure.rs#L1414-L1473 + * https://github.com/vercel/next.js/blob/v16.2.7/crates/next-core/src/app_structure.rs#L1515-L1548 + */ +export function resolveAppPageInterceptTree( + options: AppPageInterceptAttachment & { + interceptBranchSegments?: readonly string[] | null; + interceptLayoutSegments?: readonly (readonly string[])[] | null; + interceptLayouts?: readonly (AppRouteSegmentConfigModule | null | undefined)[] | null; + /** + * The `default` module of the folder that owns an intercepted slot, which + * replaces that folder's children in the intercepting route's tree. + */ + interceptOwnerDefault?: AppRouteSegmentConfigModule | null; + interceptPage?: AppRouteSegmentConfigModule | null; + /** + * Whether other slots, and a slot intercept's children, keep the branches + * vinext renders, not their defaults. + */ + keepActiveSiblings?: boolean; + }, +): AppPageSegmentConfigTree { + const interceptLayouts = options.interceptLayouts ?? []; + const interceptLayoutDepths = interceptLayouts.map( + (_, index) => options.interceptLayoutSegments?.[index]?.length ?? 0, + ); + const tree: AppPageSegmentConfigTree = { + childrenSlot: options.childrenSlot, + layoutTreePositions: options.layoutTreePositions, + layouts: options.layouts, + page: options.page, + parallelBranches: options.parallelBranches, + routeSegments: options.routeSegments, + }; + const sourceDepth = options.routeSegments?.length ?? 0; + // Next.js swaps each non-intercepting sibling at or above the intercept's + // folder for its default. Slots owned deeper sit inside the children it + // replaces, so they drop out with them. + const replaceSiblingsWithDefaults = (interceptOwner: number, interceptIndex: number) => + (options.parallelBranches ?? []).map((branch, index) => { + if (!branch || index === interceptIndex || options.keepActiveSiblings) return branch; + const owner = resolveParallelBranchOwnerPosition(branch, sourceDepth); + if (owner > interceptOwner) return null; + return { + configLayouts: [], + configLayoutTreePositions: [], + isDefault: true, + layout: null, + name: branch.name, + ownerTreePosition: owner, + page: branch.isDefault ? branch.page : (branch.default ?? null), + routeSegments: [], + }; + }); + if (!options.isSiblingPageIntercept) { + const slot = resolveAppPageInterceptedSlot(options); + if (!slot) return tree; + const interceptOwner = resolveParallelBranchOwnerPosition(slot, sourceDepth); + const parallelBranches = replaceSiblingsWithDefaults(interceptOwner, options.slotIndex); + parallelBranches[options.slotIndex] = { + configLayouts: interceptLayouts, + configLayoutTreePositions: interceptLayoutDepths, + isDefault: false, + layout: slot.layout ?? null, + name: slot.name, + ownerTreePosition: slot.ownerTreePosition, + page: options.interceptPage ?? null, + routeSegments: options.interceptBranchSegments ?? [], + }; + if (options.keepActiveSiblings) return { ...tree, parallelBranches }; + // Next.js's `keys_to_replace` covers `children` as well. Without a + // `default` of its own, an interception path's children get the + // built-in `default-null`, which exports no config. + const ownerSegments = (options.routeSegments ?? []).slice(0, interceptOwner); + const ownerLayouts = (options.layouts ?? []).flatMap((layout, index) => { + const position = options.layoutTreePositions?.[index] ?? 0; + return position > interceptOwner ? [] : [{ layout, position }]; + }); + return { + childrenSlot: { ownerTreePath: `/${ownerSegments.join("/")}`, state: "default" }, + layoutTreePositions: ownerLayouts.map(({ position }) => position), + layouts: ownerLayouts.map(({ layout }) => layout), + page: options.interceptOwnerDefault ?? null, + parallelBranches, + routeSegments: ownerSegments, + }; + } + const routeSegments = options.routeSegments ?? []; + const layouts = options.layouts ?? []; + return { + ...tree, + childrenSlot: null, + layoutTreePositions: [ + ...layouts.map((_, index) => options.layoutTreePositions?.[index] ?? 0), + ...interceptLayoutDepths.map((depth) => routeSegments.length + depth), + ], + layouts: [...layouts, ...interceptLayouts], + page: options.interceptPage ?? null, + parallelBranches: replaceSiblingsWithDefaults(sourceDepth, -1), + routeSegments: [...routeSegments, ...(options.interceptBranchSegments ?? [])], + }; +} + +type AppPageSegmentConfigModules = Pick< + ResolveAppPageSegmentConfigOptions, + "layouts" | "page" | "parallelBranches" +>; + +// A route-wide `fetchCache` mode of either tree applies to a direct +// intercepted response, in this order: a `force-*` mode overrides an `only-*` +// one, as within a tree, and the no-store mode wins a conflict between trees. +const ROUTE_WIDE_FETCH_CACHE_MODES: readonly FetchCacheMode[] = [ + "force-no-store", + "force-cache", + "only-no-store", + "only-cache", +]; + +/** + * The segment config a direct intercepted RSC response renders under. + * Next.js takes it from the segments its intercepting route renders + * (`interceptTree`, from `resolveAppPageInterceptTree`), but vinext also + * renders the active pages that tree swaps for defaults (`renderedTree`, with + * `keepActiveSiblings`). Each tree is reduced on its own, since a slot's + * `default` and its active page never render together, and the two policies + * merge: the shortest revalidate, a `force-dynamic`, a `dynamicParams = false` + * and a route-wide `fetchCache` mode from either tree apply, and the + * intercepting route's own tree keeps precedence for the rest. The shortest + * `unstable_dynamicStaleTime` of either tree's pages (a slot's `default` + * included, as Next.js reads it) applies too. + * https://github.com/vercel/next.js/blob/v16.2.7/packages/next/src/server/app-render/create-component-tree.tsx + * https://github.com/vercel/next.js/blob/v16.2.7/packages/next/src/server/app-render/app-render.tsx + */ +export function resolveAppPageInterceptSegmentConfig( + interceptTree: AppPageSegmentConfigModules, + renderedTree: AppPageSegmentConfigModules, +): EffectiveAppPageSegmentConfig { + // The `dynamic = "error"` fetchCache default applies once, to the merged + // `dynamic` mode, not to either tree's. + const [intercept, rendered] = [interceptTree, renderedTree].map((tree) => + reduceAppPageSegmentConfig({ + layouts: tree.layouts, + page: tree.page, + parallelBranches: tree.parallelBranches, + parallelPages: (tree.parallelBranches ?? []).map((branch) => branch?.page), + }), + ); + const dynamicConfig = + intercept.dynamicConfig === "force-dynamic" || rendered.dynamicConfig === "force-dynamic" + ? "force-dynamic" + : (intercept.dynamicConfig ?? rendered.dynamicConfig); + const dynamicParamsConfig = + intercept.dynamicParamsConfig === false || rendered.dynamicParamsConfig === false + ? false + : (intercept.dynamicParamsConfig ?? rendered.dynamicParamsConfig); + const dynamicStaleTimeSeconds = resolveDynamicStaleTimeSeconds( + intercept.dynamicStaleTimeSeconds, + rendered.dynamicStaleTimeSeconds, + ); + const fetchCache = + ROUTE_WIDE_FETCH_CACHE_MODES.find( + (mode) => intercept.fetchCache === mode || rendered.fetchCache === mode, + ) ?? + intercept.fetchCache ?? + rendered.fetchCache; + const runtime = intercept.runtime ?? rendered.runtime; + return applyAppPageFetchCacheDefault({ + ...(dynamicConfig === undefined ? {} : { dynamicConfig }), + ...(dynamicParamsConfig === undefined ? {} : { dynamicParamsConfig }), + ...(dynamicStaleTimeSeconds === undefined ? {} : { dynamicStaleTimeSeconds }), + ...(fetchCache === undefined ? {} : { fetchCache }), + revalidateSeconds: resolveRevalidateSeconds( + intercept.revalidateSeconds, + rendered.revalidateSeconds, + ), + ...(runtime === undefined ? {} : { runtime }), + }); +} + +/** + * The main-tree position of the folder that owns a slot, for a route whose + * main tree is `routeDepth` folders deep. + */ +function resolveParallelBranchOwnerPosition( + branch: ParallelAppPageSegmentConfigBranch, + routeDepth: number, +): number { + return Math.min( + branch.ownerTreePosition ?? + routeDepth - (branch.isDefault ? 0 : (branch.routeSegments ?? []).length), + routeDepth, + ); +} + /** * One segment of an App page's loader tree, as Next.js's build visits it when * it classifies the route. @@ -640,11 +936,7 @@ export function collectAppPageStaticParamsWalkSegments( const branchesByOwner = new Map(); for (const branch of options.parallelBranches ?? []) { if (!branch) continue; - const owner = Math.min( - branch.ownerTreePosition ?? - routeSegments.length - (branch.isDefault ? 0 : (branch.routeSegments ?? []).length), - routeSegments.length, - ); + const owner = resolveParallelBranchOwnerPosition(branch, routeSegments.length); branchesByOwner.set(owner, [...(branchesByOwner.get(owner) ?? []), branch]); } @@ -757,6 +1049,21 @@ export function hasAppPageGenerateStaticParamsAtLastDynamicSegment( return lastDynamicSegmentHasGenerateStaticParams(collectAppPageStaticParamsWalkSegments(options)); } +/** + * Whether any segment of an App page route's loader tree exports + * `generateStaticParams`, read from its layout, page and parallel-slot + * modules. Next.js collects every segment of the route's own loader tree, its + * parallel routes included, and calls each segment's generator. + * https://github.com/vercel/next.js/blob/v16.2.7/packages/next/src/build/segment-config/app/app-segments.ts#L72-L126 + */ +export function hasAppPageAnyGenerateStaticParams( + options: Parameters[0], +): boolean { + return collectAppPageStaticParamsWalkSegments(options).some( + (segment) => segment.generateStaticParams, + ); +} + /** * Whether Next.js would classify an App page route as static or SSG from its * config alone. Only such routes are full-page cache candidates; every other diff --git a/packages/vinext/src/server/app-server-action-execution.ts b/packages/vinext/src/server/app-server-action-execution.ts index da4a9be79f..3c9a6e7e12 100644 --- a/packages/vinext/src/server/app-server-action-execution.ts +++ b/packages/vinext/src/server/app-server-action-execution.ts @@ -40,6 +40,10 @@ import { } from "./app-rsc-cache-busting.js"; import { applyEdgeRuntimeHeader } from "./app-page-response.js"; import { resolveAppPageActionRerenderTarget } from "./app-page-request.js"; +import { + toRouteConfigIntercept, + type AppPageStaticEligibilityIntercept, +} from "./app-page-dispatch.js"; import { resolveAppPageNavigationParams } from "./app-page-element-builder.js"; import { deferUntilStreamConsumed } from "./app-page-stream.js"; import { buildAppPageTags } from "./implicit-tags.js"; @@ -400,9 +404,22 @@ export type HandleServerActionRscRequestOptions< options: RenderServerActionRscStreamOptions, ) => BodyInit | null | Promise; reportRequestError: AppServerActionErrorReporter; - resolveRouteFetchCacheMode?: (route: TRoute) => FetchCacheMode | null; - resolveRouteRevalidateSeconds?: (route: TRoute) => number | null; - resolveRouteDynamicConfig?: (route: TRoute) => string | null | undefined; + /** + * With an intercept, these resolve the tree the rerendered intercepting + * route renders, as `dispatchAppPage`'s resolvers do. + */ + resolveRouteFetchCacheMode?: ( + route: TRoute, + intercept?: AppPageStaticEligibilityIntercept, + ) => FetchCacheMode | null; + resolveRouteRevalidateSeconds?: ( + route: TRoute, + intercept?: AppPageStaticEligibilityIntercept, + ) => number | null; + resolveRouteDynamicConfig?: ( + route: TRoute, + intercept?: AppPageStaticEligibilityIntercept, + ) => string | null | undefined; resolveRouteRuntime?: (route: TRoute) => AppServerActionRouteRuntime; request: Request; sanitizeErrorForClient: (error: unknown) => unknown; @@ -1874,8 +1891,19 @@ export async function handleServerActionRscRequest< ); // Hydrate the re-render target before reading its page module. await options.ensureRouteLoaded?.(actionRerenderTarget.route); + // The rerender repeats the intercepted render, so it takes that render's + // config. As above, toInterceptOpts produces the dispatch intercept + // options, which the generic TInterceptOpts doesn't express. + const actionRerenderConfigIntercept = actionRerenderTarget.interceptOpts + ? toRouteConfigIntercept( + actionRerenderTarget.interceptOpts as unknown as Parameters< + typeof toRouteConfigIntercept + >[0], + ) + : undefined; const actionRerenderDynamicConfig = options.resolveRouteDynamicConfig?.( actionRerenderTarget.route, + actionRerenderConfigIntercept, ); const actionRerenderSearchParams = prepareActionPageRerenderContext({ draftModeCookie: actionDraftCookie, @@ -1895,10 +1923,16 @@ export async function handleServerActionRscRequest< ? `${options.cleanPathname}?${renderedSearch}` : options.cleanPathname; setCurrentFetchCacheMode( - options.resolveRouteFetchCacheMode?.(actionRerenderTarget.route) ?? null, + options.resolveRouteFetchCacheMode?.( + actionRerenderTarget.route, + actionRerenderConfigIntercept, + ) ?? null, ); setCurrentFetchRevalidate( - options.resolveRouteRevalidateSeconds?.(actionRerenderTarget.route) ?? null, + options.resolveRouteRevalidateSeconds?.( + actionRerenderTarget.route, + actionRerenderConfigIntercept, + ) ?? null, ); setCurrentForceDynamicFetchDefault(actionRerenderDynamicConfig === "force-dynamic"); setCurrentFetchSoftTags( diff --git a/tests/app-page-cache.test.ts b/tests/app-page-cache.test.ts index 8d38bb295d..f00c9355df 100644 --- a/tests/app-page-cache.test.ts +++ b/tests/app-page-cache.test.ts @@ -2201,7 +2201,6 @@ describe("app page regeneration failures", () => { isrGet: (key: string) => Promise; isrSet: AppPageCacheSetter; renderFreshPageForCache: () => Promise>; - revalidateSeconds?: number; scheduled: Array<() => Promise>; }) { return readAppPageCacheResponse({ @@ -2217,7 +2216,7 @@ describe("app page regeneration failures", () => { return "rsc:" + pathname; }, isrSet: options.isrSet, - revalidateSeconds: options.revalidateSeconds ?? 60, + revalidateSeconds: 60, renderFreshPageForCache: options.renderFreshPageForCache, scheduleBackgroundRegeneration(_key, renderFn) { options.scheduled.push(renderFn); @@ -2225,7 +2224,11 @@ describe("app page regeneration failures", () => { }); } - function freshPage(overrides: { cacheControl?: CacheControlMetadata; usedDynamicApi: boolean }) { + function freshPage(overrides: { + cacheControl?: CacheControlMetadata; + revalidateSeconds?: number | null; + usedDynamicApi: boolean; + }) { return { ...queryInvariantRegenObservations(), html: "

fresh

", @@ -2283,18 +2286,23 @@ describe("app page regeneration failures", () => { ); // Next.js fails a non-PPR regeneration whose render's revalidate is 0, - // whether a dynamic API or its fetches or cacheLife set it - // (`build/templates/app-page.ts`). A route without a revalidate reads the - // cache with a seed of 0. + // whether a dynamic API or the rendered tree's config, fetches or cacheLife + // set it (`build/templates/app-page.ts`). describe("a regeneration whose effective revalidate is 0 without a dynamic API", () => { type Case = { cacheControl?: CacheControlMetadata; label: string; - revalidateSeconds: number; + revalidateSeconds: number | null; }; const cases: Case[] = [ + { label: "a revalidate = 0 tree", revalidateSeconds: 0 }, + { + cacheControl: { revalidate: 300 }, + label: "a revalidate = 0 tree whose cacheLife is positive", + revalidateSeconds: 0, + }, { cacheControl: { revalidate: 0 }, label: "a zero cacheLife", revalidateSeconds: 60 }, - { label: "a route without a revalidate or cacheLife", revalidateSeconds: 0 }, + { label: "a tree without a revalidate or cacheLife", revalidateSeconds: null }, ]; async function regenerate( @@ -2320,9 +2328,12 @@ describe("app page regeneration failures", () => { }, isrSet, async renderFreshPageForCache() { - return freshPage({ cacheControl: page.cacheControl, usedDynamicApi: false }); + return freshPage({ + cacheControl: page.cacheControl, + revalidateSeconds: page.revalidateSeconds, + usedDynamicApi: false, + }); }, - revalidateSeconds: page.revalidateSeconds, scheduled, }); return { isrSet, regeneration: scheduled[0]() }; @@ -2356,11 +2367,11 @@ describe("app page regeneration failures", () => { ]); }); - it("stores a route without a revalidate at its cacheLife", async () => { + it("stores a tree without a revalidate at its cacheLife", async () => { const { isrSet, regeneration } = await regenerate({ cacheControl: { revalidate: 300 }, - label: "a cacheLife-only route", - revalidateSeconds: 0, + label: "a cacheLife-only tree", + revalidateSeconds: null, }); await expect(regeneration).resolves.toBeUndefined(); diff --git a/tests/app-page-dispatch.test.ts b/tests/app-page-dispatch.test.ts index bfcb2f6c24..a691c0068a 100644 --- a/tests/app-page-dispatch.test.ts +++ b/tests/app-page-dispatch.test.ts @@ -12,12 +12,15 @@ import { buildPageElements, type AppPageBuildRoute, } from "../packages/vinext/src/server/app-page-element-builder.js"; -import { probeAppPage } from "../packages/vinext/src/server/app-page-probe.js"; import { + buildAppPageInterceptSourceProbes, + probeAppPage, +} from "../packages/vinext/src/server/app-page-probe.js"; +import { + createAppPageTreePath, resolveAppPageSegmentParamScopeKeys, resolveAppPageSegmentParams, } from "../packages/vinext/src/server/app-page-params.js"; -import { createAppPageTreePath } from "../packages/vinext/src/server/app-page-route-wiring.js"; import { createArtifactCompatibilityEnvelope, createArtifactCompatibilityGraphVersion, @@ -31,8 +34,17 @@ import { buildRenderRequestApiObservations, type RenderObservation, } from "../packages/vinext/src/server/cache-proof.js"; -import { APP_RSC_RENDER_MODE_PREFETCH_DYNAMIC_SHELL } from "../packages/vinext/src/server/app-rsc-render-mode.js"; +import { + APP_RSC_RENDER_MODE_PREFETCH_DYNAMIC_SHELL, + APP_RSC_RENDER_MODE_PREFETCH_EMPTY, + APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL, + type AppRscRenderMode, +} from "../packages/vinext/src/server/app-rsc-render-mode.js"; import { makeThenableParams } from "../packages/vinext/src/shims/thenable-params.js"; +import { + resolveAppPageInterceptSegmentConfig, + resolveAppPageInterceptTree, +} from "../packages/vinext/src/server/app-segment-config.js"; import { after, connection } from "../packages/vinext/src/shims/server.js"; import type { AppPageMiddlewareContext } from "../packages/vinext/src/server/app-page-response.js"; import type { ISRCacheEntry } from "../packages/vinext/src/server/isr-cache.js"; @@ -53,6 +65,7 @@ import { } from "../packages/vinext/src/server/cacheability-manifest.js"; import { createRequestContext, + getRequestContext, runWithRequestContext, } from "../packages/vinext/src/shims/unified-request-context.js"; import { @@ -69,7 +82,10 @@ import { import { isPromiseLike } from "../packages/vinext/src/utils/promise.js"; import { isUnknownRecord } from "../packages/vinext/src/utils/record.js"; import { extractRscCompletionMetadata } from "../packages/vinext/src/server/rsc-completion-metadata.js"; -import { VINEXT_INTERCEPTION_ID_HEADER } from "../packages/vinext/src/server/headers.js"; +import { + VINEXT_DYNAMIC_STALE_TIME_HEADER, + VINEXT_INTERCEPTION_ID_HEADER, +} from "../packages/vinext/src/server/headers.js"; type TestRoute = { __buildTimeClassifications?: ReadonlyMap | null; @@ -94,12 +110,16 @@ type TestRoute = { loading?: { default?: unknown } | null; loadings?: readonly ({ default?: unknown } | null | undefined)[] | null; loadingTreePositions?: readonly number[] | null; + layout?: { default?: unknown } | null; + name?: string; page?: { default?: unknown; generateMetadata?: unknown } | null; slotParamNames?: readonly string[] | null; slotPatternParts?: readonly string[] | null; } > >; + templates?: readonly ({ default?: unknown } | null | undefined)[]; + templateTreePositions?: readonly number[]; unauthorizeds?: readonly ({ default?: unknown } | null | undefined)[]; }; type DispatchOptions = Parameters>[0]; @@ -296,8 +316,10 @@ type CreateDispatchOptionsOverrides = { cleanPathname?: string; clearRequestContext?: DispatchOptions["clearRequestContext"]; createRscOnErrorHandler?: DispatchOptions["createRscOnErrorHandler"]; + probeInterceptSource?: DispatchOptions["probeInterceptSource"]; dynamicConfig?: DispatchOptions["dynamicConfig"]; dynamicParamsConfig?: DispatchOptions["dynamicParamsConfig"]; + dynamicStaleTimeSeconds?: DispatchOptions["dynamicStaleTimeSeconds"]; findIntercept?: DispatchOptions["findIntercept"]; ensureRouteLoaded?: DispatchOptions["ensureRouteLoaded"]; generateStaticParams?: DispatchOptions["generateStaticParams"]; @@ -335,6 +357,11 @@ type CreateDispatchOptionsOverrides = { resolveRouteFetchCacheMode?: DispatchOptions["resolveRouteFetchCacheMode"]; resolveRouteRevalidateSeconds?: DispatchOptions["resolveRouteRevalidateSeconds"]; resolveRouteDynamicConfig?: DispatchOptions["resolveRouteDynamicConfig"]; + resolveRouteDynamicStaleTimeSeconds?: DispatchOptions["resolveRouteDynamicStaleTimeSeconds"]; + resolveRouteDynamicParamsConfig?: DispatchOptions["resolveRouteDynamicParamsConfig"]; + resolveRouteInterceptTreeDynamicConfig?: DispatchOptions["resolveRouteInterceptTreeDynamicConfig"]; + resolveRouteGenerateStaticParams?: DispatchOptions["resolveRouteGenerateStaticParams"]; + resolveRouteHasAnyGenerateStaticParams?: DispatchOptions["resolveRouteHasAnyGenerateStaticParams"]; resolveRouteStaticEligible?: DispatchOptions["resolveRouteStaticEligible"]; route?: TestRoute; scheduleBackgroundRegeneration?: DispatchOptions["scheduleBackgroundRegeneration"]; @@ -364,10 +391,12 @@ function createDispatchOptions(overrides: CreateDispatchOptionsOverrides = {}) { bypassInterceptionContextCache: overrides.bypassInterceptionContextCache, cleanPathname: overrides.cleanPathname ?? "/posts/hello", clearRequestContext, + probeInterceptSource: overrides.probeInterceptSource, createRscOnErrorHandler: overrides.createRscOnErrorHandler ?? (() => () => undefined), draftModeSecret: "draft-secret", dynamicConfig: overrides.dynamicConfig, dynamicParamsConfig: overrides.dynamicParamsConfig, + dynamicStaleTimeSeconds: overrides.dynamicStaleTimeSeconds, ensureRouteLoaded: overrides.ensureRouteLoaded, findIntercept: overrides.findIntercept ?? (() => null), generateStaticParams: overrides.generateStaticParams ?? null, @@ -438,6 +467,11 @@ function createDispatchOptions(overrides: CreateDispatchOptionsOverrides = {}) { resolveRouteFetchCacheMode: overrides.resolveRouteFetchCacheMode, resolveRouteRevalidateSeconds: overrides.resolveRouteRevalidateSeconds, resolveRouteDynamicConfig: overrides.resolveRouteDynamicConfig, + resolveRouteDynamicStaleTimeSeconds: overrides.resolveRouteDynamicStaleTimeSeconds, + resolveRouteDynamicParamsConfig: overrides.resolveRouteDynamicParamsConfig, + resolveRouteInterceptTreeDynamicConfig: overrides.resolveRouteInterceptTreeDynamicConfig, + resolveRouteGenerateStaticParams: overrides.resolveRouteGenerateStaticParams, + resolveRouteHasAnyGenerateStaticParams: overrides.resolveRouteHasAnyGenerateStaticParams, resolveRouteStaticEligible: overrides.resolveRouteStaticEligible ?? ((candidate) => !candidate.isDynamic), route, @@ -2820,275 +2854,1472 @@ describe("app page dispatch", () => { const response = await dispatchAppPage(options); await expect(response.text()).resolves.toBe("/feed"); - expect(resolveRouteStaticEligible).toHaveBeenCalledWith(sourceRoute); + expect(resolveRouteStaticEligible).toHaveBeenCalledWith( + sourceRoute, + expect.objectContaining({ interceptSlotKey: "modal@app/feed/@modal" }), + ); expect(response.headers.get("cache-control")).toBe( "private, no-cache, no-store, max-age=0, must-revalidate", ); - // Dev keeps its no-store header. - const devResponse = await dispatchAppPage({ ...options, isProduction: false }); - expect(devResponse.headers.get("cache-control")).toBe("no-store, must-revalidate"); - await devResponse.text(); }); - it("fresh-renders mounted-slot intercepted RSC requests without persistent cache reuse", async () => { + it("classifies intercepted RSC with the intercepting branch in the source's slot", async () => { + // app/feed/page.tsx is static, but app/feed/@modal/(.)photos/[id]/page.tsx + // sets dynamic = "force-dynamic". Next.js serves the response from the + // intercepting route, whose tree includes that page. const sourceRoute = createRoute({ params: [], pattern: "/feed", routeSegments: ["feed"] }); const currentRoute = createRoute({ params: ["id"], pattern: "/photos/[id]", routeSegments: ["photos", "[id]"], }); - const staleRscData = new TextEncoder().encode("stale-flight").buffer; - const buildPageElement = vi.fn( - async ( - route: TestRoute, - params: Record, - opts: Parameters[2], - searchParams: URLSearchParams, - ) => - JSON.stringify({ - params, - route: route.pattern, - search: searchParams.toString(), - slot: opts?.interceptSlotKey ?? "direct", - }), - ); - let scheduledRender: unknown = null; - const scheduleBackgroundRegeneration: DispatchOptions["scheduleBackgroundRegeneration"] = ( - _key, - renderFn, - ) => { - scheduledRender = renderFn; - }; - const resolveRouteFetchCacheMode = vi.fn((route: TestRoute) => - route === sourceRoute ? "force-cache" : null, - ); - const resolveRouteRevalidateSeconds = vi.fn((route: TestRoute) => - route === sourceRoute ? 30 : null, + const interceptLayouts = [{ default: "modal-layout" }]; + const interceptPage = { default: "modal-page", dynamic: "force-dynamic" }; + const resolveRouteStaticEligible = vi.fn( + (_route, intercept) => + (intercept?.interceptPage as { dynamic?: string } | undefined)?.dynamic !== "force-dynamic", ); const { options } = createDispatchOptions({ - buildPageElement, + async buildPageElement(route) { + return route.pattern; + }, cleanPathname: "/photos/123", findIntercept: () => ({ + interceptBranchSegments: ["(.)photos", "[id]"], + interceptLayouts, + interceptLayoutSegments: [["(.)photos"]], matchedParams: { id: "123" }, - page: { default: "modal-page" }, - slotId: "slot:modal:/feed", + page: interceptPage, slotKey: "modal@app/feed/@modal", sourceRouteIndex: 1, + targetPatternParts: ["feed", "photos", ":id"], }), getSourceRoute(sourceRouteIndex) { return sourceRouteIndex === 1 ? sourceRoute : undefined; }, - interceptionContext: "/feed", isProduction: true, isRscRequest: true, - isrGet: vi.fn(async () => - buildISRCacheEntry( - buildCachedAppPageValue( - "", - staleRscData, - undefined, - buildQueryInvariantRenderObservation(), - ), - true, - ), - ), - isrRscKey(pathname, mountedSlotsHeader, _renderMode, interceptionContext) { - return `rsc:${pathname}:${mountedSlotsHeader ?? "none"}:${interceptionContext ?? "none"}`; + renderToReadableStream(element) { + return createStream([typeof element === "string" ? element : "unexpected-element"]); }, - loadSsrHandler: async () => ({ - async handleSsr(_rscStream, _navigationContext, _fontData, captureOptions) { - if (captureOptions?.capturedRscDataRef) { - captureOptions.capturedRscDataRef.value = Promise.resolve( - new TextEncoder().encode("fresh-intercepted-flight").buffer, - ); - } - void captureOptions?.sideStream?.cancel().catch(() => {}); - return createStream(["fresh"]); - }, - }), - mountedSlotsHeader: "slot:modal:/feed", - revalidateSeconds: 60, - resolveRouteFetchCacheMode, - resolveRouteRevalidateSeconds, + resolveRouteStaticEligible, route: currentRoute, - scheduleBackgroundRegeneration, - searchParams: new URLSearchParams("tab=popular"), }); const response = await dispatchAppPage(options); - expect(response.headers.get("x-vinext-cache")).toBeNull(); - await expect(response.text()).resolves.toBe("flight"); - expect(scheduledRender).toBeNull(); - - const [routeArg, paramsArg, optsArg, searchParamsArg] = buildPageElement.mock.calls[0]; - expect(resolveRouteFetchCacheMode).toHaveBeenCalledWith(sourceRoute); - expect(resolveRouteRevalidateSeconds).toHaveBeenCalledWith(sourceRoute); - expect(routeArg).toBe(sourceRoute); - expect(paramsArg).toEqual({}); - expect(searchParamsArg.toString()).toBe("tab=popular"); - expect(optsArg).toMatchObject({ - interceptionContext: "/feed", - interceptParams: { id: "123" }, - interceptSlotId: "slot:modal:/feed", + await expect(response.text()).resolves.toBe("/feed"); + expect(resolveRouteStaticEligible).toHaveBeenCalledWith(sourceRoute, { + interceptBranchSegments: ["(.)photos", "[id]"], + interceptLayoutSegments: [["(.)photos"]], + interceptLayouts, + interceptPage, interceptSlotKey: "modal@app/feed/@modal", - interceptSourceMatchedUrl: "/feed", + interceptTargetPatternParts: ["feed", "photos", ":id"], }); - expect(options.isrGet).not.toHaveBeenCalled(); - expect(options.isrSet).not.toHaveBeenCalled(); + expect(response.headers.get("cache-control")).toBe( + "private, no-cache, no-store, max-age=0, must-revalidate", + ); + // Dev keeps its no-store header. + const devResponse = await dispatchAppPage({ ...options, isProduction: false }); + expect(devResponse.headers.get("cache-control")).toBe("no-store, must-revalidate"); + await devResponse.text(); }); - it("fails regenerating a stale intercepted entry whose source route turned revalidate = 0", async () => { - // app/feed/page.tsx now sets dynamic = "force-dynamic" (revalidate 0), - // while the matched /photos/[id] route keeps revalidate = 60. - const sourceRoute = createRoute({ params: [], pattern: "/feed", routeSegments: ["feed"] }); + it("classifies intercepted RSC with the owner's default the matched intercept loads", async () => { + // app/feed/default.tsx sets dynamic = "force-static" and takes app/feed's + // children in the intercepting route's tree. Only the matched intercept + // loads it, so classification must see it hydrated. + const sourceRoute = createRoute({ + params: [], + pattern: "/feed", + routeSegments: ["feed"], + slots: { "modal@app/feed/@modal": { default: { default: "modal-default" } } }, + }); const currentRoute = createRoute({ params: ["id"], pattern: "/photos/[id]", routeSegments: ["photos", "[id]"], }); - let scheduledRender: unknown = null; + const feedDefault = { default: "feed-default", dynamic: "force-static" }; + const intercept = { + matchedParams: { id: "123" }, + ownerDefault: null, + __loadOwnerDefault: vi.fn(async () => feedDefault), + page: { default: "modal-page" }, + slotKey: "modal@app/feed/@modal", + sourceRouteIndex: 1, + }; + const resolveRouteStaticEligible = vi.fn( + (_route, matched) => + (matched?.interceptOwnerDefault as { dynamic?: string } | undefined)?.dynamic === + "force-static", + ); const { options } = createDispatchOptions({ async buildPageElement(route) { return route.pattern; }, cleanPathname: "/photos/123", - findIntercept: () => ({ - matchedParams: { id: "123" }, - page: { default: "modal-page" }, - slotKey: "modal@app/feed/@modal", - sourceRouteIndex: 1, - }), + findIntercept: () => ({ ...intercept }), getSourceRoute(sourceRouteIndex) { return sourceRouteIndex === 1 ? sourceRoute : undefined; }, - interceptionContext: "/feed", isProduction: true, isRscRequest: true, - isrGet: vi.fn(async () => - buildISRCacheEntry( - buildCachedAppPageValue( - "", - new TextEncoder().encode("stale-flight").buffer, - undefined, - buildQueryInvariantRenderObservation(), - ), - true, - ), - ), - loadSsrHandler: async () => ({ - async handleSsr(_rscStream, _navigationContext, _fontData, captureOptions) { - if (captureOptions?.capturedRscDataRef) { - captureOptions.capturedRscDataRef.value = Promise.resolve( - new TextEncoder().encode("fresh-flight").buffer, - ); - } - void captureOptions?.sideStream?.cancel().catch(() => {}); - return createStream(["fresh"]); - }, - }), - renderToReadableStream: () => createStream(["fresh-flight"]), - resolveRouteRevalidateSeconds: (route) => (route === sourceRoute ? 0 : null), - revalidateSeconds: 60, - route: currentRoute, - scheduleBackgroundRegeneration(_key, renderFn) { - scheduledRender = renderFn; + renderToReadableStream(element) { + return createStream([typeof element === "string" ? element : "unexpected-element"]); }, + resolveRouteStaticEligible, + route: currentRoute, }); const response = await dispatchAppPage(options); - await response.text(); - if (typeof scheduledRender !== "function") { - throw new Error("expected the stale entry to schedule regeneration"); - } - await expect(scheduledRender()).rejects.toThrow( - "Page changed from static to dynamic at runtime /photos/123", + await expect(response.text()).resolves.toBe("/feed"); + expect(intercept.__loadOwnerDefault).toHaveBeenCalledTimes(1); + expect(resolveRouteStaticEligible).toHaveBeenCalledWith( + sourceRoute, + expect.objectContaining({ interceptOwnerDefault: feedDefault }), ); - expect(options.isrSet).not.toHaveBeenCalled(); + expect(response.headers.get("cache-control")).toBeNull(); }); - it("bypasses shared caches for an unverified interception context", async () => { - const isrGet = vi.fn(async () => - buildISRCacheEntry( - buildCachedAppPageValue( - "", - new TextEncoder().encode("cached-flight").buffer, - undefined, - buildQueryInvariantRenderObservation(), - ), - ), + it("renders intercepted RSC without the owner's default when the source lacks the slot", async () => { + // A route-group variant of app/feed matched as the source has no @modal, + // so its own page renders and app/feed/default.tsx, which throws at the + // top level here, must not be evaluated. + const sourceRoute = createRoute({ params: [], pattern: "/feed", routeSegments: ["feed"] }); + const currentRoute = createRoute({ + params: ["id"], + pattern: "/photos/[id]", + routeSegments: ["photos", "[id]"], + }); + const intercept = { + matchedParams: { id: "123" }, + ownerDefault: null, + __loadOwnerDefault: vi.fn(async () => { + throw new Error("app/feed/default.tsx must not be evaluated"); + }), + page: { default: "modal-page" }, + slotKey: "modal@app/feed/@modal", + sourceRouteIndex: 1, + }; + const resolveRouteStaticEligible = vi.fn( + () => true, ); - const isrSet = vi.fn(async () => {}); - const waitUntilPromises: Promise[] = []; - const executionContext = { - waitUntil(promise) { - waitUntilPromises.push(promise); - }, - } satisfies ExecutionContextLike; const { options } = createDispatchOptions({ - bypassInterceptionContextCache: true, - interceptionContext: "/attacker-selected", + async buildPageElement(route) { + return route.pattern; + }, + cleanPathname: "/photos/123", + findIntercept: () => ({ ...intercept }), + getSourceRoute(sourceRouteIndex) { + return sourceRouteIndex === 1 ? sourceRoute : undefined; + }, isProduction: true, isRscRequest: true, - isrGet, - isrSet, - renderToReadableStream: () => createStream(["fresh-flight"]), - revalidateSeconds: 60, + renderToReadableStream(element) { + return createStream([typeof element === "string" ? element : "unexpected-element"]); + }, + resolveRouteStaticEligible, + route: currentRoute, }); - const response = await runWithExecutionContext(executionContext, () => - dispatchAppPage(options), - ); + const response = await dispatchAppPage(options); - expect(response.headers.get("cache-control")).toBe("no-store, must-revalidate"); - expect(response.headers.get("x-vinext-cache")).toBeNull(); - await expect(response.text()).resolves.toBe("fresh-flight"); - await Promise.all(waitUntilPromises.splice(0)); - expect(isrGet).not.toHaveBeenCalled(); - expect(isrSet).not.toHaveBeenCalled(); + expect(response.status).toBe(200); + await expect(response.text()).resolves.toBe("/feed"); + expect(intercept.__loadOwnerDefault).not.toHaveBeenCalled(); + expect(resolveRouteStaticEligible).toHaveBeenCalledWith( + sourceRoute, + expect.objectContaining({ interceptOwnerDefault: null }), + ); }); - it("uses a verified interception id in the persistent RSC cache key", async () => { - const interceptionId = "interception:slot:modal:/feed:/feed->/photos/:id"; - const isrRscKey = vi.fn( - ( - pathname: string, - _mountedSlotsHeader?: string | null, - _renderMode?: DispatchOptions["renderMode"], - _interceptionContext?: string | null, - selector?: string | null, - ) => `rsc:${pathname}:${selector ?? "none"}`, - ); - const isrGet = vi.fn(async () => - buildISRCacheEntry( + describe("current-route interception cacheability", () => { + // app/feed/page.tsx is static and owns app/feed/@modal, whose + // (.)feed/[id] interception the /feed route renders itself. Next.js + // classifies that intercepting route's own tree. + const neverCache = "private, no-cache, no-store, max-age=0, must-revalidate"; + + function createCurrentRouteDispatch( + interceptPage: Record, + overrides: CreateDispatchOptionsOverrides = {}, + ) { + const route = createRoute({ + params: [], + pattern: "/feed", + routeSegments: ["feed"], + slots: { "modal@app/feed/@modal": { default: { default: "modal-default" } } }, + }); + const cachedEntry = buildISRCacheEntry( buildCachedAppPageValue( "", - new TextEncoder().encode("stale-flight").buffer, + new TextEncoder().encode("cached-flight").buffer, undefined, buildQueryInvariantRenderObservation(), ), - ), - ); - const isrSet = vi.fn(async () => {}); - const request = new Request("https://example.test/photos/123", { - headers: { - [VINEXT_INTERCEPTION_ID_HEADER]: interceptionId, - }, - }); - const { options } = createDispatchOptions({ - cleanPathname: "/photos/123", - isProduction: true, - isRscRequest: true, - isrGet, - isrRscKey, - isrSet, - request, - revalidateSeconds: 60, - }); - - const response = await dispatchAppPage(options); - await expect(response.text()).resolves.toBe("stale-flight"); + ); + const isrGet = vi.fn(async () => cachedEntry); + const isrSet = vi.fn(async () => {}); + const resolveRouteStaticEligible = vi.fn( + (_route, intercept) => + (intercept?.interceptPage as { dynamic?: string } | undefined)?.dynamic !== + "force-dynamic", + ); + // The intercepting tree's config is its page's, as generated. + const interceptConfig = ( + intercept: Parameters>[1], + ) => + intercept?.interceptPage as + | { dynamic?: string; revalidate?: number; unstable_dynamicStaleTime?: number } + | undefined; + const buildPageElement = vi.fn( + async (_route, _params, opts) => opts?.interceptSlotKey ?? "direct", + ); + const { options } = createDispatchOptions({ + buildPageElement, + cleanPathname: "/feed", + findIntercept: () => ({ + matchedParams: {}, + page: interceptPage, + slotKey: "modal@app/feed/@modal", + sourceRouteIndex: 0, + }), + getSourceRoute: (sourceRouteIndex) => (sourceRouteIndex === 0 ? route : undefined), + interceptionContext: "/feed", + isProduction: true, + isRscRequest: true, + isrGet, + isrSet, + params: {}, + renderToReadableStream: () => createStream(["fresh-flight"]), + resolveRouteDynamicConfig: (_route, intercept) => interceptConfig(intercept)?.dynamic, + resolveRouteRevalidateSeconds: (_route, intercept) => + interceptConfig(intercept)?.revalidate ?? null, + resolveRouteDynamicStaleTimeSeconds: (_route, intercept) => + interceptConfig(intercept)?.unstable_dynamicStaleTime, + resolveRouteStaticEligible, + revalidateSeconds: 60, + route, + ...overrides, + }); + return { buildPageElement, isrGet, isrSet, options, resolveRouteStaticEligible, route }; + } + + // Renders a cache miss and waits for its background write. + async function dispatchMiss(overrides: CreateDispatchOptionsOverrides & { page: object }) { + const { page, ...dispatchOverrides } = overrides; + const isrGet = vi.fn(async () => null); + const dispatch = createCurrentRouteDispatch(page as Record, { + isrGet, + ...dispatchOverrides, + }); + const waitUntilPromises: Promise[] = []; + const response = await runWithExecutionContext( + { waitUntil: (promise) => waitUntilPromises.push(promise) }, + () => dispatchAppPage(dispatch.options), + ); + await expect(response.text()).resolves.toBe("fresh-flight"); + await Promise.all(waitUntilPromises); + return { ...dispatch, isrGet, response }; + } + + it.each([ + ["force-dynamic", { dynamicConfig: "force-dynamic" }], + ["revalidate = 0", { revalidateSeconds: 0 }], + ])( + "admits and stores a static sibling-page intercept over a %s page", + async (_name, baseConfig) => { + // The intercepting branch replaces app/feed/page.tsx, and its tree + // keeps none of that page's config. + const page = { default: "photo-page", revalidate: 30 }; + const { isrGet, isrSet, response } = await dispatchMiss({ + ...baseConfig, + findIntercept: () => ({ + matchedParams: {}, + page, + slotKey: "__vinext_page_intercept", + sourceRouteIndex: 0, + }), + page, + }); + + expect(response.headers.get("x-vinext-cache")).toBe("MISS"); + expect(isrGet).toHaveBeenCalledTimes(1); + expect(isrSet).toHaveBeenCalledTimes(1); + expect(isrSet.mock.calls[0]![2].cacheControl.revalidate).toBe(30); + }, + ); + + it("reads, advertises and stores its variant with the intercepting tree's revalidate", async () => { + const page = { default: "modal-page", revalidate: 10 }; + const hit = createCurrentRouteDispatch(page, { revalidateSeconds: 3600 }); + const hitResponse = await dispatchAppPage(hit.options); + + expect(hitResponse.headers.get("x-vinext-cache")).toBe("HIT"); + expect(hitResponse.headers.get("cache-control")).toBe("s-maxage=10, stale-while-revalidate"); + + const { isrSet, response } = await dispatchMiss({ page, revalidateSeconds: 3600 }); + + expect(response.headers.get("x-vinext-cache")).toBe("MISS"); + expect(isrSet).toHaveBeenCalledTimes(1); + expect(isrSet.mock.calls[0]![2].cacheControl.revalidate).toBe(10); + }); + + it("stores a force-static intercepting tree over a route without a revalidate", async () => { + // app/feed is edge, so it has no static revalidate default of its own. + const { isrSet, response } = await dispatchMiss({ + isStaticGenerationEdgeRuntime: true, + page: { default: "modal-page", dynamic: "force-static" }, + revalidateSeconds: null, + }); + + expect(response.headers.get("x-vinext-cache")).toBe("STATIC"); + expect(isrSet).toHaveBeenCalledTimes(1); + expect(isrSet.mock.calls[0]![2].cacheControl.revalidate).toBe(Infinity); + }); + + // In a cacheComponents build only generateStaticParams sets the + // `revalidate = false` default of a tree without a revalidate. + async function dispatchPprMiss( + interceptExports: { generateStaticParams?: () => unknown[] }, + routeHasGenerator: boolean, + ) { + const page = { default: "modal-page", ...interceptExports }; + // The intercepting tree's generators are its page's, as generated. + const resolveRouteHasAnyGenerateStaticParams = vi.fn< + NonNullable + >( + (_route, intercept) => + intercept.interceptPage === page && typeof page.generateStaticParams === "function", + ); + const result = await dispatchMiss({ + hasAnyGenerateStaticParams: routeHasGenerator, + page, + pprRuntime: appPagePprRuntime, + resolveRouteHasAnyGenerateStaticParams, + revalidateSeconds: null, + }); + return { ...result, page, resolveRouteHasAnyGenerateStaticParams }; + } + + it("keeps the revalidate = false default from the intercepting tree's generateStaticParams in a cacheComponents build", async () => { + const { isrSet, page, resolveRouteHasAnyGenerateStaticParams, response, route } = + await dispatchPprMiss({ generateStaticParams: () => [] }, false); + + expect(response.headers.get("x-vinext-cache")).toBe("MISS"); + expect(resolveRouteHasAnyGenerateStaticParams).toHaveBeenCalledWith( + route, + expect.objectContaining({ interceptPage: page }), + ); + expect(isrSet).toHaveBeenCalledTimes(1); + expect(isrSet.mock.calls[0]![2].cacheControl.revalidate).toBe(Infinity); + }); + + it("drops the revalidate = false default from the replaced branch's generateStaticParams in a cacheComponents build", async () => { + // app/feed/@modal/page.tsx exports generateStaticParams, but the + // intercepting page that replaces it doesn't. + const { isrSet, response } = await dispatchPprMiss({}, true); + + expect(response.headers.get("x-vinext-cache")).toBeNull(); + expect(isrSet).not.toHaveBeenCalled(); + }); + + it("neither reads nor stores its variant when the intercepting tree can't be static", async () => { + const interceptPage = { default: "modal-page", dynamic: "force-dynamic" }; + const { buildPageElement, isrGet, isrSet, options, resolveRouteStaticEligible, route } = + createCurrentRouteDispatch(interceptPage); + + const response = await dispatchAppPage(options); + + await expect(response.text()).resolves.toBe("fresh-flight"); + expect(response.headers.get("x-vinext-cache")).toBeNull(); + expect(response.headers.get("cache-control")).toBe(neverCache); + expect(isrGet).not.toHaveBeenCalled(); + expect(isrSet).not.toHaveBeenCalled(); + expect(resolveRouteStaticEligible).toHaveBeenCalledWith( + route, + expect.objectContaining({ interceptPage }), + ); + expect(buildPageElement).toHaveBeenCalledWith( + route, + {}, + expect.objectContaining({ interceptSlotKey: "modal@app/feed/@modal" }), + expect.any(URLSearchParams), + expect.anything(), + expect.anything(), + ); + }); + + it("reads its variant when the intercepting tree is static but the route's own isn't", async () => { + // app/feed/@modal/page.tsx is edge, but the intercepting branch takes + // its place in the intercepting route's tree. + const { isrGet, options } = createCurrentRouteDispatch( + { default: "modal-page" }, + { isStaticGenerationEdgeRuntime: true }, + ); + + const response = await dispatchAppPage(options); + + await expect(response.text()).resolves.toBe("cached-flight"); + expect(response.headers.get("x-vinext-cache")).toBe("HIT"); + expect(isrGet).toHaveBeenCalledTimes(1); + }); + + it("sends the uncacheable header on an uncached render of a dynamic intercepting tree", async () => { + // Outside production the cache is never read, so only the render + // policy classifies the intercepting tree. Dev keeps its no-store header. + const { isrGet, isrSet, options } = createCurrentRouteDispatch( + { default: "modal-page", dynamic: "force-dynamic" }, + { isProduction: false }, + ); + + const response = await dispatchAppPage(options); + + await expect(response.text()).resolves.toBe("fresh-flight"); + expect(response.headers.get("cache-control")).toBe("no-store, must-revalidate"); + expect(isrGet).not.toHaveBeenCalled(); + expect(isrSet).not.toHaveBeenCalled(); + }); + + // Renders a sibling-page intercept whose dynamic config is the inverse of + // the page it replaces, and records the render policy its element is + // built under. + async function renderInverseConfigIntercept( + interceptDynamic: string, + overrides: CreateDispatchOptionsOverrides, + ) { + const renders: Record[] = []; + const page = { default: "photo-page", dynamic: interceptDynamic }; + const { options } = createCurrentRouteDispatch(page, { + async buildPageElement(_route, _params, opts, searchParams, _paramAccess, buildOptions) { + renders.push({ + forceDynamicFetchDefault: getRequestContext().currentForceDynamicFetchDefault, + observePageSearchParamsAccess: buildOptions?.observePageSearchParamsAccess, + requestHeader: getHeadersContext()?.headers.get("x-request-value") ?? null, + search: searchParams.toString(), + }); + return opts?.interceptSlotKey ?? "direct"; + }, + findIntercept: () => ({ + matchedParams: {}, + page, + slotKey: "__vinext_page_intercept", + sourceRouteIndex: 0, + }), + isrGet: vi.fn(async () => null), + searchParams: new URLSearchParams("q=1"), + ...overrides, + }); + const requestContext = createRequestContext({ + headersContext: { + cookies: new Map(), + headers: new Headers({ "x-request-value": "request" }), + }, + }); + const response = await runWithRequestContext(requestContext, () => dispatchAppPage(options)); + await expect(response.text()).resolves.toBe("fresh-flight"); + return renders; + } + + const staticRender = { + forceDynamicFetchDefault: false, + observePageSearchParamsAccess: false, + requestHeader: null, + search: "", + }; + const dynamicRender = { + forceDynamicFetchDefault: true, + observePageSearchParamsAccess: true, + requestHeader: "request", + search: "q=1", + }; + + it.each([ + ["in dev", { isProduction: false }], + ["in a cacheComponents build", { pprRuntime: appPagePprRuntime }], + ["for an unverified interception context", { bypassInterceptionContextCache: true }], + ])( + "renders a force-static intercepting tree over a force-dynamic page as static %s", + async (_name, overrides) => { + const renders = await renderInverseConfigIntercept("force-static", { + dynamicConfig: "force-dynamic", + revalidateSeconds: 0, + ...overrides, + }); + + expect(renders).toEqual([staticRender]); + }, + ); + + it.each([ + ["in dev", { isProduction: false }], + ["in a cacheComponents build", { pprRuntime: appPagePprRuntime }], + ["for an unverified interception context", { bypassInterceptionContextCache: true }], + ])( + "renders a force-dynamic intercepting tree over a force-static page as dynamic %s", + async (_name, overrides) => { + const renders = await renderInverseConfigIntercept("force-dynamic", { + dynamicConfig: "force-static", + revalidateSeconds: null, + ...overrides, + }); + + expect(renders).toEqual([dynamicRender]); + }, + ); + + it.each([ + ["force-dynamic", undefined, undefined], + ["force-static", "force-dynamic", 'dynamic = "force-dynamic"'], + ])( + "marks a cacheability probe's pattern from the route's own config, not a %s intercepting tree's", + async (interceptDynamic, routeDynamic, patternDynamicReason) => { + // The pattern covers every request to the route, while the + // intercepting tree renders only this one. + const context: ExecutionContextLike = { waitUntil() {} }; + const state: RouteCacheabilityState = { + captureDeadlineAt: Date.now() + 10_000, + mode: "probe", + }; + Reflect.set(context, CACHEABILITY_REQUEST_STATE, state); + const forceDynamicFetchDefaults: boolean[] = []; + const page = { default: "photo-page", dynamic: interceptDynamic }; + const { options } = createCurrentRouteDispatch(page, { + async buildPageElement(_route, _params, opts) { + forceDynamicFetchDefaults.push(getRequestContext().currentForceDynamicFetchDefault); + return opts?.interceptSlotKey ?? "direct"; + }, + dynamicConfig: routeDynamic, + findIntercept: () => ({ + matchedParams: {}, + page, + slotKey: "__vinext_page_intercept", + sourceRouteIndex: 0, + }), + revalidateSeconds: routeDynamic === "force-dynamic" ? 0 : null, + }); + + const response = await runWithExecutionContext(context, () => + runWithRequestContext(createRequestContext(), () => dispatchAppPage(options)), + ); + await response.text(); + + expect(state.patternDynamicReason).toBe(patternDynamicReason); + expect(forceDynamicFetchDefaults).toEqual([interceptDynamic === "force-dynamic"]); + }, + ); + + it.each([ + ["its own", 5, "5"], + // Without one, the configured staleTimes.dynamic applies. + ["the configured", undefined, "0"], + ])( + "advertises %s dynamic stale time for a sibling-page intercept, not the replaced page's", + async (_name, interceptStaleTime, header) => { + // app/feed/page.tsx sets unstable_dynamicStaleTime = 300, but the + // intercepting page replaces it. + const page = { + default: "photo-page", + dynamic: "force-dynamic", + unstable_dynamicStaleTime: interceptStaleTime, + }; + const { options } = createCurrentRouteDispatch(page, { + dynamicConfig: "force-dynamic", + dynamicStaleTimeSeconds: 300, + findIntercept: () => ({ + matchedParams: {}, + page, + slotKey: "__vinext_page_intercept", + sourceRouteIndex: 0, + }), + }); + + const response = await dispatchAppPage(options); + + await expect(response.text()).resolves.toBe("fresh-flight"); + expect(response.headers.get(VINEXT_DYNAMIC_STALE_TIME_HEADER)).toBe(header); + }, + ); + + it("classifies the intercepting tree with the owner's default it loads", async () => { + const feedDefault = { default: "feed-default", dynamic: "force-static" }; + const __loadOwnerDefault = vi.fn(async () => feedDefault); + // The manifest's intercept is one object, which keeps what it loads. + const intercept = { + __loadOwnerDefault, + matchedParams: {}, + ownerDefault: null, + page: { default: "modal-page" }, + slotKey: "modal@app/feed/@modal", + sourceRouteIndex: 0, + }; + const { options, resolveRouteStaticEligible, route } = createCurrentRouteDispatch( + { default: "modal-page" }, + { findIntercept: () => intercept }, + ); + + const response = await dispatchAppPage(options); + + await expect(response.text()).resolves.toBe("cached-flight"); + expect(__loadOwnerDefault).toHaveBeenCalledTimes(1); + expect(resolveRouteStaticEligible).toHaveBeenCalledWith( + route, + expect.objectContaining({ interceptOwnerDefault: feedDefault }), + ); + }); + + function createUnattachedInterceptDispatch(overrides: CreateDispatchOptionsOverrides = {}) { + // A route-group variant of app/feed without @modal is the source, so it + // renders unchanged and the unused intercepting tree, whose modules + // throw at the top level here, must not be evaluated. + const route = createRoute({ params: [], pattern: "/feed", routeSegments: ["feed"] }); + const loaders = { + __loadInterceptLayouts: [ + vi.fn(async () => { + throw new Error("the intercepting layout must not be evaluated"); + }), + ], + __loadNotFound: vi.fn(async () => { + throw new Error("the intercepting not-found must not be evaluated"); + }), + __loadOwnerDefault: vi.fn(async () => { + throw new Error("app/feed/default.tsx must not be evaluated"); + }), + __pageLoader: vi.fn(async () => { + throw new Error("the intercepting page must not be evaluated"); + }), + }; + const dispatch = createCurrentRouteDispatch( + { default: "modal-page" }, + { + findIntercept: () => ({ + ...loaders, + interceptLayouts: [null], + matchedParams: {}, + notFound: null, + ownerDefault: null, + page: null, + slotKey: "modal@app/feed/@modal", + sourceRouteIndex: 0, + }), + getSourceRoute: () => route, + route, + ...overrides, + }, + ); + return { ...dispatch, loaders, route }; + } + + function expectNoInterceptLoads( + loaders: ReturnType["loaders"], + ) { + expect(loaders.__pageLoader).not.toHaveBeenCalled(); + expect(loaders.__loadNotFound).not.toHaveBeenCalled(); + expect(loaders.__loadOwnerDefault).not.toHaveBeenCalled(); + expect(loaders.__loadInterceptLayouts[0]).not.toHaveBeenCalled(); + } + + it("serves a cached variant without loading a slot intercept the route has no slot for", async () => { + const { isrGet, loaders, options, resolveRouteStaticEligible, route } = + createUnattachedInterceptDispatch(); + + const response = await dispatchAppPage(options); + + await expect(response.text()).resolves.toBe("cached-flight"); + expect(response.headers.get("x-vinext-cache")).toBe("HIT"); + expect(isrGet).toHaveBeenCalledTimes(1); + expectNoInterceptLoads(loaders); + expect(resolveRouteStaticEligible).toHaveBeenCalledWith( + route, + expect.objectContaining({ interceptPage: null, interceptSlotKey: "modal@app/feed/@modal" }), + ); + }); + + it("regenerates a stale variant without loading a slot intercept the route has no slot for", async () => { + const staleEntry = buildISRCacheEntry( + buildCachedAppPageValue( + "", + new TextEncoder().encode("cached-flight").buffer, + undefined, + buildQueryInvariantRenderObservation(), + ), + true, + ); + let regeneration: Promise | undefined; + const { buildPageElement, loaders, options } = createUnattachedInterceptDispatch({ + isrGet: vi.fn(async () => staleEntry), + scheduleBackgroundRegeneration: vi.fn((_key, renderFn) => { + regeneration = renderFn(); + }), + }); + + const response = await dispatchAppPage(options); + + await expect(response.text()).resolves.toBe("cached-flight"); + expect(response.headers.get("x-vinext-cache")).toBe("STALE"); + // Only the rerender target's module loading matters here, not the write. + await regeneration?.catch(() => {}); + expect(buildPageElement).toHaveBeenCalledTimes(1); + expectNoInterceptLoads(loaders); + }); + + it("renders a cache miss without loading a slot intercept the route has no slot for", async () => { + const isrGet = vi.fn(async () => null); + const { buildPageElement, loaders, options } = createUnattachedInterceptDispatch({ isrGet }); + + const response = await dispatchAppPage(options); + + await expect(response.text()).resolves.toBe("fresh-flight"); + expect(isrGet).toHaveBeenCalledTimes(1); + expect(buildPageElement).toHaveBeenCalledTimes(1); + expectNoInterceptLoads(loaders); + }); + + function createGeneratedParamsDispatch(overrides: CreateDispatchOptionsOverrides = {}) { + // /feed/[slug] renders the (.)feed/[slug]/[id] interception in its own + // @modal slot. app/feed/[slug]/layout.tsx exports dynamicParams = false + // and generateStaticParams, so the intercepting tree keeps both. + const generateStaticParams = vi.fn(async () => [{ slug: "known" }]); + const route = createRoute({ + isDynamic: true, + params: ["slug"], + pattern: "/feed/:slug", + routeSegments: ["feed", "[slug]"], + slots: { "modal@app/feed/[slug]/@modal": {} }, + }); + const dispatch = createCurrentRouteDispatch( + { default: "modal-page" }, + { + cleanPathname: "/feed/unknown", + dynamicParamsConfig: false, + findIntercept: () => ({ + matchedParams: { slug: "unknown" }, + page: { default: "modal-page" }, + slotKey: "modal@app/feed/[slug]/@modal", + sourceRouteIndex: 0, + }), + generateStaticParams, + getSourceRoute: () => route, + params: { slug: "unknown" }, + resolveRouteDynamicParamsConfig: () => false, + resolveRouteGenerateStaticParams: () => generateStaticParams, + route, + ...overrides, + }, + ); + return { ...dispatch, generateStaticParams, route }; + } + + it("serves a cached variant without generating the route's static params", async () => { + // Like any other request, generated params are checked only on a miss. + const { generateStaticParams, isrGet, options } = createGeneratedParamsDispatch(); + + const response = await dispatchAppPage(options); + + await expect(response.text()).resolves.toBe("cached-flight"); + expect(response.headers.get("x-vinext-cache")).toBe("HIT"); + expect(isrGet).toHaveBeenCalledTimes(1); + expect(generateStaticParams).not.toHaveBeenCalled(); + }); + + it("404s a generated-params miss once the cache misses", async () => { + const isrGet = vi.fn(async () => null); + const { buildPageElement, generateStaticParams, isrSet, options } = + createGeneratedParamsDispatch({ isrGet }); + + const response = await dispatchAppPage(options); + + expect(response.status).toBe(404); + expect(isrGet).toHaveBeenCalledTimes(1); + expect(generateStaticParams).toHaveBeenCalledTimes(1); + expect(buildPageElement).not.toHaveBeenCalled(); + expect(isrSet).not.toHaveBeenCalled(); + }); + + it.each([ + ["rendered", true], + ["plain", false], + ])( + "sends the never-cache header on a %s generated-params miss of a dynamic intercepting tree", + async (_kind, rendersNotFound) => { + // The intercepting tree skipped the cache, so its 404 is never-cache + // like its normal render, even though the route's own tree is static. + // Its revalidate = 0 keeps the params gate, which a force-dynamic tree + // skips in production. + const interceptPage = { default: "modal-page", revalidate: 0 }; + const { buildPageElement, generateStaticParams, isrGet, isrSet, options } = + createGeneratedParamsDispatch({ + findIntercept: () => ({ + matchedParams: { slug: "unknown" }, + page: interceptPage, + slotKey: "modal@app/feed/[slug]/@modal", + sourceRouteIndex: 0, + }), + resolveRouteStaticEligible: (_route, intercept) => intercept === undefined, + }); + if (rendersNotFound) { + options.renderHttpAccessFallbackPage = vi.fn( + async () => new Response("not-found", { status: 404 }), + ); + } + + const response = await dispatchAppPage(options); + + expect(response.status).toBe(404); + expect(response.headers.get("cache-control")).toBe(neverCache); + expect(isrGet).not.toHaveBeenCalled(); + expect(generateStaticParams).toHaveBeenCalledTimes(1); + expect(buildPageElement).not.toHaveBeenCalled(); + expect(isrSet).not.toHaveBeenCalled(); + }, + ); + + it.each([ + ["in dev", { isProduction: false }, "no-store, must-revalidate"], + [ + "when the interception cache is bypassed", + { bypassInterceptionContextCache: true }, + neverCache, + ], + ])( + "sends the never-cache header on a generated-params miss of a dynamic intercepting tree %s", + async (_kind, readSkippingOverrides, cacheControl) => { + // No cache read classifies the intercepting tree here, so the miss must + // load and classify it before responding, like its normal render. Its + // revalidate = 0 keeps the params gate, which a force-dynamic tree + // skips in production. + const interceptPage = { default: "modal-page", revalidate: 0 }; + const __pageLoader = vi.fn(async () => interceptPage); + const { buildPageElement, generateStaticParams, isrGet, isrSet, options } = + createGeneratedParamsDispatch({ + findIntercept: () => ({ + __pageLoader, + matchedParams: { slug: "unknown" }, + page: null, + slotKey: "modal@app/feed/[slug]/@modal", + sourceRouteIndex: 0, + }), + resolveRouteStaticEligible: (_route, intercept) => intercept === undefined, + ...readSkippingOverrides, + }); + + const response = await dispatchAppPage(options); + + expect(response.status).toBe(404); + expect(response.headers.get("cache-control")).toBe(cacheControl); + expect(__pageLoader).toHaveBeenCalledTimes(1); + expect(isrGet).not.toHaveBeenCalled(); + expect(generateStaticParams).toHaveBeenCalledTimes(1); + expect(buildPageElement).not.toHaveBeenCalled(); + expect(isrSet).not.toHaveBeenCalled(); + }, + ); + + it("404s a generated-params miss in dev without loading a slot intercept the route has no slot for", async () => { + const generateStaticParams = vi.fn(async () => [{ slug: "known" }]); + const { buildPageElement, isrGet, loaders, options, route } = + createUnattachedInterceptDispatch({ + cleanPathname: "/feed/unknown", + dynamicParamsConfig: false, + generateStaticParams, + isProduction: false, + params: { slug: "unknown" }, + // An unattached intercept leaves the route's own tree, as generated. + resolveRouteDynamicParamsConfig: () => false, + resolveRouteGenerateStaticParams: () => generateStaticParams, + }); + // The route-group variant is dynamic too: app/(group)/feed/[slug]. + route.isDynamic = true; + + const response = await dispatchAppPage(options); + + expect(response.status).toBe(404); + expect(isrGet).not.toHaveBeenCalled(); + expect(generateStaticParams).toHaveBeenCalledTimes(1); + expect(buildPageElement).not.toHaveBeenCalled(); + expectNoInterceptLoads(loaders); + }); + + // app/feed/[slug]/@modal/(.)[slug]/page.tsx intercepts /feed/[slug] from + // itself, in place of app/feed/[slug]/@modal/default.tsx, and the + // intercepting tree drops app/feed/[slug]/page.tsx. Only the intercepting + // tree's own segments set its dynamicParams and generators, as generated. + type InterceptPage = { + default: string; + dynamicParams?: boolean; + generateStaticParams?: () => Promise[]>; + }; + function dispatchInterceptedGeneratedParams( + interceptPage: InterceptPage, + slug: string, + overrides: CreateDispatchOptionsOverrides = {}, + ) { + const isrGet = vi.fn(async () => null); + const resolveRouteDynamicParamsConfig = vi.fn< + NonNullable + >((_route, intercept) => (intercept.interceptPage as InterceptPage).dynamicParams); + const resolveRouteGenerateStaticParams = vi.fn< + NonNullable + >((_route, intercept) => { + const generator = (intercept.interceptPage as InterceptPage).generateStaticParams; + return generator ? [generator] : []; + }); + const dispatch = createGeneratedParamsDispatch({ + cleanPathname: `/feed/${slug}`, + findIntercept: () => ({ + matchedParams: { slug }, + page: interceptPage, + slotKey: "modal@app/feed/[slug]/@modal", + sourceRouteIndex: 0, + }), + isrGet, + params: { slug }, + resolveRouteDynamicParamsConfig, + resolveRouteGenerateStaticParams, + ...overrides, + }); + return { + ...dispatch, + isrGet, + resolveRouteDynamicParamsConfig, + resolveRouteGenerateStaticParams, + response: dispatchAppPage(dispatch.options), + }; + } + + it("404s a param the intercepting tree's dynamicParams = false doesn't generate over a route that allows it", async () => { + const interceptGenerator = vi.fn(async () => [{ slug: "known" }]); + const interceptPage = { + default: "modal-page", + dynamicParams: false, + generateStaticParams: interceptGenerator, + }; + // app/feed/[slug]/page.tsx allows any slug and has no generator. + const routeConfig = { dynamicParamsConfig: undefined, generateStaticParams: undefined }; + const miss = dispatchInterceptedGeneratedParams(interceptPage, "unknown", routeConfig); + const response = await miss.response; + + expect(response.status).toBe(404); + expect(miss.isrGet).toHaveBeenCalledTimes(1); + expect(miss.resolveRouteDynamicParamsConfig).toHaveBeenCalledWith( + miss.route, + expect.objectContaining({ interceptPage }), + ); + expect(miss.resolveRouteGenerateStaticParams).toHaveBeenCalledWith( + miss.route, + expect.objectContaining({ interceptPage }), + ); + expect(interceptGenerator).toHaveBeenCalledTimes(1); + expect(miss.buildPageElement).not.toHaveBeenCalled(); + + const known = dispatchInterceptedGeneratedParams(interceptPage, "known", routeConfig); + const knownResponse = await known.response; + + expect(knownResponse.status).toBe(200); + await expect(knownResponse.text()).resolves.toBe("fresh-flight"); + }); + + it("renders any param under an intercepting tree that allows it over a dynamicParams = false route", async () => { + // app/feed/[slug]/page.tsx exports dynamicParams = false and generates + // only "known", but the intercepting tree drops it. + const { buildPageElement, generateStaticParams, response } = + dispatchInterceptedGeneratedParams({ default: "modal-page" }, "other"); + + const rendered = await response; + expect(rendered.status).toBe(200); + await expect(rendered.text()).resolves.toBe("fresh-flight"); + expect(generateStaticParams).not.toHaveBeenCalled(); + expect(buildPageElement).toHaveBeenCalledTimes(1); + }); + + it("checks the intercepting tree's generators, not the replaced branch's, over a dynamicParams = false route", async () => { + const interceptGenerator = vi.fn(async () => [{ slug: "other" }]); + const interceptPage = { + default: "modal-page", + dynamicParams: false, + generateStaticParams: interceptGenerator, + }; + // The route's generators allow only "known"; the intercepting tree's + // allow only "other". + const other = dispatchInterceptedGeneratedParams(interceptPage, "other"); + const otherResponse = await other.response; + + expect(otherResponse.status).toBe(200); + await expect(otherResponse.text()).resolves.toBe("fresh-flight"); + expect(interceptGenerator).toHaveBeenCalledTimes(1); + expect(other.generateStaticParams).not.toHaveBeenCalled(); + + const known = dispatchInterceptedGeneratedParams(interceptPage, "known"); + const knownResponse = await known.response; + + expect(knownResponse.status).toBe(404); + expect(known.generateStaticParams).not.toHaveBeenCalled(); + expect(known.buildPageElement).not.toHaveBeenCalled(); + }); + + it("checks the params the intercepting tree renders with", async () => { + // app/feed/[slug]/@modal/(.)[photo]/page.tsx names the param photo. + const interceptGenerator = vi.fn(async () => [{ photo: "known" }]); + const interceptPage = { + default: "modal-page", + dynamicParams: false, + generateStaticParams: interceptGenerator, + }; + const { options } = createGeneratedParamsDispatch({ + cleanPathname: "/feed/unknown", + findIntercept: () => ({ + matchedParams: { photo: "unknown", slug: "unknown" }, + page: interceptPage, + slotKey: "modal@app/feed/[slug]/@modal", + sourceRouteIndex: 0, + }), + isrGet: vi.fn(async () => null), + resolveRouteDynamicParamsConfig: () => false, + resolveRouteGenerateStaticParams: () => [interceptGenerator], + }); + + const response = await dispatchAppPage(options); + + expect(response.status).toBe(404); + expect(interceptGenerator).toHaveBeenCalledTimes(1); + }); + + // A cache miss of the static /feed rendering its @modal intercept with the + // given branch, params and generators, under dynamicParams = false. + async function dispatchStaticSourceIntercept( + interceptBranchSegments: string[], + matchedParams: Record, + generateStaticParams: () => Promise[]>, + ) { + const interceptPage = { default: "modal-page", dynamicParams: false, generateStaticParams }; + const { buildPageElement, options } = createCurrentRouteDispatch(interceptPage, { + findIntercept: () => ({ + interceptBranchSegments, + matchedParams, + page: interceptPage, + slotKey: "modal@app/feed/@modal", + sourceRouteIndex: 0, + }), + isrGet: vi.fn(async () => null), + resolveRouteDynamicParamsConfig: () => false, + resolveRouteGenerateStaticParams: () => [generateStaticParams], + }); + const response = await dispatchAppPage(options); + return { buildPageElement, response }; + } + + it("gates the params of an intercepting tree's own dynamic segments under a static route", async () => { + // app/feed/page.tsx is static, but app/feed/@modal/(.)photo/[id] adds + // [id] to the intercepting route's tree. + const generator = vi.fn(async () => [{ id: "known" }]); + const unknown = await dispatchStaticSourceIntercept( + ["photo", "[id]"], + { id: "unknown" }, + generator, + ); + + expect(unknown.response.status).toBe(404); + expect(generator).toHaveBeenCalledTimes(1); + expect(unknown.buildPageElement).not.toHaveBeenCalled(); + + const known = await dispatchStaticSourceIntercept( + ["photo", "[id]"], + { id: "known" }, + generator, + ); + + expect(known.response.status).toBe(200); + await expect(known.response.text()).resolves.toBe("fresh-flight"); + }); + + it("gates an intercepting tree's omitted optional catch-all by an explicit empty value", async () => { + // app/feed/@modal/(.)[[...photo]] matches without a photo param, a path + // Next.js generates only from an explicit empty value. + const known = await dispatchStaticSourceIntercept(["[[...photo]]"], {}, async () => [ + { photo: ["known"] }, + ]); + + expect(known.response.status).toBe(404); + expect(known.buildPageElement).not.toHaveBeenCalled(); + + const empty = await dispatchStaticSourceIntercept(["[[...photo]]"], {}, async () => [ + { photo: [] }, + ]); + + expect(empty.response.status).toBe(200); + await expect(empty.response.text()).resolves.toBe("fresh-flight"); + }); + + it("renders a generated-params miss of an intercepting tree through that tree's not-found", async () => { + // app/feed/[slug]/@modal/(..)[slug]/not-found.tsx is the intercepting + // tree's own not-found boundary. + const interceptNotFound = { default: "modal-not-found" }; + const interceptPage = { + default: "modal-page", + dynamicParams: false, + generateStaticParams: vi.fn(async () => [{ slug: "known" }]), + }; + const renderHttpAccessFallbackPage = vi.fn( + async () => new Response("modal-not-found", { status: 404 }), + ); + const { buildPageElement, options } = createGeneratedParamsDispatch({ + findIntercept: () => ({ + interceptBranchSegments: ["(..)[slug]"], + matchedParams: { slug: "unknown" }, + notFound: interceptNotFound, + notFoundTreePosition: 1, + page: interceptPage, + slotKey: "modal@app/feed/[slug]/@modal", + sourceRouteIndex: 0, + }), + isrGet: vi.fn(async () => null), + resolveRouteDynamicParamsConfig: () => false, + resolveRouteGenerateStaticParams: () => [interceptPage.generateStaticParams], + }); + options.renderHttpAccessFallbackPage = renderHttpAccessFallbackPage; + + const response = await dispatchAppPage(options); + + expect(response.status).toBe(404); + await expect(response.text()).resolves.toBe("modal-not-found"); + expect(renderHttpAccessFallbackPage).toHaveBeenCalledTimes(1); + expect(renderHttpAccessFallbackPage).toHaveBeenCalledWith( + 404, + { + intercept: expect.objectContaining({ + interceptBranchSegments: ["(..)[slug]"], + interceptNotFound, + interceptNotFoundTreePosition: 1, + interceptPage, + interceptParams: { slug: "unknown" }, + interceptSlotKey: "modal@app/feed/[slug]/@modal", + }), + matchedParams: { slug: "unknown" }, + }, + options.middlewareContext, + ); + expect(buildPageElement).not.toHaveBeenCalled(); + }); + + it("keeps a static intercepting tree's params gate under a force-dynamic active sibling in production", async () => { + // app/feed/[slug]/page.tsx, which vinext renders beside the intercept + // where Next.js's intercepting route has the owner's default, exports + // dynamic = "force-dynamic". That makes the render dynamic, but the + // intercepting route's own tree is not force-dynamic, so its prerender + // manifest entry still has the NOT_FOUND fallback. + const interceptGenerator = vi.fn(async () => [{ slug: "known" }]); + const interceptPage = { + default: "modal-page", + dynamicParams: false, + generateStaticParams: interceptGenerator, + }; + const resolveRouteInterceptTreeDynamicConfig = vi.fn< + NonNullable + >(() => null); + const miss = dispatchInterceptedGeneratedParams(interceptPage, "unknown", { + resolveRouteDynamicConfig: () => "force-dynamic", + resolveRouteInterceptTreeDynamicConfig, + }); + const response = await miss.response; + + expect(response.status).toBe(404); + expect(miss.isrGet).not.toHaveBeenCalled(); + expect(resolveRouteInterceptTreeDynamicConfig).toHaveBeenCalledWith( + miss.route, + expect.objectContaining({ interceptPage }), + ); + expect(interceptGenerator).toHaveBeenCalledTimes(1); + expect(miss.buildPageElement).not.toHaveBeenCalled(); + }); + + it("skips the params gate of a force-dynamic intercepting tree in production", async () => { + // Next.js's production build leaves the intercepting route out of its + // prerender manifest when its own tree is force-dynamic. + const interceptGenerator = vi.fn(async () => [{ slug: "known" }]); + const interceptPage = { + default: "modal-page", + dynamicParams: false, + generateStaticParams: interceptGenerator, + }; + const miss = dispatchInterceptedGeneratedParams(interceptPage, "unknown", { + resolveRouteDynamicConfig: () => "force-dynamic", + resolveRouteInterceptTreeDynamicConfig: () => "force-dynamic", + }); + const response = await miss.response; + + expect(response.status).toBe(200); + await expect(response.text()).resolves.toBe("fresh-flight"); + expect(interceptGenerator).not.toHaveBeenCalled(); + }); + }); + + it("fresh-renders mounted-slot intercepted RSC requests without persistent cache reuse", async () => { + const sourceRoute = createRoute({ params: [], pattern: "/feed", routeSegments: ["feed"] }); + const currentRoute = createRoute({ + params: ["id"], + pattern: "/photos/[id]", + routeSegments: ["photos", "[id]"], + }); + const staleRscData = new TextEncoder().encode("stale-flight").buffer; + const buildPageElement = vi.fn( + async ( + route: TestRoute, + params: Record, + opts: Parameters[2], + searchParams: URLSearchParams, + ) => + JSON.stringify({ + params, + route: route.pattern, + search: searchParams.toString(), + slot: opts?.interceptSlotKey ?? "direct", + }), + ); + let scheduledRender: unknown = null; + const scheduleBackgroundRegeneration: DispatchOptions["scheduleBackgroundRegeneration"] = ( + _key, + renderFn, + ) => { + scheduledRender = renderFn; + }; + const resolveRouteFetchCacheMode = vi.fn((route: TestRoute) => + route === sourceRoute ? "force-cache" : null, + ); + const resolveRouteRevalidateSeconds = vi.fn((route: TestRoute) => + route === sourceRoute ? 30 : null, + ); + const { options } = createDispatchOptions({ + buildPageElement, + cleanPathname: "/photos/123", + findIntercept: () => ({ + matchedParams: { id: "123" }, + page: { default: "modal-page" }, + slotId: "slot:modal:/feed", + slotKey: "modal@app/feed/@modal", + sourceRouteIndex: 1, + }), + getSourceRoute(sourceRouteIndex) { + return sourceRouteIndex === 1 ? sourceRoute : undefined; + }, + interceptionContext: "/feed", + isProduction: true, + isRscRequest: true, + isrGet: vi.fn(async () => + buildISRCacheEntry( + buildCachedAppPageValue( + "", + staleRscData, + undefined, + buildQueryInvariantRenderObservation(), + ), + true, + ), + ), + isrRscKey(pathname, mountedSlotsHeader, _renderMode, interceptionContext) { + return `rsc:${pathname}:${mountedSlotsHeader ?? "none"}:${interceptionContext ?? "none"}`; + }, + loadSsrHandler: async () => ({ + async handleSsr(_rscStream, _navigationContext, _fontData, captureOptions) { + if (captureOptions?.capturedRscDataRef) { + captureOptions.capturedRscDataRef.value = Promise.resolve( + new TextEncoder().encode("fresh-intercepted-flight").buffer, + ); + } + void captureOptions?.sideStream?.cancel().catch(() => {}); + return createStream(["fresh"]); + }, + }), + mountedSlotsHeader: "slot:modal:/feed", + revalidateSeconds: 60, + resolveRouteFetchCacheMode, + resolveRouteRevalidateSeconds, + route: currentRoute, + scheduleBackgroundRegeneration, + searchParams: new URLSearchParams("tab=popular"), + }); + + const response = await dispatchAppPage(options); + + expect(response.headers.get("x-vinext-cache")).toBeNull(); + await expect(response.text()).resolves.toBe("flight"); + expect(scheduledRender).toBeNull(); + + const [routeArg, paramsArg, optsArg, searchParamsArg] = buildPageElement.mock.calls[0]; + expect(resolveRouteFetchCacheMode).toHaveBeenCalledWith( + sourceRoute, + expect.objectContaining({ interceptSlotKey: "modal@app/feed/@modal" }), + ); + expect(resolveRouteRevalidateSeconds).toHaveBeenCalledWith( + sourceRoute, + expect.objectContaining({ interceptSlotKey: "modal@app/feed/@modal" }), + ); + expect(routeArg).toBe(sourceRoute); + expect(paramsArg).toEqual({}); + expect(searchParamsArg.toString()).toBe("tab=popular"); + expect(optsArg).toMatchObject({ + interceptionContext: "/feed", + interceptParams: { id: "123" }, + interceptSlotId: "slot:modal:/feed", + interceptSlotKey: "modal@app/feed/@modal", + interceptSourceMatchedUrl: "/feed", + }); + expect(options.isrGet).not.toHaveBeenCalled(); + expect(options.isrSet).not.toHaveBeenCalled(); + }); + + it("fails regenerating a stale intercepted entry whose source route turned revalidate = 0", async () => { + // app/feed/page.tsx now sets dynamic = "force-dynamic" (revalidate 0), + // while the matched /photos/[id] route keeps revalidate = 60. + const sourceRoute = createRoute({ params: [], pattern: "/feed", routeSegments: ["feed"] }); + const currentRoute = createRoute({ + params: ["id"], + pattern: "/photos/[id]", + routeSegments: ["photos", "[id]"], + }); + let scheduledRender: unknown = null; + const { options } = createDispatchOptions({ + async buildPageElement(route) { + return route.pattern; + }, + cleanPathname: "/photos/123", + findIntercept: () => ({ + matchedParams: { id: "123" }, + page: { default: "modal-page" }, + slotKey: "modal@app/feed/@modal", + sourceRouteIndex: 1, + }), + getSourceRoute(sourceRouteIndex) { + return sourceRouteIndex === 1 ? sourceRoute : undefined; + }, + interceptionContext: "/feed", + isProduction: true, + isRscRequest: true, + isrGet: vi.fn(async () => + buildISRCacheEntry( + buildCachedAppPageValue( + "", + new TextEncoder().encode("stale-flight").buffer, + undefined, + buildQueryInvariantRenderObservation(), + ), + true, + ), + ), + loadSsrHandler: async () => ({ + async handleSsr(_rscStream, _navigationContext, _fontData, captureOptions) { + if (captureOptions?.capturedRscDataRef) { + captureOptions.capturedRscDataRef.value = Promise.resolve( + new TextEncoder().encode("fresh-flight").buffer, + ); + } + void captureOptions?.sideStream?.cancel().catch(() => {}); + return createStream(["fresh"]); + }, + }), + renderToReadableStream: () => createStream(["fresh-flight"]), + resolveRouteRevalidateSeconds: (route) => (route === sourceRoute ? 0 : null), + revalidateSeconds: 60, + route: currentRoute, + scheduleBackgroundRegeneration(_key, renderFn) { + scheduledRender = renderFn; + }, + }); + + const response = await dispatchAppPage(options); + await response.text(); + if (typeof scheduledRender !== "function") { + throw new Error("expected the stale entry to schedule regeneration"); + } + + await expect(scheduledRender()).rejects.toThrow( + "Page changed from static to dynamic at runtime /photos/123", + ); + expect(options.isrSet).not.toHaveBeenCalled(); + }); + + it("bypasses shared caches for an unverified interception context", async () => { + const isrGet = vi.fn(async () => + buildISRCacheEntry( + buildCachedAppPageValue( + "", + new TextEncoder().encode("cached-flight").buffer, + undefined, + buildQueryInvariantRenderObservation(), + ), + ), + ); + const isrSet = vi.fn(async () => {}); + const waitUntilPromises: Promise[] = []; + const executionContext = { + waitUntil(promise) { + waitUntilPromises.push(promise); + }, + } satisfies ExecutionContextLike; + const { options } = createDispatchOptions({ + bypassInterceptionContextCache: true, + interceptionContext: "/attacker-selected", + isProduction: true, + isRscRequest: true, + isrGet, + isrSet, + renderToReadableStream: () => createStream(["fresh-flight"]), + revalidateSeconds: 60, + }); + + const response = await runWithExecutionContext(executionContext, () => + dispatchAppPage(options), + ); + + expect(response.headers.get("cache-control")).toBe("no-store, must-revalidate"); + expect(response.headers.get("x-vinext-cache")).toBeNull(); + await expect(response.text()).resolves.toBe("fresh-flight"); + await Promise.all(waitUntilPromises.splice(0)); + expect(isrGet).not.toHaveBeenCalled(); + expect(isrSet).not.toHaveBeenCalled(); + }); + + it("uses a verified interception id in the persistent RSC cache key", async () => { + const interceptionId = "interception:slot:modal:/feed:/feed->/photos/:id"; + const isrRscKey = vi.fn( + ( + pathname: string, + _mountedSlotsHeader?: string | null, + _renderMode?: DispatchOptions["renderMode"], + _interceptionContext?: string | null, + selector?: string | null, + ) => `rsc:${pathname}:${selector ?? "none"}`, + ); + const isrGet = vi.fn(async () => + buildISRCacheEntry( + buildCachedAppPageValue( + "", + new TextEncoder().encode("stale-flight").buffer, + undefined, + buildQueryInvariantRenderObservation(), + ), + ), + ); + const isrSet = vi.fn(async () => {}); + const request = new Request("https://example.test/photos/123", { + headers: { + [VINEXT_INTERCEPTION_ID_HEADER]: interceptionId, + }, + }); + const { options } = createDispatchOptions({ + cleanPathname: "/photos/123", + isProduction: true, + isRscRequest: true, + isrGet, + isrRscKey, + isrSet, + request, + revalidateSeconds: 60, + }); + + const response = await dispatchAppPage(options); + await expect(response.text()).resolves.toBe("stale-flight"); expect(response.headers.get("cache-control")).not.toContain("no-store"); expect(response.headers.get("x-vinext-cache")).toBe("HIT"); @@ -3142,7 +4373,10 @@ describe("app page dispatch", () => { }); expect(response.status).toBe(200); - expect(resolveRouteDynamicConfig).toHaveBeenCalledWith(sourceRoute); + expect(resolveRouteDynamicConfig).toHaveBeenCalledWith( + sourceRoute, + expect.objectContaining({ interceptSlotKey: "modal@app/feed/@modal" }), + ); }); it("does not leak the current route's force-dynamic config into the intercept source route", async () => { @@ -3191,7 +4425,10 @@ describe("app page dispatch", () => { }); expect(response.status).toBe(200); - expect(resolveRouteDynamicConfig).toHaveBeenCalledWith(sourceRoute); + expect(resolveRouteDynamicConfig).toHaveBeenCalledWith( + sourceRoute, + expect.objectContaining({ interceptSlotKey: "modal@app/feed/@modal" }), + ); }); describe("intercepted RSC of a known-dynamic source or current route", () => { @@ -3207,6 +4444,8 @@ describe("app page dispatch", () => { ]; function dispatchIntercept(overrides: CreateDispatchOptionsOverrides) { + // Start from a request headers context, not one an earlier test left. + setHeadersContext(null); const { options } = createDispatchOptions({ async buildPageElement(route) { return route.pattern; @@ -3232,6 +4471,297 @@ describe("app page dispatch", () => { return dispatchAppPage(options); } + it("sends the never-cache header for a source that reads a dynamic API while probed", async () => { + // app/feed has no dynamic config, but a component its intercepted + // render includes calls headers(). + const probedSourceRoute = createRoute({ + layouts: [{ default: () => null }], + params: [], + pattern: "/feed", + routeSegments: ["feed"], + }); + const probeInterceptSource = vi.fn>( + () => { + markDynamicUsage(); + }, + ); + const response = await dispatchIntercept({ + probeInterceptSource, + getSourceRoute: (index) => (index === 1 ? probedSourceRoute : undefined), + }); + + await expect(response.text()).resolves.toBe("/feed"); + expect(probeInterceptSource).toHaveBeenCalledWith( + probedSourceRoute, + {}, + expect.any(URLSearchParams), + ); + expect(response.headers.get("cache-control")).toBe( + "private, no-cache, no-store, max-age=0, must-revalidate", + ); + }); + + it("waits for the source probes before choosing its headers", async () => { + const response = await dispatchIntercept({ + async probeInterceptSource() { + await new Promise((resolve) => setTimeout(resolve, 0)); + markDynamicUsage(); + }, + }); + + await expect(response.text()).resolves.toBe("/feed"); + expect(response.headers.get("cache-control")).toBe( + "private, no-cache, no-store, max-age=0, must-revalidate", + ); + }); + + // The source's own tree includes app/feed/@modal/page.tsx, which the + // intercepting page replaces in the rendered tree. + const staticInterceptPage = { default: "modal-page" }; + const interceptStaticPage = () => ({ + matchedParams: { id: "123" }, + page: staticInterceptPage, + slotKey: "modal@app/feed/@modal", + sourceRouteIndex: 1, + }); + + it("reads revalidate from the tree with the intercepting page in the replaced slot", async () => { + // app/feed/@modal/page.tsx sets revalidate = 0, but the static + // intercepting page replaces it. + const resolveRouteRevalidateSeconds = vi.fn< + NonNullable + >((route, intercept) => + route === sourceRoute && intercept?.interceptPage !== staticInterceptPage ? 0 : null, + ); + const response = await dispatchIntercept({ + findIntercept: interceptStaticPage, + pprRuntime: appPagePprRuntime, + resolveRouteRevalidateSeconds, + }); + + await expect(response.text()).resolves.toBe("/feed"); + expect(resolveRouteRevalidateSeconds).toHaveBeenCalledWith( + sourceRoute, + expect.objectContaining({ + interceptPage: staticInterceptPage, + interceptSlotKey: "modal@app/feed/@modal", + }), + ); + expect(response.headers.get("cache-control")).toBeNull(); + }); + + it("reads fetchCache from the tree with the intercepting page in the replaced slot", async () => { + // app/feed/@modal/page.tsx sets fetchCache = "force-no-store", but the + // intercepting page replaces it and sets "force-cache". + const fetchCacheModes: unknown[] = []; + const response = await runWithRequestContext(createRequestContext(), () => + dispatchIntercept({ + async buildPageElement(route) { + fetchCacheModes.push(getRequestContext().currentFetchCacheMode); + return route.pattern; + }, + findIntercept: interceptStaticPage, + resolveRouteFetchCacheMode: (route, intercept) => + route === sourceRoute && intercept?.interceptPage === staticInterceptPage + ? "force-cache" + : "force-no-store", + }), + ); + + await expect(response.text()).resolves.toBe("/feed"); + expect(fetchCacheModes).toEqual(["force-cache"]); + }); + + it("counts an intercepting page's dynamic API read when a replaced slot page is force-static", async () => { + // app/feed/@modal/page.tsx sets dynamic = "force-static", but the + // intercepting page replaces it and calls headers(). + const response = await dispatchIntercept({ + probeInterceptSource() { + markDynamicUsage(); + }, + findIntercept: interceptStaticPage, + resolveRouteDynamicConfig: (route, intercept) => + route === sourceRoute && intercept?.interceptPage !== staticInterceptPage + ? "force-static" + : undefined, + }); + + await expect(response.text()).resolves.toBe("/feed"); + expect(response.headers.get("cache-control")).toBe( + "private, no-cache, no-store, max-age=0, must-revalidate", + ); + }); + + it("counts a dynamic API read while the source element is built", async () => { + // app/feed/page.tsx's generateMetadata (or generateViewport) calls + // headers(), which runs while the element tree is built, not in the + // layout and page probes. + const response = await dispatchIntercept({ + async buildPageElement(route) { + if (route === sourceRoute) markDynamicUsage(); + return route.pattern; + }, + probeInterceptSource() {}, + }); + + await expect(response.text()).resolves.toBe("/feed"); + expect(response.headers.get("cache-control")).toBe( + "private, no-cache, no-store, max-age=0, must-revalidate", + ); + }); + + it("doesn't count dynamic API reads from before the source is probed", async () => { + const response = await dispatchIntercept({ + probeInterceptSource() {}, + findIntercept() { + // Stands in for anything the matched target read earlier. + markDynamicUsage(); + return { + matchedParams: { id: "123" }, + page: { default: "modal-page" }, + slotKey: "modal@app/feed/@modal", + sourceRouteIndex: 1, + }; + }, + }); + + await expect(response.text()).resolves.toBe("/feed"); + expect(response.headers.get("cache-control")).toBeNull(); + }); + + describe("with the source probes the generated entry composes", () => { + // Like probeInterceptSource in app-rsc-entry.ts. + const probeSourceLikeEntry = + (renderMode?: AppRscRenderMode): NonNullable => + (route, params, searchParams) => + Promise.all( + buildAppPageInterceptSourceProbes({ + route, + pageComponent: undefined, + intercept: { page: { default: "modal-page" }, slotKey: "modal@app/feed/@modal" }, + sourceParams: params, + searchParams, + mountedSlotsHeader: null, + renderMode, + makeThenableParams: (value) => makeThenableParams(value as Record), + }), + ); + // Stands in for a component that calls headers() or cookies(). + function DynamicComponent(props: { children?: React.ReactNode }) { + markDynamicUsage(); + return props.children ?? null; + } + // A dynamic component that never settles. + function PendingDynamicComponent(): Promise { + markDynamicUsage(); + return new Promise(() => {}); + } + const dispatchSource = (source: Partial, renderMode?: AppRscRenderMode) => + Promise.race([ + dispatchIntercept({ + getSourceRoute: (index) => + index === 1 + ? createRoute({ params: [], pattern: "/feed", routeSegments: ["feed"], ...source }) + : undefined, + probeInterceptSource: probeSourceLikeEntry(renderMode), + }), + new Promise<"stalled">((resolve) => setTimeout(() => resolve("stalled"), 100)), + ]); + const neverCache = "private, no-cache, no-store, max-age=0, must-revalidate"; + + it("sends the never-cache header for a source template that reads a dynamic API", async () => { + // app/feed/template.tsx + const response = await dispatchSource({ + templates: [{ default: DynamicComponent }], + templateTreePositions: [1], + }); + + expect(response).not.toBe("stalled"); + expect((response as Response).headers.get("cache-control")).toBe(neverCache); + }); + + it("sends the never-cache header for an ordinary slot layout that reads a dynamic API", async () => { + // app/feed/@sidebar/layout.tsx around app/feed/@sidebar/page.tsx + const response = await dispatchSource({ + slots: { + "modal@app/feed/@modal": { name: "modal" }, + "sidebar@app/feed/@sidebar": { + layout: { default: DynamicComponent }, + name: "sidebar", + page: { default: () => null }, + }, + }, + }); + + expect(response).not.toBe("stalled"); + expect((response as Response).headers.get("cache-control")).toBe(neverCache); + }); + + // app/feed/@sidebar/default.tsx, which the prefetch doesn't render + const slotsWithDefault = (DefaultComponent: unknown) => ({ + "modal@app/feed/@modal": { name: "modal" }, + "sidebar@app/feed/@sidebar": { default: { default: DefaultComponent }, name: "sidebar" }, + // app/feed/@team/loading.tsx gives a loading-shell prefetch its shell. + "team@app/feed/@team": { + loading: { default: () => null }, + name: "team", + page: { default: () => null }, + }, + }); + + it("sends the never-cache header for a loading-shell route loading UI that reads a dynamic API", async () => { + // app/feed/loading.tsx, which the shell renders in place of the page + const response = await dispatchSource( + { loadings: [{ default: DynamicComponent }], loadingTreePositions: [1] }, + APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL, + ); + + expect(response).not.toBe("stalled"); + expect((response as Response).headers.get("cache-control")).toBe(neverCache); + }); + + it("sends the never-cache header for a loading-shell slot loading UI that reads a dynamic API", async () => { + // app/feed/@team/loading.tsx, which the shell renders in place of the + // slot's page + const response = await dispatchSource( + { + slots: { + "modal@app/feed/@modal": { name: "modal" }, + "team@app/feed/@team": { + loading: { default: DynamicComponent }, + name: "team", + page: { default: PendingDynamicComponent }, + }, + }, + }, + APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL, + ); + + expect(response).not.toBe("stalled"); + expect((response as Response).headers.get("cache-control")).toBe(neverCache); + }); + + for (const renderMode of [ + APP_RSC_RENDER_MODE_PREFETCH_EMPTY, + APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL, + ] as const) { + for (const [label, DefaultComponent] of [ + ["dynamic", DynamicComponent], + ["pending dynamic", PendingDynamicComponent], + ] as const) { + it(`doesn't probe a ${label} slot default a ${renderMode} render leaves out`, async () => { + const response = await dispatchSource( + { slots: slotsWithDefault(DefaultComponent) }, + renderMode, + ); + + expect(response).not.toBe("stalled"); + expect((response as Response).headers.get("cache-control")).toBeNull(); + }); + } + } + }); + for (const config of knownDynamicConfigs) { const label = config.dynamicConfig ?? "revalidate = 0"; @@ -3253,6 +4783,38 @@ describe("app page dispatch", () => { ); }); + it(`sends the never-cache header for a ${label} intercepting page in a cacheComponents build`, async () => { + // app/feed/@modal/(.)photos/[id]/page.tsx sets the config, so the + // intercepting route's tree is known dynamic. + const interceptPage = { + default: "modal-page", + dynamic: config.dynamicConfig, + revalidate: config.revalidateSeconds, + }; + const response = await dispatchIntercept({ + findIntercept: () => ({ + matchedParams: { id: "123" }, + page: interceptPage, + slotKey: "modal@app/feed/@modal", + sourceRouteIndex: 1, + }), + pprRuntime: appPagePprRuntime, + resolveRouteDynamicConfig: (route, intercept) => + route === sourceRoute && intercept?.interceptPage === interceptPage + ? config.dynamicConfig + : undefined, + resolveRouteRevalidateSeconds: (route, intercept) => + route === sourceRoute && intercept?.interceptPage === interceptPage + ? config.revalidateSeconds + : null, + }); + + await expect(response.text()).resolves.toBe("/feed"); + expect(response.headers.get("cache-control")).toBe( + "private, no-cache, no-store, max-age=0, must-revalidate", + ); + }); + for (const pprRuntime of [undefined, appPagePprRuntime]) { const build = pprRuntime ? "a cacheComponents build" : "a standard build"; @@ -3337,7 +4899,10 @@ describe("app page dispatch", () => { const navigationContext = setNavigationContext.mock.calls.at(-1)?.[0]; expect(navigationContext?.searchParams.toString()).toBe(""); expect(resolveRouteDynamicConfig).toHaveBeenCalledTimes(1); - expect(resolveRouteDynamicConfig).toHaveBeenCalledWith(sourceRoute); + expect(resolveRouteDynamicConfig).toHaveBeenCalledWith( + sourceRoute, + expect.objectContaining({ interceptSlotKey: "modal@app/feed/@modal" }), + ); }); it("observes searchParams access for a dynamic-error intercept source route", async () => { @@ -4029,11 +5594,404 @@ describe("app page dispatch", () => { expect(response.headers.get("x-vinext-cache")).toBeNull(); await expect(response.text()).resolves.toBe("flight"); expect(scheduledRender).toBeNull(); - expect(resolveRouteDynamicConfig).toHaveBeenCalledWith(targetRoute); + expect(resolveRouteDynamicConfig).toHaveBeenCalledWith( + targetRoute, + expect.objectContaining({ interceptSlotKey: "modal@app/feed/@modal" }), + ); const [routeArg] = buildPageElement.mock.calls[0]; expect(routeArg).toBe(targetRoute); }); + async function regenerateStaleInterceptedRscEntry(overrides: { + currentRoute: TestRoute; + dynamicConfig?: string; + fetchCache?: DispatchOptions["fetchCache"]; + interceptPage: unknown; + pprRuntime?: DispatchOptions["pprRuntime"]; + readsHeaders?: boolean; + resolveRouteDynamicConfig: DispatchOptions["resolveRouteDynamicConfig"]; + resolveRouteFetchCacheMode: DispatchOptions["resolveRouteFetchCacheMode"]; + resolveRouteHasAnyGenerateStaticParams?: DispatchOptions["resolveRouteHasAnyGenerateStaticParams"]; + resolveRouteRevalidateSeconds: DispatchOptions["resolveRouteRevalidateSeconds"]; + revalidateSeconds: number; + sourceRoute: TestRoute; + }) { + let scheduledRender: unknown = null; + const isrSet = vi.fn(async () => {}); + const renders: Record[] = []; + const staleValue = buildCachedAppPageValue( + "", + new TextEncoder().encode("stale-flight").buffer, + undefined, + buildQueryInvariantRenderObservation(), + ); + const staleEntry = buildISRCacheEntry(staleValue, true); + staleEntry.value.cacheControl = { revalidate: 60 }; + const { options } = createDispatchOptions({ + async buildPageElement(route, _params, _opts, _searchParams, _paramAccess, buildOptions) { + // Stands in for headers(), which force-static answers without + // marking the render dynamic. + if (overrides.readsHeaders !== false) markDynamicUsage(); + const context = getRequestContext(); + renders.push({ + dynamicUsage: consumeDynamicUsage(), + fetchCacheMode: context.currentFetchCacheMode, + fetchRevalidate: context.currentFetchRevalidate, + observePageSearchParamsAccess: buildOptions?.observePageSearchParamsAccess, + }); + return route.pattern; + }, + cleanPathname: "/photos/123", + dynamicConfig: overrides.dynamicConfig, + findIntercept: () => ({ + matchedParams: { id: "123" }, + page: overrides.interceptPage, + slotKey: "modal@app/feed/@modal", + sourceRouteIndex: 1, + }), + getSourceRoute: (index) => (index === 1 ? overrides.sourceRoute : undefined), + interceptionContext: "/feed", + isProduction: true, + isRscRequest: true, + isrGet: vi.fn(async () => staleEntry), + isrRscKey(pathname, _mountedSlotsHeader, _renderMode, interceptionContext) { + return `rsc:${pathname}:${interceptionContext ?? "none"}`; + }, + isrSet, + loadSsrHandler: async () => ({ + async handleSsr(_rscStream, _navigationContext, _fontData, captureOptions) { + if (captureOptions?.capturedRscDataRef) { + captureOptions.capturedRscDataRef.value = Promise.resolve( + new TextEncoder().encode("regenerated-flight").buffer, + ); + } + void captureOptions?.sideStream?.cancel().catch(() => {}); + return createStream(["regenerated"]); + }, + }), + pprRuntime: overrides.pprRuntime, + resolveRouteDynamicConfig: overrides.resolveRouteDynamicConfig, + resolveRouteFetchCacheMode: overrides.resolveRouteFetchCacheMode, + resolveRouteHasAnyGenerateStaticParams: overrides.resolveRouteHasAnyGenerateStaticParams, + resolveRouteRevalidateSeconds: overrides.resolveRouteRevalidateSeconds, + revalidateSeconds: overrides.revalidateSeconds, + route: overrides.currentRoute, + scheduleBackgroundRegeneration(_key, renderFn) { + scheduledRender = renderFn; + }, + }); + options.fetchCache = overrides.fetchCache; + + const response = await dispatchAppPage(options); + await expect(response.text()).resolves.toBe("stale-flight"); + if (typeof scheduledRender !== "function") { + throw new Error("expected the stale intercepted entry to schedule regeneration"); + } + const regeneration: Promise = scheduledRender(); + return { isrSet, regeneration, renders, staleValue }; + } + + it("regenerates a stale intercepted RSC entry with its intercepted tree's config", async () => { + // app/feed/@modal/(.)photos/[id]/page.tsx sets dynamic = "force-static", + // fetchCache = "force-cache" and revalidate = 30; app/feed's own tree + // doesn't, and app/photos/[id] sets revalidate = 60. + const sourceRoute = createRoute({ pattern: "/feed", routeSegments: ["feed"] }); + const currentRoute = createRoute({ + params: ["id"], + pattern: "/photos/[id]", + routeSegments: ["photos", "[id]"], + }); + const interceptPage = { default: "modal-page" }; + const isInterceptTree = ( + route: TestRoute, + intercept?: Parameters>[1], + ) => route === sourceRoute && intercept?.interceptPage === interceptPage; + const { isrSet, regeneration, renders } = await regenerateStaleInterceptedRscEntry({ + currentRoute, + interceptPage, + resolveRouteDynamicConfig: (route, intercept) => + isInterceptTree(route, intercept) ? "force-static" : undefined, + resolveRouteFetchCacheMode: (route, intercept) => + isInterceptTree(route, intercept) ? "force-cache" : "force-no-store", + resolveRouteRevalidateSeconds: (route, intercept) => + isInterceptTree(route, intercept) ? 30 : null, + revalidateSeconds: 60, + sourceRoute, + }); + await regeneration; + + expect(renders).toEqual([ + { + dynamicUsage: false, + fetchCacheMode: "force-cache", + fetchRevalidate: 30, + observePageSearchParamsAccess: false, + }, + ]); + // The render collected no revalidate of its own, so the entry keeps the + // intercepted tree's, not the matched route's 60 seconds. + expect(isrSet).toHaveBeenCalledTimes(1); + expect(isrSet).toHaveBeenCalledWith("rsc:/photos/123:/feed", expect.anything(), { + cacheControl: { revalidate: 30 }, + tags: expect.any(Array), + }); + }); + + // Resolves app/feed's intercepted tree like the generated entry: Next.js's + // tree for the intercepting route, merged with app/feed/@sidebar's active + // page, which vinext renders where Next.js renders its default. + function regenerateStaleInterceptWithActiveSidebar( + interceptPage: Record, + sidebarPage: Record, + ) { + const sourceRoute = createRoute({ pattern: "/feed", routeSegments: ["feed"] }); + const resolveConfig = ( + route: TestRoute, + intercept?: Parameters>[1], + ) => { + if (route !== sourceRoute || !intercept) return null; + const [interceptTree, renderedTree] = [false, true].map((keepActiveSiblings) => + resolveAppPageInterceptTree({ + interceptBranchSegments: ["(.)photos", "[id]"], + interceptPage: intercept.interceptPage as Record, + isSiblingPageIntercept: false, + keepActiveSiblings, + layouts: [{}, {}], + layoutTreePositions: [0, 1], + page: {}, + parallelBranches: [ + { isDefault: true, layout: null, name: "modal", ownerTreePosition: 1, page: {} }, + { + default: {}, + isDefault: false, + layout: null, + name: "sidebar", + ownerTreePosition: 1, + page: sidebarPage, + routeSegments: [], + }, + ], + routeSegments: ["feed"], + slotIndex: 0, + }), + ); + return resolveAppPageInterceptSegmentConfig(interceptTree, renderedTree); + }; + return regenerateStaleInterceptedRscEntry({ + currentRoute: createRoute({ + params: ["id"], + pattern: "/photos/[id]", + routeSegments: ["photos", "[id]"], + }), + interceptPage, + resolveRouteDynamicConfig: (route, intercept) => + resolveConfig(route, intercept)?.dynamicConfig ?? null, + resolveRouteFetchCacheMode: (route, intercept) => + resolveConfig(route, intercept)?.fetchCache ?? null, + resolveRouteRevalidateSeconds: (route, intercept) => + resolveConfig(route, intercept)?.revalidateSeconds ?? null, + revalidateSeconds: 60, + sourceRoute, + }); + } + + it("regenerates a stale intercepted RSC entry under an active sibling's shorter revalidate", async () => { + // app/feed/@modal/(.)photos/[id]/page.tsx sets dynamic = "force-static" + // and revalidate = 30; app/feed/@sidebar/page.tsx, which renders beside + // it, sets revalidate = 10. + const { isrSet, regeneration, renders } = await regenerateStaleInterceptWithActiveSidebar( + { default: "modal-page", dynamic: "force-static", revalidate: 30 }, + { default: "sidebar-page", revalidate: 10 }, + ); + await expect(regeneration).resolves.toBeUndefined(); + + expect(renders).toEqual([ + { + dynamicUsage: false, + fetchCacheMode: null, + fetchRevalidate: 10, + observePageSearchParamsAccess: false, + }, + ]); + expect(isrSet).toHaveBeenCalledTimes(1); + expect(isrSet).toHaveBeenCalledWith("rsc:/photos/123:/feed", expect.anything(), { + cacheControl: { revalidate: 10 }, + tags: expect.any(Array), + }); + }); + + it("regenerates a stale intercepted RSC entry with an active sibling's dynamic and fetchCache", async () => { + // app/feed/@sidebar/page.tsx sets dynamic = "force-static" and + // fetchCache = "force-no-store"; the intercepting page sets neither. + const { isrSet, regeneration, renders } = await regenerateStaleInterceptWithActiveSidebar( + { default: "modal-page" }, + { default: "sidebar-page", dynamic: "force-static", fetchCache: "force-no-store" }, + ); + await expect(regeneration).resolves.toBeUndefined(); + + expect(renders).toEqual([ + { + dynamicUsage: false, + fetchCacheMode: "force-no-store", + fetchRevalidate: null, + observePageSearchParamsAccess: false, + }, + ]); + // A force-static tree without a revalidate keeps `revalidate = false`. + expect(isrSet).toHaveBeenCalledTimes(1); + expect(isrSet).toHaveBeenCalledWith("rsc:/photos/123:/feed", expect.anything(), { + cacheControl: { revalidate: Infinity }, + tags: expect.any(Array), + }); + }); + + it("regenerates an intercept on the matched route without the replaced branch's config", async () => { + // app/photos/[id]/@modal/page.tsx sets dynamic = "force-static", + // fetchCache = "force-cache" and revalidate = 60, which the matched + // route's tree takes. The intercepting page that replaces it on the same + // route sets none of them. + const currentRoute = createRoute({ + params: ["id"], + pattern: "/photos/[id]", + routeSegments: ["photos", "[id]"], + }); + const interceptPage = { default: "modal-page" }; + const { isrSet, regeneration, renders, staleValue } = await regenerateStaleInterceptedRscEntry({ + currentRoute, + dynamicConfig: "force-static", + fetchCache: "force-cache", + interceptPage, + resolveRouteDynamicConfig: (_route, intercept) => (intercept ? null : "force-static"), + resolveRouteFetchCacheMode: (_route, intercept) => (intercept ? null : "force-cache"), + resolveRouteRevalidateSeconds: (_route, intercept) => (intercept ? null : 60), + revalidateSeconds: 60, + sourceRoute: currentRoute, + }); + // Without the replaced branch's force-static, the render's headers() read + // is dynamic usage, which fails the regeneration as in Next.js. + await expect(regeneration).rejects.toThrow( + "Page changed from static to dynamic at runtime /photos/123", + ); + + expect(renders).toEqual([ + { + dynamicUsage: true, + fetchCacheMode: null, + fetchRevalidate: null, + observePageSearchParamsAccess: true, + }, + ]); + expect(isrSet).toHaveBeenCalledTimes(1); + expect(isrSet).toHaveBeenCalledWith("rsc:/photos/123:/feed", staleValue, { + cacheControl: { revalidate: 30 }, + tags: expect.any(Array), + }); + }); + + it("regenerates a stale intercepted RSC entry with its tree's generateStaticParams default in a cacheComponents build", async () => { + // app/feed/@modal/(.)photos/[id]/page.tsx exports generateStaticParams + // and no revalidate, so the intercepting tree keeps `revalidate = false`. + const sourceRoute = createRoute({ pattern: "/feed", routeSegments: ["feed"] }); + const currentRoute = createRoute({ + params: ["id"], + pattern: "/photos/[id]", + routeSegments: ["photos", "[id]"], + }); + const interceptPage = { default: "modal-page", generateStaticParams: () => [] }; + const resolveRouteHasAnyGenerateStaticParams = vi.fn< + NonNullable + >((route, intercept) => route === sourceRoute && intercept.interceptPage === interceptPage); + const { isrSet, regeneration } = await regenerateStaleInterceptedRscEntry({ + currentRoute, + interceptPage, + pprRuntime: appPagePprRuntime, + resolveRouteDynamicConfig: () => null, + resolveRouteFetchCacheMode: () => null, + resolveRouteHasAnyGenerateStaticParams, + resolveRouteRevalidateSeconds: (route, intercept) => (intercept ? null : 60), + revalidateSeconds: 60, + sourceRoute, + }); + await regeneration; + + expect(resolveRouteHasAnyGenerateStaticParams).toHaveBeenCalledWith( + sourceRoute, + expect.objectContaining({ interceptPage }), + ); + expect(isrSet).toHaveBeenCalledTimes(1); + expect(isrSet).toHaveBeenCalledWith("rsc:/photos/123:/feed", expect.anything(), { + cacheControl: { revalidate: Infinity }, + tags: expect.any(Array), + }); + }); + + describe("a stale intercepted entry whose intercepted tree turned dynamic", () => { + // app/feed/@modal/(.)photos/[id]/page.tsx now sets revalidate = 0 or + // dynamic = "force-dynamic" (which resolves revalidate = 0), and its + // render reads no request API. app/photos/[id] sets revalidate = 60. + const sourceRoute = createRoute({ pattern: "/feed", routeSegments: ["feed"] }); + const currentRoute = createRoute({ + params: ["id"], + pattern: "/photos/[id]", + routeSegments: ["photos", "[id]"], + }); + const interceptPage = { default: "modal-page" }; + const isInterceptTree = ( + route: TestRoute, + intercept?: Parameters>[1], + ) => route === sourceRoute && intercept?.interceptPage === interceptPage; + const regenerate = ( + dynamicConfig: string | undefined, + pprRuntime?: DispatchOptions["pprRuntime"], + ) => + regenerateStaleInterceptedRscEntry({ + currentRoute, + interceptPage, + pprRuntime, + readsHeaders: false, + resolveRouteDynamicConfig: (route, intercept) => + isInterceptTree(route, intercept) ? dynamicConfig : undefined, + resolveRouteFetchCacheMode: () => null, + resolveRouteRevalidateSeconds: (route, intercept) => + isInterceptTree(route, intercept) ? 0 : null, + revalidateSeconds: 60, + sourceRoute, + }); + + it.each([ + ["revalidate = 0", undefined], + ['dynamic = "force-dynamic"', "force-dynamic"], + ])("fails its regeneration at %s and keeps the previous entry", async (_label, config) => { + const { isrSet, regeneration, renders, staleValue } = await regenerate(config); + + // Like Next.js, an effective revalidate of 0 fails a regeneration + // without PPR even when the render used no dynamic API. + await expect(regeneration).rejects.toThrow( + "Page changed from static to dynamic at runtime /photos/123", + ); + expect(renders).toEqual([expect.objectContaining({ dynamicUsage: false })]); + expect(isrSet).toHaveBeenCalledTimes(1); + expect(isrSet).toHaveBeenCalledWith("rsc:/photos/123:/feed", staleValue, { + cacheControl: { revalidate: 30 }, + tags: expect.any(Array), + }); + }); + + it("stores its PPR regeneration", async () => { + const { isrSet, regeneration } = await regenerate(undefined, appPagePprRuntime); + + await expect(regeneration).resolves.toBeUndefined(); + expect(isrSet).toHaveBeenCalledTimes(1); + expect(isrSet).toHaveBeenCalledWith( + "rsc:/photos/123:/feed", + expect.objectContaining({ + rscData: new TextEncoder().encode("regenerated-flight").buffer, + }), + { cacheControl: { revalidate: 0 }, tags: expect.any(Array) }, + ); + }); + }); + it("does not leak the current route's force-dynamic config into the revalidation target route", async () => { // When regenerating a stale cache entry for a target route that is NOT force-dynamic, // the dispatch must resolve the target route's dynamic config so that fetch @@ -4123,7 +6081,10 @@ describe("app page dispatch", () => { // the target route's dynamic config instead of inheriting the current route's. const response = await dispatchAppPage(options); expect(response.status).toBe(200); - expect(resolveRouteDynamicConfig).toHaveBeenCalledWith(targetRoute); + expect(resolveRouteDynamicConfig).toHaveBeenCalledWith( + targetRoute, + expect.objectContaining({ interceptSlotKey: "modal@app/feed/@modal" }), + ); }); it("serves exact cache HIT instead of fallback shell", async () => { diff --git a/tests/app-page-element-builder.test.ts b/tests/app-page-element-builder.test.ts index af714eae6f..66dced5ac9 100644 --- a/tests/app-page-element-builder.test.ts +++ b/tests/app-page-element-builder.test.ts @@ -1,3 +1,7 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { toSlash } from "pathslash"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import React from "react"; import { @@ -31,6 +35,7 @@ import { type AppPageBuildRoute, } from "../packages/vinext/src/server/app-page-element-builder.js"; import { probeAppPage } from "../packages/vinext/src/server/app-page-probe.js"; +import { appRouter, invalidateAppRouteCache } from "../packages/vinext/src/routing/app-router.js"; import { isPromiseLike } from "../packages/vinext/src/utils/promise.js"; import { ClientPageRoot } from "../packages/vinext/src/shims/client-page-root.js"; import { SIBLING_PAGE_INTERCEPT_SLOT_KEY } from "../packages/vinext/src/server/app-rsc-route-matching.js"; @@ -2070,6 +2075,86 @@ describe("buildPageElements", () => { expect(Object.prototype.hasOwnProperty.call(record, "page:/feed")).toBe(true); }); + it("wraps a slot intercept's page and metadata with the slot's layouts above the marker", async () => { + // Next.js builds the loader tree of app/@modal/gallery/(.)photo/page.tsx + // from every folder on its path, so app/@modal/gallery/layout.tsx wraps + // the marker's layout, the intercepting page and its metadata. + const appDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-intercept-ancestor-render-")); + try { + for (const file of [ + "layout.tsx", + "gallery/page.tsx", + "gallery/photo/page.tsx", + "@modal/default.tsx", + "@modal/gallery/layout.tsx", + "@modal/gallery/(.)photo/layout.tsx", + "@modal/gallery/(.)photo/page.tsx", + ]) { + fs.mkdirSync(path.dirname(path.join(appDir, file)), { recursive: true }); + fs.writeFileSync( + path.join(appDir, file), + "export default function Page() { return null; }\n", + ); + } + invalidateAppRouteCache(); + const galleryRoute = (await appRouter(appDir)).find((route) => route.pattern === "/gallery"); + const intercept = galleryRoute?.parallelSlots[0]?.interceptingRoutes[0]; + const layoutModules: Record = { + "@modal/gallery/layout.tsx": { + default: ({ children }: { children?: React.ReactNode }) => + React.createElement("section", null, children), + metadata: { description: "Gallery layout" }, + } as AppPageModule, + "@modal/gallery/(.)photo/layout.tsx": { + default: ({ children }: { children?: React.ReactNode }) => + React.createElement("article", null, children), + metadata: { title: "Photo layout" }, + } as AppPageModule, + }; + + const result = await buildPageElements( + createBaseOptions({ + route: createSyntheticRoute({ + page: createSyntheticPageModule(() => React.createElement("div", null, "Gallery")), + routeSegments: ["gallery"], + pattern: "/gallery", + slots: { + "@modal": { + name: "modal", + default: createSyntheticPageModule(() => null), + layoutIndex: -1, + routeSegments: [], + }, + }, + }), + routePath: "/gallery/photo", + opts: { + interceptBranchSegments: intercept?.branchSegments, + interceptLayouts: intercept?.layoutPaths.map( + (layoutPath) => layoutModules[toSlash(path.relative(appDir, layoutPath))], + ), + interceptLayoutSegments: intercept?.layoutSegments, + interceptPage: createSyntheticPageModule(() => + React.createElement("div", null, "Photo"), + ), + interceptParams: {}, + interceptSlotKey: "@modal", + }, + }), + ); + const html = await renderRouteEntry(result, result[APP_ROUTE_KEY] as string); + + expect(html).toContain(''); + expect(html).toContain("Photo layout"); + await expect(renderElementEntry(result, "slot:modal:/")).resolves.toContain( + "
Photo
", + ); + } finally { + invalidateAppRouteCache(); + fs.rmSync(appDir, { recursive: true, force: true }); + } + }); + it("builds elements for a page that receives search params", async () => { function ParamPage(): React.ReactNode { return React.createElement("div", null, "Params"); diff --git a/tests/app-page-probe.test.ts b/tests/app-page-probe.test.ts index 301254edb8..47a96450ff 100644 --- a/tests/app-page-probe.test.ts +++ b/tests/app-page-probe.test.ts @@ -1,14 +1,18 @@ import React from "react"; import { describe, expect, it, vi } from "vite-plus/test"; import { + buildAppPageInterceptSourceProbes, buildAppPageProbes, probeAppPage, probeAppPageBeforeRender, probeReactServerSubtree, + resolveAppPageProbeIntercept, } from "../packages/vinext/src/server/app-page-probe.js"; +import { SIBLING_PAGE_INTERCEPT_SLOT_KEY } from "../packages/vinext/src/server/app-rsc-route-matching.js"; import { consumeDynamicUsage, consumeRenderRequestApiUsage, + markDynamicUsage, } from "../packages/vinext/src/shims/headers.js"; // Mirrors makeThenableParams() from app-rsc-entry.ts — the function that @@ -718,12 +722,13 @@ describe("buildAppPageProbes", () => { slots: { modal: { page: { default: recordingPage("modal", probed) } }, sidebar: { page: { default: recordingPage("sidebar", probed) } }, + photos: {}, }, }, pageComponent: recordingPage("page", probed), asyncRouteParams: makeThenableParams({ slug: "intro" }), searchParams: new URLSearchParams("q=hello"), - intercept: { page: { default: recordingPage("intercept", probed) } }, + intercept: { page: { default: recordingPage("intercept", probed) }, slotKey: "photos" }, isRscRequest: true, matchedParams: { slug: "intro" }, makeThenableParams: makeThenableParamsLoose, @@ -794,6 +799,32 @@ describe("buildAppPageProbes", () => { expect(probed).not.toContain("modal-page"); }); + it("omits the probe of a slot intercept the route has no slot for", async () => { + // A route-group variant of the source without @modal renders unchanged, + // so the intercepting page never renders and must not bail it out. + const probed: string[] = []; + const route = { slots: { sidebar: { page: { default: recordingPage("sidebar", probed) } } } }; + const intercept = { slotKey: "modal", page: { default: recordingPage("intercept", probed) } }; + const probes = buildAppPageProbes({ + route, + pageComponent: recordingPage("page", probed), + asyncRouteParams: makeThenableParams({}), + searchParams: new URLSearchParams("q=hello"), + intercept, + isRscRequest: true, + matchedParams: {}, + makeThenableParams: makeThenableParamsLoose, + }); + + await Promise.all(probes); + + expect(probed.sort()).toEqual(["page", "sidebar"]); + expect(resolveAppPageProbeIntercept(route, intercept)).toBeNull(); + expect(resolveAppPageProbeIntercept({ slots: { modal: {} } }, intercept)).toBe(intercept); + const siblingIntercept = { ...intercept, slotKey: SIBLING_PAGE_INTERCEPT_SLOT_KEY }; + expect(resolveAppPageProbeIntercept(route, siblingIntercept)).toBe(siblingIntercept); + }); + it("omits the interception probe when no interception matches", async () => { const probed: string[] = []; const probes = buildAppPageProbes({ @@ -936,11 +967,11 @@ describe("buildAppPageProbes", () => { const fallbackParams = { username: "ada" }; const probes = buildAppPageProbes({ - route: {}, + route: { slots: { modal: {} } }, pageComponent: () => "page", asyncRouteParams: makeThenableParams({}), searchParams: null, - intercept: { page: { default: InterceptPage } }, + intercept: { page: { default: InterceptPage }, slotKey: "modal" }, isRscRequest: true, matchedParams: fallbackParams, makeThenableParams: makeThenableParamsLoose, @@ -951,4 +982,621 @@ describe("buildAppPageProbes", () => { expect(receivedParams).toHaveLength(1); expect(await (receivedParams[0] as Promise>)).toEqual(fallbackParams); }); + + // app/feed/page.tsx with app/feed/(.)photos/[id]/page.tsx + it("probes a sibling-page intercept's page in place of the source page", async () => { + const probed: string[] = []; + const probe = (sourceIsDynamic: boolean) => + buildAppPageProbes({ + route: {}, + pageComponent: function Page() { + probed.push("page"); + // Stands in for headers() or cookies(). + if (sourceIsDynamic) markDynamicUsage(); + return "page"; + }, + asyncRouteParams: makeThenableParams({}), + searchParams: null, + intercept: { + page: { + default: function Photo() { + probed.push("intercept"); + if (!sourceIsDynamic) markDynamicUsage(); + return "intercept"; + }, + }, + slotKey: SIBLING_PAGE_INTERCEPT_SLOT_KEY, + }, + isRscRequest: true, + matchedParams: {}, + makeThenableParams: makeThenableParamsLoose, + }); + consumeDynamicUsage(); + + await Promise.all(probe(true)); + expect(consumeDynamicUsage()).toBe(false); + await Promise.all(probe(false)); + expect(consumeDynamicUsage()).toBe(true); + expect(probed).toEqual(["intercept", "intercept"]); + }); + + // A route-group variant of the source without app/feed/@modal. + it("does not probe an interception for a slot the route doesn't have", async () => { + const probed: string[] = []; + consumeDynamicUsage(); + const probes = buildAppPageProbes({ + route: { slots: { sidebar: { page: { default: recordingPage("sidebar", probed) } } } }, + pageComponent: recordingPage("page", probed), + asyncRouteParams: makeThenableParams({}), + searchParams: null, + intercept: { + page: { + default: function Photo() { + probed.push("intercept"); + markDynamicUsage(); + return "intercept"; + }, + }, + slotKey: "modal", + }, + isRscRequest: true, + matchedParams: {}, + makeThenableParams: makeThenableParamsLoose, + }); + + await Promise.all(probes); + + expect(probed.sort()).toEqual(["page", "sidebar"]); + expect(consumeDynamicUsage()).toBe(false); + }); +}); + +// buildAppPageInterceptSourceProbes() runs, ahead of a direct intercepted RSC +// response's headers, what that response's render includes before any loading +// boundary. +describe("buildAppPageInterceptSourceProbes", () => { + const makeThenableParamsLoose = (params: unknown): unknown => + makeThenableParams((params ?? {}) as Record); + + // A layout, template, page or default that records its render and, when + // `dynamic`, stands in for a headers() or cookies() call. + function recording(label: string, sink: string[], dynamic = false) { + return function Component(props: { children?: React.ReactNode }) { + sink.push(label); + if (dynamic) markDynamicUsage(); + return props.children ?? label; + }; + } + + // A component that awaits forever; awaiting its probe would hold the + // response headers. + function neverSettling(label: string, sink: string[]) { + return function Component(): Promise { + sink.push(label); + markDynamicUsage(); + return new Promise(() => {}); + }; + } + + function probeSource( + options: Partial[0]> & + Pick[0], "route">, + ): Promise { + consumeDynamicUsage(); + return Promise.all( + buildAppPageInterceptSourceProbes({ + pageComponent: undefined, + sourceParams: {}, + searchParams: null, + mountedSlotsHeader: null, + renderMode: undefined, + makeThenableParams: makeThenableParamsLoose, + ...options, + }), + ); + } + + const modalIntercept = (sink: string[]) => ({ + page: { default: recording("intercept", sink) }, + slotKey: "modal", + }); + + it("probes the intercepted slot's layout and the intercepting branch's layouts", async () => { + const probed: string[] = []; + await probeSource({ + route: { + slots: { + modal: { layout: { default: recording("modal", probed) } }, + // Its layout wraps only a page it doesn't have. + sidebar: { layout: { default: recording("sidebar", probed) } }, + }, + }, + intercept: { + ...modalIntercept(probed), + interceptLayouts: [{ default: recording("photos", probed) }, null], + matchedParams: { id: "123" }, + }, + }); + + expect(probed).toEqual(["modal", "photos", "intercept"]); + }); + + it("records an intercepting layout's dynamic API read", async () => { + const probed: string[] = []; + await probeSource({ + route: { slots: { modal: {} } }, + intercept: { + ...modalIntercept(probed), + interceptLayouts: [{ default: recording("photos", probed, true) }], + }, + }); + + expect(consumeDynamicUsage()).toBe(true); + }); + + // app/feed/template.tsx + it("probes the source's templates", async () => { + const probed: string[] = []; + await probeSource({ + route: { + layouts: [{ default: recording("layout", probed) }], + layoutTreePositions: [1], + routeSegments: ["feed"], + templates: [{ default: recording("template", probed, true) }], + templateTreePositions: [1], + }, + pageComponent: recording("page", probed), + }); + + expect(probed).toEqual(["layout", "template", "page"]); + expect(consumeDynamicUsage()).toBe(true); + }); + + // app/feed/loading.tsx above app/feed/[id]/template.tsx + it("stops the source's layouts and templates at its loading boundary", async () => { + const probed: string[] = []; + await probeSource({ + route: { + layouts: [{ default: recording("feed", probed) }, { default: neverSettling("id", probed) }], + layoutTreePositions: [1, 2], + loadings: [{ default: () => null }], + loadingTreePositions: [1], + routeSegments: ["feed", "[id]"], + templates: [ + { default: recording("feed-template", probed) }, + { default: neverSettling("id-template", probed) }, + ], + templateTreePositions: [1, 2], + }, + pageComponent: neverSettling("page", probed), + }); + + expect(probed).toEqual(["feed", "feed-template"]); + expect(consumeDynamicUsage()).toBe(false); + }); + + // app/feed/@sidebar/layout.tsx and app/feed/@sidebar/settings/layout.tsx + it("probes an ordinary slot's layout chain", async () => { + const probed: string[] = []; + const received: Record[] = []; + await probeSource({ + route: { + layoutTreePositions: [0, 2], + routeSegments: ["[team]", "feed"], + slots: { + modal: {}, + sidebar: { + configLayouts: [ + { + default: async function SettingsLayout(props: { + params: Promise>; + }) { + received.push({ ...(await props.params) }); + probed.push("settings"); + return null; + }, + }, + ], + configLayoutTreePositions: [1], + layout: { default: recording("sidebar", probed, true) }, + layoutIndex: 1, + name: "sidebar", + page: { default: recording("sidebar-page", probed) }, + routeSegments: ["settings"], + }, + }, + }, + intercept: modalIntercept(probed), + sourceParams: { team: "acme" }, + }); + + expect(probed.sort()).toEqual(["intercept", "settings", "sidebar", "sidebar-page"]); + expect(received).toEqual([{ team: "acme" }]); + expect(consumeDynamicUsage()).toBe(true); + }); + + // app/feed/@sidebar/settings/loading.tsx + it("stops an ordinary slot's layout chain at its loading boundary", async () => { + const probed: string[] = []; + await probeSource({ + route: { + slots: { + sidebar: { + configLayouts: [ + { default: recording("settings", probed) }, + { default: neverSettling("advanced", probed) }, + ], + configLayoutTreePositions: [1, 2], + layout: { default: recording("sidebar", probed) }, + loadings: [{ default: () => null }], + loadingTreePositions: [1], + page: { default: neverSettling("sidebar-page", probed) }, + }, + }, + }, + }); + + expect(probed.sort()).toEqual(["settings", "sidebar"]); + expect(consumeDynamicUsage()).toBe(false); + }); + + it("probes a slot's default where route wiring renders it", async () => { + const probed: string[] = []; + const defaultOnly = (name: string, extra: Record = {}) => ({ + default: { default: recording(name, probed) }, + // Its layout wraps only a page, not its default. + layout: { default: recording(`${name}-layout`, probed) }, + layoutIndex: 1, + name, + ...extra, + }); + const probe = (mountedSlotsHeader: string | null) => + probeSource({ + route: { + layoutTreePositions: [0, 1], + routeSegments: ["feed"], + slots: { + // Its page renders, not its default. + analytics: { + ...defaultOnly("analytics-default"), + page: { default: recording("analytics", probed) }, + }, + // Replaced by the interception. + modal: defaultOnly("modal"), + sidebar: defaultOnly("sidebar"), + // Behind its loading boundary. + team: defaultOnly("team", { loading: { default: () => "loading" } }), + }, + }, + pageComponent: recording("page", probed), + intercept: modalIntercept(probed), + mountedSlotsHeader, + }); + + await probe(null); + expect(probed.splice(0).sort()).toEqual([ + "analytics", + "analytics-default-layout", + "intercept", + "modal-layout", + "page", + "sidebar", + ]); + // The client keeps app/feed/@sidebar mounted, so the payload omits it. + await probe("slot:sidebar:/feed"); + expect(probed.splice(0).sort()).toEqual([ + "analytics", + "analytics-default-layout", + "intercept", + "modal-layout", + "page", + ]); + }); + + it("probes nothing for a prefetch-empty render", async () => { + const probed: string[] = []; + const probes = buildAppPageInterceptSourceProbes({ + route: { + layouts: [{ default: recording("layout", probed, true) }], + slots: { + modal: {}, + // Its default reads a dynamic API and never settles. + sidebar: { default: { default: neverSettling("sidebar", probed) }, name: "sidebar" }, + }, + }, + pageComponent: recording("page", probed, true), + intercept: modalIntercept(probed), + sourceParams: {}, + searchParams: null, + mountedSlotsHeader: null, + renderMode: "prefetch-empty", + makeThenableParams: makeThenableParamsLoose, + }); + consumeDynamicUsage(); + + await Promise.all(probes); + + expect(probes).toEqual([]); + expect(probed).toEqual([]); + expect(consumeDynamicUsage()).toBe(false); + }); + + // app/feed/@team/loading.tsx, with app/feed/@sidebar/default.tsx + it("probes only what a loading-shell prefetch renders", async () => { + const probed: string[] = []; + await probeSource({ + route: { + layouts: [{ default: recording("layout", probed) }], + layoutTreePositions: [0], + slots: { + // The shell renders only branches with a loading boundary. + sidebar: { default: { default: neverSettling("sidebar", probed) }, name: "sidebar" }, + team: { + layout: { default: recording("team-layout", probed) }, + loading: { default: () => null }, + name: "team", + page: { default: neverSettling("team-page", probed) }, + }, + }, + }, + // The shell omits the page. + pageComponent: neverSettling("page", probed), + renderMode: "prefetch-loading-shell", + }); + + expect(probed.sort()).toEqual(["layout", "team-layout"]); + expect(consumeDynamicUsage()).toBe(false); + }); + + // app/feed/@team/loading.tsx, where app/feed/layout.tsx owns app/feed/@team + it("probes no layout below a loading-shell prefetch's cutoff", async () => { + const probed: string[] = []; + await probeSource({ + route: { + layouts: [ + { default: recording("root", probed) }, + { default: recording("feed", probed) }, + { default: neverSettling("id", probed) }, + ], + layoutTreePositions: [0, 1, 2], + routeSegments: ["feed", "[id]"], + slots: { + team: { + layout: { default: recording("team-layout", probed) }, + layoutIndex: 1, + loading: { default: () => null }, + name: "team", + ownerTreePosition: 1, + page: { default: neverSettling("team-page", probed) }, + }, + }, + }, + renderMode: "prefetch-loading-shell", + }); + + // The shell stops at the deepest owner of a slot loading boundary. + expect(probed.sort()).toEqual(["feed", "root", "team-layout"]); + expect(consumeDynamicUsage()).toBe(false); + }); + + // app/loading.tsx and app/feed/[id]/loading.tsx + it("probes the route loading component a loading-shell prefetch renders", async () => { + const probed: string[] = []; + await probeSource({ + route: { + layouts: [ + { default: recording("root", probed) }, + { default: recording("feed", probed) }, + { default: recording("id", probed) }, + ], + layoutTreePositions: [0, 1, 2], + loadings: [ + { default: recording("root-loading", probed) }, + { default: recording("id-loading", probed, true) }, + ], + loadingTreePositions: [0, 2], + routeSegments: ["feed", "[id]"], + }, + pageComponent: neverSettling("page", probed), + renderMode: "prefetch-loading-shell", + }); + + // The shell renders the first loading UI below the root, with no + // Suspense boundary for the root's, so every layout above it renders. + expect(probed.sort()).toEqual(["feed", "id", "id-loading", "root"]); + expect(consumeDynamicUsage()).toBe(true); + }); + + // app/feed/@team/loading.tsx + it("probes the slot loading component a loading-shell prefetch renders", async () => { + const probed: string[] = []; + await probeSource({ + route: { + layouts: [{ default: recording("layout", probed) }], + layoutTreePositions: [0], + slots: { + modal: {}, + team: { + layout: { default: recording("team-layout", probed) }, + loading: { default: recording("team-loading", probed, true) }, + name: "team", + page: { default: neverSettling("team-page", probed) }, + }, + }, + }, + intercept: modalIntercept(probed), + renderMode: "prefetch-loading-shell", + }); + + expect(probed.sort()).toEqual(["layout", "team-layout", "team-loading"]); + expect(consumeDynamicUsage()).toBe(true); + }); + + // app/[team]/feed/@modal/(.)photos/layout.tsx and + // app/[team]/feed/@modal/(.)photos/[id]/layout.tsx + it("passes each layout the params its place in the intercepted tree sees", async () => { + const received: [string, Record][] = []; + const paramsLayout = (label: string, readsHeadersWithId = false) => + async function Layout(props: { + children?: React.ReactNode; + params: Promise>; + }) { + const params = await props.params; + received.push([label, { ...params }]); + // Stands in for a headers() call that only a child param triggers. + if (readsHeadersWithId && params.id) markDynamicUsage(); + return props.children; + }; + + await probeSource({ + route: { + layoutTreePositions: [0, 2], + routeSegments: ["[team]", "feed"], + slots: { modal: { layout: { default: paramsLayout("modal") }, layoutIndex: 1 } }, + }, + intercept: { + interceptBranchSegments: ["(.)photos", "[id]"], + interceptLayouts: [ + { default: paramsLayout("photos", true) }, + { default: paramsLayout("photo") }, + ], + interceptLayoutSegments: [["(.)photos"], ["(.)photos", "[id]"]], + matchedParams: { id: "123", team: "acme" }, + page: { default: () => null }, + slotKey: "modal", + }, + sourceParams: { team: "acme" }, + }); + + expect(received).toEqual([ + ["modal", { team: "acme" }], + ["photos", { team: "acme" }], + ["photo", { id: "123", team: "acme" }], + ]); + expect(consumeDynamicUsage()).toBe(false); + }); + + // app/[team]/(.)photos/layout.tsx and app/[team]/(.)photos/[id]/layout.tsx + it("passes a sibling-page intercept's layouts the params their segments see", async () => { + const received: [string, Record][] = []; + const paramsLayout = (label: string) => + async function Layout(props: { + children?: React.ReactNode; + params: Promise>; + }) { + received.push([label, { ...(await props.params) }]); + return props.children; + }; + + await probeSource({ + route: { routeSegments: ["[team]", "feed"] }, + intercept: { + interceptBranchSegments: ["(.)photos", "[id]"], + interceptLayouts: [{ default: paramsLayout("photos") }, { default: paramsLayout("photo") }], + interceptLayoutSegments: [["(.)photos"], ["(.)photos", "[id]"]], + matchedParams: { id: "123", team: "acme" }, + slotKey: SIBLING_PAGE_INTERCEPT_SLOT_KEY, + }, + sourceParams: { team: "acme" }, + }); + + expect(received).toEqual([ + ["photos", { team: "acme" }], + ["photo", { id: "123", team: "acme" }], + ]); + }); + + // app/feed/@modal/(.)photos/loading.tsx above + // app/feed/@modal/(.)photos/[id]/layout.tsx + it("stops at an intercepting loading boundary", async () => { + const probed: string[] = []; + await probeSource({ + route: { slots: { modal: { layout: { default: recording("modal", probed) } } } }, + intercept: { + interceptBranchSegments: ["(.)photos", "[id]"], + interceptLayouts: [ + { default: recording("photos", probed) }, + { default: neverSettling("photo", probed) }, + ], + interceptLayoutSegments: [["(.)photos"], ["(.)photos", "[id]"]], + interceptLoadings: [{ default: () => null }], + interceptLoadingTreePositions: [1], + matchedParams: { id: "123" }, + page: { default: neverSettling("intercept", probed) }, + slotKey: "modal", + }, + }); + + expect(probed).toEqual(["modal", "photos"]); + }); + + it("stops below the intercepted slot's root loading boundary", async () => { + const probed: string[] = []; + await probeSource({ + route: { + slots: { + modal: { + layout: { default: recording("modal", probed) }, + loading: { default: () => null }, + }, + }, + }, + intercept: { + interceptBranchSegments: ["(.)photos"], + interceptLayouts: [{ default: neverSettling("photos", probed) }], + interceptLayoutSegments: [["(.)photos"]], + page: { default: neverSettling("intercept", probed) }, + slotKey: "modal", + }, + }); + + expect(probed).toEqual(["modal"]); + }); + + it("probes no slot inside a source loading boundary", async () => { + const probed: string[] = []; + const probe = (slotKey: string, loadingTreePosition: number) => + probeSource({ + route: { + layoutTreePositions: [0, 1], + loadings: [{ default: () => null }], + loadingTreePositions: [loadingTreePosition], + routeSegments: ["feed", "[id]"], + slots: { modal: { layout: { default: recording("modal", probed) }, layoutIndex: 1 } }, + }, + intercept: { + interceptLayouts: [{ default: recording("photos", probed) }], + interceptLayoutSegments: [["(.)photos"]], + page: { default: () => null }, + slotKey, + }, + }); + + // app/feed/loading.tsx wraps the slot that app/feed/layout.tsx owns. + await probe("modal", 1); + expect(probed.splice(0)).toEqual([]); + // A sibling-page intercept renders in place of the source's page. + await probe(SIBLING_PAGE_INTERCEPT_SLOT_KEY, 2); + expect(probed.splice(0)).toEqual([]); + // A loading boundary below the slot's owner doesn't wrap the slot. + await probe("modal", 2); + expect(probed.splice(0)).toEqual(["modal", "photos"]); + }); + + // A route-group variant of the source without app/feed/@modal. + it("probes no interception for a slot the route doesn't have", async () => { + const probed: string[] = []; + await probeSource({ + route: { slots: { sidebar: { page: { default: recording("sidebar", probed) } } } }, + intercept: { + interceptLayouts: [{ default: recording("photos", probed) }], + page: { default: recording("intercept", probed, true) }, + slotKey: "modal", + }, + pageComponent: recording("page", probed), + }); + + expect(probed.sort()).toEqual(["page", "sidebar"]); + expect(consumeDynamicUsage()).toBe(false); + }); }); diff --git a/tests/app-page-request.test.ts b/tests/app-page-request.test.ts index 036f972277..30b5a159ae 100644 --- a/tests/app-page-request.test.ts +++ b/tests/app-page-request.test.ts @@ -2,12 +2,15 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { resolveAppPageSpecialError } from "../packages/vinext/src/server/app-page-execution.js"; import { buildAppPageElement, + loadAppPageCurrentRouteIntercept, + matchAppPageCurrentRouteIntercept, resolveAppPageActionRerenderTarget, resolveAppPageIntercept, resolveAppPageInterceptionRerenderTarget, resolveAppPageGenerateStaticParamsSources, validateAppPageDynamicParams, } from "../packages/vinext/src/server/app-page-request.js"; +import { SIBLING_PAGE_INTERCEPT_SLOT_KEY } from "../packages/vinext/src/server/app-rsc-route-matching.js"; import { cookies, headersContextFromRequest } from "../packages/vinext/src/shims/headers.js"; import { createRequestContext, @@ -85,6 +88,47 @@ describe("app page request helpers", () => { expect(response?.status).toBe(404); }); + it.each([ + ["an independent", () => [{ path: ["known"] }]], + [ + "a chained", + [ + { + chained: true as const, + generateStaticParams: () => [{ path: ["known"] }], + parentParamNames: [], + }, + ], + ], + ])( + "compares an omitted optional catch-all against %s generator's values", + async (_name, source) => { + const validate = (params: Record) => + validateAppPageDynamicParams({ + enforceStaticParamsOnly: true, + generateStaticParams: source, + isDynamicRoute: true, + optionalCatchAllParamNames: ["path"], + params, + }); + + expect((await validate({}))?.status).toBe(404); + expect(await validate({ path: ["known"] })).toBeNull(); + }, + ); + + it("allows an omitted optional catch-all that a generator leaves explicitly empty", async () => { + const response = await validateAppPageDynamicParams({ + enforceStaticParamsOnly: true, + generateStaticParams: () => [{ path: [] }], + isDynamicRoute: true, + optionalCatchAllParamNames: ["path"], + params: {}, + }); + + expect(response).toBeNull(); + }); + it("allows matching static params, including nested parent params", async () => { const response = await validateAppPageDynamicParams({ enforceStaticParamsOnly: true, @@ -230,6 +274,36 @@ describe("app page request helpers", () => { ).resolves.toMatchObject({ status: 404 }); }); + it("names the param of an intercepting route's marked segment", async () => { + // app/feed/[slug]/@modal/(..)[slug]/page.tsx generates the slug its own + // segment names, so it is no parent param of that generator. + const interceptGenerateStaticParams = vi.fn(() => [{ slug: "known" }]); + const generateStaticParams = resolveAppPageGenerateStaticParamsSources({ + layouts: [null], + layoutTreePositions: [0], + parallelBranches: [ + { + page: { generateStaticParams: interceptGenerateStaticParams }, + routeSegments: ["(..)[slug]"], + }, + ], + routeSegments: ["feed", "[slug]"], + }); + const validate = (slug: string) => + validateAppPageDynamicParams({ + enforceStaticParamsOnly: true, + generateStaticParams, + isDynamicRoute: true, + params: { slug }, + }); + + await expect(validate("known")).resolves.toBeNull(); + await expect(validate("unknown")).resolves.toMatchObject({ status: 404 }); + expect(interceptGenerateStaticParams).toHaveBeenCalledTimes(2); + expect(interceptGenerateStaticParams).toHaveBeenNthCalledWith(1, { params: {} }); + expect(interceptGenerateStaticParams).toHaveBeenNthCalledWith(2, { params: {} }); + }); + it("validates chained and parallel branch params independently", async () => { const localeGenerateStaticParams = vi.fn(() => [{ locale: "en" }]); const slugGenerateStaticParams = vi.fn(() => [{ slug: "static-123" }]); @@ -564,6 +638,7 @@ describe("app page request helpers", () => { return { ...params, catchAll: ["photos", "123"] }; }, renderInterceptResponse, + routeHasSlot: () => true, searchParams: new URLSearchParams("from=feed"), setNavigationContext, toInterceptOpts(intercept) { @@ -631,6 +706,7 @@ describe("app page request helpers", () => { async renderInterceptResponse() { throw new Error("should not render a separate intercept response"); }, + routeHasSlot: () => true, searchParams: new URLSearchParams(), setNavigationContext() {}, toInterceptOpts(intercept) { @@ -650,6 +726,247 @@ describe("app page request helpers", () => { }); }); + describe("owner default of a slot intercept", () => { + const currentRoute = { params: ["id"], pattern: "/photos/[id]", slots: {} }; + const feedDefault = { default: "feed-default", dynamic: "force-static" }; + + async function resolveWithSource( + sourceRoute: { params: string[]; pattern: string; slots: Record }, + slotKey: string, + routeHasSlot = (route: typeof sourceRoute, key: string) => Object.hasOwn(route.slots, key), + ) { + const __loadOwnerDefault = vi.fn(async () => feedDefault); + const loadInterceptLayout = vi.fn(async () => ({ default: "modal-layout" })); + const renderInterceptResponse = vi.fn( + (_route: unknown, _element: unknown, _opts: { interceptOwnerDefault: unknown }) => + new Response("intercepted"), + ); + const result = await resolveAppPageIntercept({ + async buildPageElement(route) { + return route.pattern; + }, + cleanPathname: "/photos/123", + currentRoute, + findIntercept() { + return { + __loadInterceptLayouts: [loadInterceptLayout], + __loadOwnerDefault, + interceptLayouts: [null], + matchedParams: { id: "123" }, + ownerDefault: null, + page: { default: "modal-page" }, + slotKey, + sourceRouteIndex: 0, + }; + }, + getRouteParamNames(route) { + return route.params; + }, + getSourceRoute() { + return sourceRoute; + }, + isRscRequest: true, + resolveNavigationParams(_route, params) { + return params; + }, + renderInterceptResponse, + routeHasSlot, + searchParams: new URLSearchParams(), + setNavigationContext() {}, + toInterceptOpts(intercept) { + return { interceptOwnerDefault: intercept.ownerDefault }; + }, + }); + return { __loadOwnerDefault, loadInterceptLayout, renderInterceptResponse, result }; + } + + it("loads it once the concrete source has the intercepted slot", async () => { + const sourceRoute = { + params: [], + pattern: "/feed", + slots: { "modal@app/feed/@modal": {} }, + }; + const { __loadOwnerDefault, loadInterceptLayout, renderInterceptResponse } = + await resolveWithSource(sourceRoute, "modal@app/feed/@modal"); + + expect(__loadOwnerDefault).toHaveBeenCalledTimes(1); + expect(loadInterceptLayout).toHaveBeenCalledTimes(1); + expect(renderInterceptResponse).toHaveBeenCalledWith(sourceRoute, "/feed", { + interceptOwnerDefault: feedDefault, + }); + }); + + it("skips it when the concrete source variant lacks the intercepted slot", async () => { + // A route-group variant of app/feed without @modal renders its own page, + // so a throwing or slow app/feed/default.tsx, or any module of the + // unused intercept, must not run. + const sourceRoute = { params: [], pattern: "/feed", slots: {} }; + const { __loadOwnerDefault, loadInterceptLayout, renderInterceptResponse } = + await resolveWithSource(sourceRoute, "modal@app/feed/@modal"); + + expect(__loadOwnerDefault).not.toHaveBeenCalled(); + expect(loadInterceptLayout).not.toHaveBeenCalled(); + expect(renderInterceptResponse).toHaveBeenCalledWith(sourceRoute, "/feed", { + interceptOwnerDefault: null, + }); + }); + + it("skips it for a sibling-page intercept", async () => { + // The intercepting page replaces the source's page, not the children of + // a slot's owner, whatever slots the source has. + const sourceRoute = { + params: [], + pattern: "/feed", + slots: { "modal@app/feed/@modal": {} }, + }; + const { __loadOwnerDefault, renderInterceptResponse } = await resolveWithSource( + sourceRoute, + SIBLING_PAGE_INTERCEPT_SLOT_KEY, + () => true, + ); + + expect(__loadOwnerDefault).not.toHaveBeenCalled(); + expect(renderInterceptResponse).toHaveBeenCalledWith(sourceRoute, "/feed", { + interceptOwnerDefault: null, + }); + }); + + it("loads it for a current-route interception, which renders the intercepting tree", async () => { + const __loadOwnerDefault = vi.fn(async () => feedDefault); + const feedRoute = { params: [], pattern: "/feed", slots: { "modal@app/feed/@modal": {} } }; + + const result = await resolveAppPageIntercept({ + async buildPageElement() { + throw new Error("should not build a separate intercept element"); + }, + cleanPathname: "/feed", + currentRoute: feedRoute, + findIntercept: () => ({ + __loadOwnerDefault, + matchedParams: {}, + ownerDefault: null, + page: { default: "modal-page" }, + slotKey: "modal@app/feed/@modal", + sourceRouteIndex: 0, + }), + getRouteParamNames: (route) => route.params, + getSourceRoute: () => feedRoute, + isRscRequest: true, + resolveNavigationParams: (_route, params) => params, + async renderInterceptResponse() { + throw new Error("should not render a separate intercept response"); + }, + routeHasSlot: (route, key) => Object.hasOwn(route.slots, key), + searchParams: new URLSearchParams(), + setNavigationContext() {}, + toInterceptOpts: (intercept) => ({ interceptOwnerDefault: intercept.ownerDefault }), + }); + + expect(result.response).toBeNull(); + expect(__loadOwnerDefault).toHaveBeenCalledTimes(1); + expect(result.interceptOpts).toEqual({ interceptOwnerDefault: feedDefault }); + }); + + it("leaves it unloaded on re-render targets, which never classify", async () => { + const __loadOwnerDefault = vi.fn(async () => feedDefault); + const sourceRoute = { + params: [], + pattern: "/feed", + slots: { "modal@app/feed/@modal": {} }, + }; + + const result = await resolveAppPageActionRerenderTarget({ + cleanPathname: "/photos/123", + currentParams: { id: "123" }, + currentRoute, + findIntercept: () => ({ + __loadOwnerDefault, + matchedParams: { id: "123" }, + ownerDefault: null, + page: { default: "modal-page" }, + slotKey: "modal@app/feed/@modal", + sourceRouteIndex: 0, + }), + getRouteParamNames: (route) => route.params, + getSourceRoute: () => sourceRoute, + isRscRequest: true, + toInterceptOpts: (intercept) => ({ interceptPage: intercept.page }), + }); + + expect(result.route).toBe(sourceRoute); + expect(__loadOwnerDefault).not.toHaveBeenCalled(); + }); + }); + + describe("current-route interception", () => { + const feedRoute = { pattern: "/feed", slots: { "modal@app/feed/@modal": {} } }; + + function createIntercept(slotKey = "modal@app/feed/@modal") { + return { + __loadOwnerDefault: vi.fn(async () => ({ default: "feed-default" })), + __pageLoader: vi.fn(async () => ({ default: "modal-page" })), + matchedParams: {}, + ownerDefault: null, + page: null, + slotKey, + sourceRouteIndex: 0, + }; + } + + it("matches an interception whose source is the current route without loading it", async () => { + const intercept = createIntercept(); + + const match = await matchAppPageCurrentRouteIntercept({ + cleanPathname: "/feed", + currentRoute: feedRoute, + findIntercept: () => intercept, + getSourceRoute: () => feedRoute, + isRscRequest: true, + }); + + expect(match).toBe(intercept); + expect(intercept.__pageLoader).not.toHaveBeenCalled(); + expect(intercept.__loadOwnerDefault).not.toHaveBeenCalled(); + }); + + it("matches nothing for another source route or a non-RSC request", async () => { + const intercept = createIntercept(); + const options = { + cleanPathname: "/feed", + currentRoute: feedRoute, + findIntercept: () => intercept, + isRscRequest: true, + }; + + await expect( + matchAppPageCurrentRouteIntercept({ ...options, getSourceRoute: () => ({ ...feedRoute }) }), + ).resolves.toBeNull(); + await expect( + matchAppPageCurrentRouteIntercept({ + ...options, + getSourceRoute: () => feedRoute, + isRscRequest: false, + }), + ).resolves.toBeNull(); + }); + + it("loads the intercepting tree's page and the owner's default for a slot the route has", async () => { + const intercept = createIntercept(); + const routeHasSlot = (route: typeof feedRoute, key: string) => + Object.hasOwn(route.slots, key); + + await loadAppPageCurrentRouteIntercept(intercept, feedRoute, routeHasSlot); + + expect(intercept.page).toEqual({ default: "modal-page" }); + expect(intercept.ownerDefault).toEqual({ default: "feed-default" }); + + const siblingIntercept = createIntercept(SIBLING_PAGE_INTERCEPT_SLOT_KEY); + await loadAppPageCurrentRouteIntercept(siblingIntercept, feedRoute, () => true); + expect(siblingIntercept.page).toEqual({ default: "modal-page" }); + expect(siblingIntercept.__loadOwnerDefault).not.toHaveBeenCalled(); + }); + }); + it("returns special-error responses from page builds", async () => { const result = await buildAppPageElement({ async buildPageElement() { @@ -767,6 +1084,45 @@ describe("resolveAppPageInterceptionRerenderTarget intercept loading", () => { expect(result.interceptOpts).toEqual(toInterceptOpts(intercept)); }); + it("loads a slot intercept's owner default before building its intercept options", async () => { + // A stale variant regenerates with the intercepting route's tree, where + // app/feed/default.tsx replaces app/feed's children, so its config must be + // loaded before the options it classifies with are built. + const feedDefault = { default: "feed-default", dynamic: "force-dynamic" }; + const resolveWith = async (slots: Record) => { + const __loadOwnerDefault = vi.fn(async () => feedDefault); + const result = await resolveAppPageInterceptionRerenderTarget({ + cleanPathname: "/photos/123", + currentParams: { id: "123" }, + currentRoute, + findIntercept: () => ({ + __loadOwnerDefault, + matchedParams: { id: "123" }, + ownerDefault: null, + page: { default: "modal-page" }, + slotKey: "modal@app/feed/@modal", + sourceRouteIndex: 0, + }), + getRouteParamNames: (route) => route.params, + getSourceRoute: () => ({ ...sourceRoute, slots }), + isRscRequest: true, + routeHasSlot: (route, key) => + Object.hasOwn((route as { slots?: Record }).slots ?? {}, key), + toInterceptOpts: (intercept) => ({ interceptOwnerDefault: intercept.ownerDefault }), + }); + return { __loadOwnerDefault, result }; + }; + + const attached = await resolveWith({ "modal@app/feed/@modal": {} }); + expect(attached.__loadOwnerDefault).toHaveBeenCalledTimes(1); + expect(attached.result.interceptOpts).toEqual({ interceptOwnerDefault: feedDefault }); + + // A source variant without @modal renders unchanged and never loads it. + const unattached = await resolveWith({}); + expect(unattached.__loadOwnerDefault).not.toHaveBeenCalled(); + expect(unattached.result.interceptOpts).toEqual({ interceptOwnerDefault: null }); + }); + it("deduplicates concurrent intercept page and layout loads", async () => { const interceptPage = { default: "modal-page" }; const interceptLayout = { default: "modal-layout" }; diff --git a/tests/app-page-route-wiring.test.ts b/tests/app-page-route-wiring.test.ts index 135c20422f..d7f11013f5 100644 --- a/tests/app-page-route-wiring.test.ts +++ b/tests/app-page-route-wiring.test.ts @@ -35,10 +35,8 @@ import { getRequestContext, runWithRequestContext, } from "../packages/vinext/src/shims/unified-request-context.js"; -import { - APP_PAGE_INTERCEPTION_MARKER_TRAVERSALS, - buildPageElements as buildResolvedPageElements, -} from "../packages/vinext/src/server/app-page-element-builder.js"; +import { buildPageElements as buildResolvedPageElements } from "../packages/vinext/src/server/app-page-element-builder.js"; +import { APP_PAGE_INTERCEPTION_MARKER_TRAVERSALS } from "../packages/vinext/src/server/app-page-interception-markers.js"; import { createNextBfcacheIdMap } from "../packages/vinext/src/server/app-bfcache-identity.js"; import type { AppPageSemanticSegment } from "../packages/vinext/src/server/app-page-segment-state.js"; import { createAppPageRenderDependency } from "../packages/vinext/src/server/app-render-dependency.js"; diff --git a/tests/app-route-graph.test.ts b/tests/app-route-graph.test.ts index 48991d2cb9..6989dcd12c 100644 --- a/tests/app-route-graph.test.ts +++ b/tests/app-route-graph.test.ts @@ -1639,6 +1639,82 @@ describe("App Router route graph builder", () => { }); }); + it("starts a slot intercept's layout chain with the slot's layouts above the marker", async () => { + // Next.js builds the loader tree of app/@modal/gallery/(.)photo/page.tsx + // from every folder on its path, so app/@modal/gallery/layout.tsx wraps + // the (.)photo branch outside the marker's own layout. + await withTempApp(async (appDir) => { + await writeAppFile(appDir, "layout.tsx", EMPTY_LAYOUT); + await writeAppFile(appDir, "gallery/page.tsx", EMPTY_PAGE); + await writeAppFile(appDir, "gallery/photo/page.tsx", EMPTY_PAGE); + await writeAppFile(appDir, "@modal/default.tsx", EMPTY_PAGE); + await writeAppFile(appDir, "@modal/gallery/layout.tsx", EMPTY_LAYOUT); + await writeAppFile(appDir, "@modal/gallery/(.)photo/layout.tsx", EMPTY_LAYOUT); + await writeAppFile(appDir, "@modal/gallery/(.)photo/page.tsx", EMPTY_PAGE); + + const graph = await buildAppRouteGraph(appDir, createValidFileMatcher()); + const intercept = findRoute(graph.routes, "/gallery").parallelSlots[0] + ?.interceptingRoutes[0]; + + expect(intercept?.layoutPaths).toEqual([ + canonical(appDir, "@modal/gallery/layout.tsx"), + canonical(appDir, "@modal/gallery/(.)photo/layout.tsx"), + ]); + expect(intercept?.layoutSegments).toEqual([["gallery"], ["gallery", "photo"]]); + expect(intercept?.branchSegments).toEqual(["gallery", "photo"]); + }); + }); + + it("starts a sibling intercept's layout chain with the folders between its source and the marker", async () => { + // app/feed/(shell)/(.)photo/page.tsx replaces app/feed/page.tsx, and + // Next.js's intercepting route tree continues below app/feed through + // (shell), so its layout and loading wrap the (.)photo branch. + await withTempApp(async (appDir) => { + await writeAppFile(appDir, "layout.tsx", EMPTY_LAYOUT); + await writeAppFile(appDir, "feed/page.tsx", EMPTY_PAGE); + await writeAppFile(appDir, "photo/page.tsx", EMPTY_PAGE); + await writeAppFile(appDir, "feed/(shell)/layout.tsx", EMPTY_LAYOUT); + await writeAppFile(appDir, "feed/(shell)/loading.tsx", EMPTY_PAGE); + await writeAppFile(appDir, "feed/(shell)/(.)photo/layout.tsx", EMPTY_LAYOUT); + await writeAppFile(appDir, "feed/(shell)/(.)photo/loading.tsx", EMPTY_PAGE); + await writeAppFile(appDir, "feed/(shell)/(.)photo/page.tsx", EMPTY_PAGE); + + const graph = await buildAppRouteGraph(appDir, createValidFileMatcher()); + const intercept = findRoute(graph.routes, "/feed").siblingIntercepts[0]; + + expect(intercept?.layoutPaths).toEqual([ + canonical(appDir, "feed/(shell)/layout.tsx"), + canonical(appDir, "feed/(shell)/(.)photo/layout.tsx"), + ]); + expect(intercept?.layoutSegments).toEqual([["(shell)"], ["(shell)", "photo"]]); + expect(intercept?.branchSegments).toEqual(["(shell)", "photo"]); + expect(intercept?.loadingPaths).toEqual([ + canonical(appDir, "feed/(shell)/loading.tsx"), + canonical(appDir, "feed/(shell)/(.)photo/loading.tsx"), + ]); + expect(intercept?.loadingTreePositions).toEqual([1, 2]); + // Its not-found position still counts from the marker. + expect(intercept?.notFoundBranchSegments).toEqual(["photo"]); + }); + }); + + it("keeps a sibling intercept in its source's folder rooted at the marker", async () => { + await withTempApp(async (appDir) => { + await writeAppFile(appDir, "layout.tsx", EMPTY_LAYOUT); + await writeAppFile(appDir, "feed/page.tsx", EMPTY_PAGE); + await writeAppFile(appDir, "photo/page.tsx", EMPTY_PAGE); + await writeAppFile(appDir, "feed/(.)photo/layout.tsx", EMPTY_LAYOUT); + await writeAppFile(appDir, "feed/(.)photo/page.tsx", EMPTY_PAGE); + + const graph = await buildAppRouteGraph(appDir, createValidFileMatcher()); + const intercept = findRoute(graph.routes, "/feed").siblingIntercepts[0]; + + expect(intercept?.layoutPaths).toEqual([canonical(appDir, "feed/(.)photo/layout.tsx")]); + expect(intercept?.layoutSegments).toEqual([["photo"]]); + expect(intercept?.branchSegments).toEqual(["photo"]); + }); + }); + it("includes dynamic ancestor params for (.) slot with a dynamic ancestor segment", async () => { // Regression for the double-conversion bug: raw filesystem segments must be // passed as baseParts so that [locale] is not converted to :locale before diff --git a/tests/app-route-module-loader.test.ts b/tests/app-route-module-loader.test.ts index f7aad85f3b..889eda4446 100644 --- a/tests/app-route-module-loader.test.ts +++ b/tests/app-route-module-loader.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { ensureAppRouteModulesLoaded, loadAppInterceptNotFound, + loadAppInterceptOwnerDefault, loadAppInterceptPage, loadAppInterceptLayouts, type LazyLoadableRoute, @@ -213,6 +214,33 @@ describe("ensureAppRouteModulesLoaded", () => { expect(route.slots?.["@modal"].configLayouts).toEqual([nestedSlotLayout]); expect(route.slots?.["@modal"].loadings).toEqual([nestedSlotLoading]); }); + + it("leaves a slot intercept's owner default to the matched intercept", async () => { + // app/feed/default.tsx replaces app/feed's children only in the tree of + // app/feed/@modal/(.)photos/[id], so a direct /feed request never + // evaluates it. + const feedDefault = { default: () => null, dynamic: "force-static" }; + const __loadOwnerDefault = vi.fn(async () => feedDefault); + const intercept = { ownerDefault: null, __loadOwnerDefault }; + const route: LazyLoadableRoute = { + slots: { + "@modal": { + page: null, + __loadPage: async () => ({ default: () => null }), + intercepts: [intercept], + }, + }, + }; + + await ensureAppRouteModulesLoaded(route); + + expect(__loadOwnerDefault).not.toHaveBeenCalled(); + expect(intercept.ownerDefault).toBeNull(); + + await loadAppInterceptOwnerDefault(intercept); + + expect(intercept.ownerDefault).toBe(feedDefault); + }); }); describe("loadAppInterceptLayouts", () => { @@ -245,6 +273,7 @@ describe("loadAppInterceptLayouts", () => { describe.each([ ["page", "__pageLoader", "pageLoading", loadAppInterceptPage], ["notFound", "__loadNotFound", "notFoundLoading", loadAppInterceptNotFound], + ["ownerDefault", "__loadOwnerDefault", "ownerDefaultLoading", loadAppInterceptOwnerDefault], ] as const)("loadAppIntercept%s", (field, loaderField, loadingField, load) => { it("publishes a shared concurrent load onto every request-local intercept clone", async () => { let release!: () => void; @@ -261,6 +290,8 @@ describe.each([ pageLoading: null, notFound: null, notFoundLoading: null, + ownerDefault: null, + ownerDefaultLoading: null, interceptLayoutsLoading: null, }; const first = { diff --git a/tests/app-router-dev-server.test.ts b/tests/app-router-dev-server.test.ts index 181a554cbd..47a4dfa23a 100644 --- a/tests/app-router-dev-server.test.ts +++ b/tests/app-router-dev-server.test.ts @@ -1,5 +1,6 @@ import http from "node:http"; import fsp from "node:fs/promises"; +import os from "node:os"; import path from "node:path"; import { type ViteDevServer } from "vite"; import { afterAll, beforeAll, describe, expect, it } from "vite-plus/test"; @@ -2584,3 +2585,87 @@ describe("App Router public files whose route starts with basePath in dev", () = expect(await res.text()).toBe("Method Not Allowed"); }); }); + +describe("App Router slot intercept owner default in dev", () => { + let server: ViteDevServer; + let baseUrl: string; + let root: string; + let evaluationKey: string; + + beforeAll(async () => { + root = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-owner-default-")); + evaluationKey = `vinext-owner-default:${root}`; + const write = async (file: string, code: string) => { + await fsp.mkdir(path.dirname(path.join(root, file)), { recursive: true }); + await fsp.writeFile(path.join(root, file), code); + }; + await fsp.symlink( + path.resolve(import.meta.dirname, "../node_modules"), + path.join(root, "node_modules"), + "junction", + ); + await write("package.json", JSON.stringify({ type: "module", private: true })); + await write( + "app/layout.tsx", + "export default function Layout({ children }) { return {children}; }", + ); + await write( + "app/feed/layout.tsx", + "export default function Layout({ children, modal }) { return <>{children}{modal}; }", + ); + await write( + "app/feed/page.tsx", + 'export const dynamic = "force-static";\nexport default function Page() { return
Feed page
; }', + ); + // Only the intercepting route's tree holds app/feed/default.tsx, in place + // of app/feed's children. + await write( + "app/feed/default.tsx", + `globalThis[${JSON.stringify(evaluationKey)}] = (globalThis[${JSON.stringify(evaluationKey)}] ?? 0) + 1; +export const dynamic = "force-static"; +export default function Default() { return null; }`, + ); + await write( + "app/feed/@modal/default.tsx", + "export default function Default() { return null; }", + ); + await write( + "app/feed/@modal/(..)photos/[id]/page.tsx", + "export default function Modal() { return
Photo modal
; }", + ); + await write( + "app/photos/[id]/page.tsx", + "export default function Photo() { return
Photo page
; }", + ); + ({ server, baseUrl } = await startFixtureServer(root, { appRouter: true })); + }, 30000); + + afterAll(async () => { + await server?.close(); + Reflect.deleteProperty(globalThis, evaluationKey); + if (root) await fsp.rm(root, { recursive: true, force: true }); + }); + + it("evaluates the owner's default only for an intercepted request", async () => { + const direct = await fetch(`${baseUrl}/feed`); + expect(direct.status).toBe(200); + expect(await direct.text()).toContain("Feed page"); + expect(Reflect.get(globalThis, evaluationKey)).toBeUndefined(); + + // The force-static default takes app/feed's children in the intercepting + // route's tree, so the auto modal without generateStaticParams is static. + // Without it, that tree's children would be the built-in default-null. + const intercepted = await fetch(`${baseUrl}/photos/1.rsc`, { + headers: { + Accept: "text/x-component", + "X-Vinext-Interception-Context": "/feed", + }, + }); + expect(intercepted.status).toBe(200); + expect(await intercepted.text()).toContain("Photo modal"); + expect(Reflect.get(globalThis, evaluationKey)).toBe(1); + expect(intercepted.headers.get("cache-control")).not.toBe( + "private, no-cache, no-store, max-age=0, must-revalidate", + ); + }); +}); diff --git a/tests/app-router-isr-codegen.test.ts b/tests/app-router-isr-codegen.test.ts index f3b1f95df6..4f99e97094 100644 --- a/tests/app-router-isr-codegen.test.ts +++ b/tests/app-router-isr-codegen.test.ts @@ -1,6 +1,21 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { toSlash } from "pathslash"; import { describe, expect, it } from "vite-plus/test"; +import vm from "node:vm"; import { generateRscEntry } from "../packages/vinext/src/entries/app-rsc-entry.js"; -import type { AppRoute } from "../packages/vinext/src/routing/app-router.js"; +import { SIBLING_PAGE_INTERCEPT_SLOT_KEY } from "../packages/vinext/src/server/app-rsc-route-matching.js"; +import * as appSegmentConfig from "../packages/vinext/src/server/app-segment-config.js"; +import { + resolveAppPageGenerateStaticParamsSources, + validateAppPageDynamicParams, +} from "../packages/vinext/src/server/app-page-request.js"; +import { + appRouter, + invalidateAppRouteCache, + type AppRoute, +} from "../packages/vinext/src/routing/app-router.js"; describe("generateRscEntry ISR code generation", () => { // Minimal route list — only the generated ISR guard logic matters here @@ -46,10 +61,28 @@ describe("generateRscEntry ISR code generation", () => { parallelBranches: segmentConfigBranches, routeSegments: route.routeSegments, }),`); + // An intercept swaps a sibling slot for its default. + expect(code).toContain("default: slot.default,"); expect(code).toContain("isDefault: !slot.page,"); expect(code).toContain("ownerTreePosition: slot.ownerTreePosition,"); // Any segment's generator still sets the route's revalidate default. expect(code).toContain("hasAnyGenerateStaticParams: __generateStaticParams.length > 0,"); + // ...and an intercepting tree's generators set its own, read from Next.js's + // tree for the intercepting route. + expect(code).toContain(`function __resolveRouteHasAnyGenerateStaticParams(route, intercept) { + const tree = __resolveRouteInterceptTree(route, intercept); + return __hasAppPageAnyGenerateStaticParams({ + childrenSlot: tree.route.childrenSlot, + layouts: tree.route.layouts, + layoutTreePositions: tree.route.layoutTreePositions, + page: tree.route.page, + parallelBranches: tree.branches, + routeSegments: tree.route.routeSegments, + }); +}`); + expect(code).toContain(`resolveRouteHasAnyGenerateStaticParams(targetRoute, intercept) { + return __resolveRouteHasAnyGenerateStaticParams(targetRoute, intercept); + },`); // The runtime merges across the whole loader tree, slots included. expect(code).toContain(`isStaticGenerationEdgeRuntime: __isEdgeRuntime( __resolveAppPageStaticGenerationRuntime( @@ -72,17 +105,428 @@ describe("generateRscEntry ISR code generation", () => { expect(code).toContain( "isStaticGenerationEdgeRuntime: __staticGeneration.isStaticGenerationEdgeRuntime,", ); - expect(code).toContain(`resolveRouteStaticEligible(targetRoute) { - return __resolveRouteStaticEligible(targetRoute); + expect(code).toContain(`resolveRouteStaticEligible(targetRoute, intercept) { + return __resolveRouteStaticEligible(targetRoute, intercept); + },`); + // A direct intercepted RSC response classifies the source's tree with the + // intercepting branch in the intercepted slot, and is dynamic when the + // intercepted route is. + expect(code).toContain( + `slotIndex: Object.keys(route.slots ?? {}).indexOf(intercept.interceptSlotKey),`, + ); + // ...and its own folder's children for that folder's default, which only + // the matched intercept carries. + expect(code).toContain("interceptOwnerDefault: intercept.interceptOwnerDefault,"); + // A sibling-page intercept is told apart from a slot the source lacks. + expect(code).toContain( + "isSiblingPageIntercept: intercept.interceptSlotKey === __SIBLING_PAGE_INTERCEPT_SLOT_KEY,", + ); + // Next.js's tree swaps the other slots for their defaults, but vinext + // renders their active pages, so both trees must be static. + expect(code).toContain(` return [false, true].every((keepActiveSiblings) => + __isRouteTreeStaticEligible(`); + expect(code).toContain("__resolveRouteInterceptTree(route, intercept, keepActiveSiblings),"); + expect(code).toContain(" keepActiveSiblings,\n"); + expect(code).toContain("const effectiveRoute = tree?.route ?? route;"); + // The direct intercept render's dynamic config, revalidate and fetchCache + // come from the same trees: Next.js's, and the active siblings vinext + // renders in place of its defaults. + expect(code).toContain(`resolveRouteFetchCacheMode(targetRoute, intercept) { + return __resolveRouteFetchCacheMode(targetRoute, intercept); + },`); + expect(code).toContain(`function __resolveRouteFetchCacheMode(route, intercept) { + if (intercept) { + return __resolveRouteInterceptSegmentConfig(route, intercept).fetchCache ?? null; + }`); + expect(code).toContain( + "return __resolveRouteInterceptSegmentConfig(route, intercept).dynamicConfig ?? null;", + ); + expect(code).toContain( + "return __resolveRouteInterceptSegmentConfig(route, intercept).revalidateSeconds;", + ); + expect(code) + .toContain(` const [interceptTree, renderedTree] = [false, true].map((keepActiveSiblings) => { + const tree = __resolveRouteInterceptTree(route, intercept, keepActiveSiblings);`); + expect(code).toContain( + "return __resolveAppPageInterceptSegmentConfig(interceptTree, renderedTree);", + ); + expect(code).toContain(`resolveRouteDynamicConfig(targetRoute, intercept) { + return __resolveRouteDynamicConfig(targetRoute, intercept); },`); + expect(code).toContain(`resolveRouteRevalidateSeconds(targetRoute, intercept) { + return __resolveRouteRevalidateSeconds(targetRoute, intercept); + },`); + // A Server Action rerender of that response resolves the same trees, with + // the owner default a slot intercept's tree renders. + for (const name of ["FetchCacheMode", "RevalidateSeconds", "DynamicConfig"]) { + expect(code.split(`return __resolveRoute${name}(targetRoute, intercept);`)).toHaveLength(3); + } + expect(code).toContain("interceptOwnerDefault: intercept.ownerDefault,"); + // ...and so does the dynamic stale time its RSC metadata advertises. + expect(code).toContain(`function __resolveRouteDynamicStaleTimeSeconds(route, intercept) { + if (intercept) { + return __resolveRouteInterceptSegmentConfig(route, intercept).dynamicStaleTimeSeconds; + } + return __resolveRouteSegmentConfig(route, __resolveRouteSegmentConfigBranches(route)) + .dynamicStaleTimeSeconds; +}`); + expect(code).toContain(`resolveRouteDynamicStaleTimeSeconds(targetRoute, intercept) { + return __resolveRouteDynamicStaleTimeSeconds(targetRoute, intercept); + },`); + // A slot intercept whose slot the source lacks leaves the source's tree as + // rendered, so the unused intercepted route's dynamism doesn't apply. + expect(code).toContain( + "if (!__isAppPageInterceptAttached(__resolveRouteInterceptTreeOptions(route, intercept))) {", + ); + // Its headers precede its render, so the direct intercept probes run what + // that render includes for the request's mounted slots and render mode. + expect(code).toContain(`return Promise.all(__buildAppPageInterceptSourceProbes({ + route: sourceRoute, + pageComponent: sourceRoute.page?.default, + intercept: __probeIntercept, + sourceParams, + searchParams: sourceSearchParams, + mountedSlotsHeader, + renderMode, + makeThenableParams, + }));`); expect(code).toContain(` return __isAppPageStaticEligible({ - ...__resolveRouteStaticGeneration(route, segmentConfigBranches), + ...__resolveRouteStaticGeneration(effectiveRoute, segmentConfigBranches), dynamicConfig: segmentConfig.dynamicConfig, - isDynamicRoute: route.isDynamic, + isDynamicRoute: intercept + ? __isAppPageInterceptTargetDynamic(intercept.interceptTargetPatternParts) + : route.isDynamic, revalidateSeconds: segmentConfig.revalidateSeconds, });`); }); + it("classifies a slot intercept the source can't take by the source alone", () => { + // Run the generated classification against the real segment-config helpers. + const code = generateRscEntry("/tmp/test/app", minimalRoutes); + const start = code.indexOf("function __resolveRouteSegmentConfigBranches(route) {"); + const end = code.indexOf("\n}\n", code.indexOf("function __isRouteTreeStaticEligible(")) + 3; + const resolveRouteStaticEligible = vm.runInNewContext( + `${code.slice(start, end)}\n__resolveRouteStaticEligible;`, + { + __collectAppPageStaticGenerationRuntimes: + appSegmentConfig.collectAppPageStaticGenerationRuntimes, + __hasAppPageGenerateStaticParamsAtLastDynamicSegment: + appSegmentConfig.hasAppPageGenerateStaticParamsAtLastDynamicSegment, + __isAppPageInterceptTargetDynamic: appSegmentConfig.isAppPageInterceptTargetDynamic, + __isAppPageInterceptAttached: appSegmentConfig.isAppPageInterceptAttached, + __isAppPageStaticEligible: appSegmentConfig.isAppPageStaticEligible, + __isEdgeRuntime: appSegmentConfig.isEdgeRuntime, + __resolveAppPageInterceptTree: appSegmentConfig.resolveAppPageInterceptTree, + __resolveAppPageSegmentConfig: appSegmentConfig.resolveAppPageSegmentConfig, + __resolveAppPageStaticGenerationRuntime: + appSegmentConfig.resolveAppPageStaticGenerationRuntime, + __SIBLING_PAGE_INTERCEPT_SLOT_KEY: SIBLING_PAGE_INTERCEPT_SLOT_KEY, + }, + ) as (route: unknown, intercept?: unknown) => boolean; + // A static app/feed intercepting the dynamic app/photos/[id] through + // app/feed/@modal/(..)photos/[id], which has no generateStaticParams. + const feed = { + childrenSlot: { ownerTreePath: "/feed", state: "active" }, + isDynamic: false, + layouts: [{}, {}], + layoutTreePositions: [0, 1], + page: {}, + routeSegments: ["feed"], + slots: { + "modal@app/feed/@modal": { + default: {}, + layout: null, + name: "modal", + ownerTreePosition: 1, + page: null, + routeSegments: [], + }, + }, + }; + const intercept = { + interceptBranchSegments: ["(..)photos", "[id]"], + interceptPage: {}, + interceptSlotKey: "modal@app/feed/@modal", + interceptTargetPatternParts: ["photos", ":id"], + }; + + expect(resolveRouteStaticEligible(feed)).toBe(true); + expect(resolveRouteStaticEligible(feed, intercept)).toBe(false); + // Next.js's isDynamicRoute classifies the intercepting route by the route + // its folders intercept: app/feed/@modal/(..)about intercepts /about, + // even where app/[slug] is the route that matches it... + expect( + resolveRouteStaticEligible(feed, { + ...intercept, + interceptBranchSegments: ["(..)about"], + interceptTargetPatternParts: ["about"], + }), + ).toBe(true); + // ...and app/[lang]/feed/@modal/(...)photos intercepts the static /photos. + const langFeed = { + ...feed, + childrenSlot: { ownerTreePath: "/[lang]/feed", state: "active" }, + isDynamic: true, + layouts: [{}, {}, {}], + layoutTreePositions: [0, 1, 2], + routeSegments: ["[lang]", "feed"], + slots: { + "modal@app/feed/@modal": { ...feed.slots["modal@app/feed/@modal"], ownerTreePosition: 2 }, + }, + }; + expect(resolveRouteStaticEligible(langFeed)).toBe(false); + expect( + resolveRouteStaticEligible(langFeed, { + ...intercept, + interceptBranchSegments: ["(...)photos"], + interceptTargetPatternParts: ["photos"], + }), + ).toBe(true); + // A route-group variant of app/feed without @modal renders its own page. + expect(resolveRouteStaticEligible({ ...feed, slots: {} }, intercept)).toBe(true); + expect( + resolveRouteStaticEligible( + { ...feed, slots: {} }, + { ...intercept, interceptSlotKey: SIBLING_PAGE_INTERCEPT_SLOT_KEY }, + ), + ).toBe(false); + }); + + it("gates a current-route intercept's params by the intercepting tree's own dynamicParams and generators", async () => { + // Run the generated resolvers against the real segment-config helpers. + const code = generateRscEntry("/tmp/test/app", minimalRoutes); + expect(code).toContain(`resolveRouteDynamicParamsConfig(targetRoute, intercept) { + return __resolveRouteDynamicParamsConfig(targetRoute, intercept); + },`); + expect(code).toContain(`resolveRouteGenerateStaticParams(targetRoute, intercept) { + return __resolveRouteGenerateStaticParams(targetRoute, intercept); + },`); + // The matched route's own gate reads the same generator walk. + expect(code).toContain( + "const __generateStaticParams = __resolveRouteGenerateStaticParams(route);", + ); + const start = code.indexOf("function __resolveRouteSegmentConfigBranches(route) {"); + const end = code.indexOf("\n}\n", code.indexOf("function __isRouteTreeStaticEligible(")) + 3; + const [resolveDynamicParamsConfig, resolveGenerateStaticParams] = vm.runInNewContext( + `${code.slice(start, end)}\n[__resolveRouteDynamicParamsConfig, __resolveRouteGenerateStaticParams];`, + { + __isAppPageInterceptTargetDynamic: appSegmentConfig.isAppPageInterceptTargetDynamic, + __isAppPageInterceptAttached: appSegmentConfig.isAppPageInterceptAttached, + __resolveAppPageGenerateStaticParamsSources: resolveAppPageGenerateStaticParamsSources, + __resolveAppPageInterceptTree: appSegmentConfig.resolveAppPageInterceptTree, + __resolveAppPageSegmentConfig: appSegmentConfig.resolveAppPageSegmentConfig, + __SIBLING_PAGE_INTERCEPT_SLOT_KEY: SIBLING_PAGE_INTERCEPT_SLOT_KEY, + }, + ) as [ + (route: unknown, intercept: unknown) => boolean | undefined, + ( + route: unknown, + intercept?: unknown, + ) => ReturnType, + ]; + // app/feed/[slug]/page.tsx and app/feed/[slug]/@modal/(..)[slug]/page.tsx, + // which intercepts /feed/[slug] from itself. The intercepting tree drops + // the route's page for app/feed/[slug]/default.tsx. + function resolveGate(page: object, interceptPage: object, hasModalSlot = true) { + const route = { + childrenSlot: { ownerTreePath: "/feed/[slug]", state: "active" }, + isDynamic: true, + layouts: [{}], + layoutTreePositions: [0], + page, + patternParts: ["feed", ":slug"], + routeSegments: ["feed", "[slug]"], + slots: hasModalSlot + ? { + "modal@app/feed/[slug]/@modal": { + default: {}, + layout: null, + name: "modal", + ownerTreePosition: 2, + page: null, + routeSegments: [], + }, + } + : {}, + }; + const intercept = { + interceptBranchSegments: ["(..)[slug]"], + interceptOwnerDefault: {}, + interceptPage, + interceptSlotKey: "modal@app/feed/[slug]/@modal", + }; + const dynamicParamsConfig = resolveDynamicParamsConfig(route, intercept); + const generateStaticParams = resolveGenerateStaticParams(route, intercept); + return (slug: string) => + validateAppPageDynamicParams({ + enforceStaticParamsOnly: dynamicParamsConfig === false, + generateStaticParams, + isDynamicRoute: true, + params: { slug }, + }); + } + const generate = (slug: string) => () => [{ slug }]; + const allowsKnown = { dynamicParams: false, generateStaticParams: generate("known") }; + const allowsOther = { dynamicParams: false, generateStaticParams: generate("other") }; + + // The intercepting tree's dynamicParams = false over a route that allows + // fallback params. + const interceptForbids = resolveGate({}, allowsKnown); + await expect(interceptForbids("known")).resolves.toBeNull(); + await expect(interceptForbids("unknown")).resolves.toMatchObject({ status: 404 }); + // An intercepting tree that allows fallback params over a route that + // doesn't. + await expect(resolveGate(allowsKnown, {})("unknown")).resolves.toBeNull(); + // Both forbid them, but only the intercepting tree's generators apply. + const bothForbid = resolveGate(allowsKnown, allowsOther); + await expect(bothForbid("other")).resolves.toBeNull(); + await expect(bothForbid("known")).resolves.toMatchObject({ status: 404 }); + // A route-group variant without @modal renders its own page. + const unattached = resolveGate(allowsKnown, allowsOther, false); + await expect(unattached("known")).resolves.toBeNull(); + await expect(unattached("other")).resolves.toMatchObject({ status: 404 }); + }); + + it("gates a renamed intercepting param by every param of the intercepting route's path", async () => { + // Run the generated resolvers against the real segment-config helpers. + const code = generateRscEntry("/tmp/test/app", minimalRoutes); + const start = code.indexOf("function __resolveRouteSegmentConfigBranches(route) {"); + const end = code.indexOf("\n}\n", code.indexOf("function __isRouteTreeStaticEligible(")) + 3; + const [resolveDynamicParamsConfig, resolveGenerateStaticParams] = vm.runInNewContext( + `${code.slice(start, end)}\n[__resolveRouteDynamicParamsConfig, __resolveRouteGenerateStaticParams];`, + { + __isAppPageInterceptTargetDynamic: appSegmentConfig.isAppPageInterceptTargetDynamic, + __isAppPageInterceptAttached: appSegmentConfig.isAppPageInterceptAttached, + __resolveAppPageGenerateStaticParamsSources: resolveAppPageGenerateStaticParamsSources, + __resolveAppPageInterceptTree: appSegmentConfig.resolveAppPageInterceptTree, + __resolveAppPageSegmentConfig: appSegmentConfig.resolveAppPageSegmentConfig, + __SIBLING_PAGE_INTERCEPT_SLOT_KEY: SIBLING_PAGE_INTERCEPT_SLOT_KEY, + }, + ) as [ + (route: unknown, intercept: unknown) => boolean | undefined, + ( + route: unknown, + intercept: unknown, + ) => ReturnType, + ]; + // app/feed/[slug]/layout.tsx generates slug "a", and + // app/feed/[slug]/@modal/(..)[photo]/page.tsx generates photo "known". + // Next.js serves /feed/known from /feed/a as /feed/[slug]/(..)[photo], + // whose path takes slug from the source and photo from the URL, and + // prerenders /feed/a/(..)known. + const route = { + childrenSlot: { ownerTreePath: "/feed/[slug]", state: "active" }, + isDynamic: true, + layouts: [{ generateStaticParams: () => [{ slug: "a" }] }], + layoutTreePositions: [2], + page: {}, + patternParts: ["feed", ":slug"], + routeSegments: ["feed", "[slug]"], + slots: { + "modal@app/feed/[slug]/@modal": { + default: {}, + layout: null, + name: "modal", + ownerTreePosition: 2, + page: null, + routeSegments: [], + }, + }, + }; + const intercept = { + interceptBranchSegments: ["(..)[photo]"], + interceptOwnerDefault: {}, + interceptPage: { dynamicParams: false, generateStaticParams: () => [{ photo: "known" }] }, + interceptSlotKey: "modal@app/feed/[slug]/@modal", + }; + const dynamicParamsConfig = resolveDynamicParamsConfig(route, intercept); + const generateStaticParams = resolveGenerateStaticParams(route, intercept); + const validate = (params: Record) => + validateAppPageDynamicParams({ + enforceStaticParamsOnly: dynamicParamsConfig === false, + generateStaticParams, + isDynamicRoute: true, + params, + }); + + await expect(validate({ photo: "known", slug: "a" })).resolves.toBeNull(); + await expect(validate({ photo: "unknown", slug: "a" })).resolves.toMatchObject({ + status: 404, + }); + await expect(validate({ photo: "known", slug: "b" })).resolves.toMatchObject({ + status: 404, + }); + }); + + it("exempts a current-route intercept's params gate only by the intercepting tree's own force-dynamic", () => { + // Run the generated resolvers against the real segment-config helpers. + const code = generateRscEntry("/tmp/test/app", minimalRoutes); + expect(code).toContain(`resolveRouteInterceptTreeDynamicConfig(targetRoute, intercept) { + return __resolveRouteInterceptTreeDynamicConfig(targetRoute, intercept); + },`); + const start = code.indexOf("function __resolveRouteSegmentConfigBranches(route) {"); + const end = code.indexOf("\n}\n", code.indexOf("function __isRouteTreeStaticEligible(")) + 3; + const [resolveInterceptSegmentConfig, resolveInterceptTreeDynamicConfig] = vm.runInNewContext( + `${code.slice(start, end)}\n[__resolveRouteInterceptSegmentConfig, __resolveRouteInterceptTreeDynamicConfig];`, + { + __resolveAppPageInterceptSegmentConfig: + appSegmentConfig.resolveAppPageInterceptSegmentConfig, + __resolveAppPageInterceptTree: appSegmentConfig.resolveAppPageInterceptTree, + __resolveAppPageSegmentConfig: appSegmentConfig.resolveAppPageSegmentConfig, + __SIBLING_PAGE_INTERCEPT_SLOT_KEY: SIBLING_PAGE_INTERCEPT_SLOT_KEY, + }, + ) as [ + (route: unknown, intercept: unknown) => { dynamicConfig?: string }, + (route: unknown, intercept: unknown) => string | null, + ]; + // app/feed/[slug]/page.tsx and app/feed/[slug]/@modal/(..)[slug]/page.tsx, + // which intercepts /feed/[slug] from itself. Next.js's intercepting tree + // has app/feed/[slug]/default.tsx where vinext renders the route's page. + function resolveDynamicConfigs(page: object, interceptPage: object) { + const route = { + childrenSlot: { ownerTreePath: "/feed/[slug]", state: "active" }, + isDynamic: true, + layouts: [{}], + layoutTreePositions: [0], + page, + patternParts: ["feed", ":slug"], + routeSegments: ["feed", "[slug]"], + slots: { + "modal@app/feed/[slug]/@modal": { + default: {}, + layout: null, + name: "modal", + ownerTreePosition: 2, + page: null, + routeSegments: [], + }, + }, + }; + const intercept = { + interceptBranchSegments: ["(..)[slug]"], + interceptOwnerDefault: {}, + interceptPage, + interceptSlotKey: "modal@app/feed/[slug]/@modal", + }; + return { + rendered: resolveInterceptSegmentConfig(route, intercept).dynamicConfig, + route: resolveInterceptTreeDynamicConfig(route, intercept), + }; + } + const forceDynamic = { dynamic: "force-dynamic" }; + + // The route's force-dynamic page makes the render dynamic, not the + // intercepting route, whose manifest entry keeps its params gate. + expect(resolveDynamicConfigs(forceDynamic, { dynamicParams: false })).toEqual({ + rendered: "force-dynamic", + route: null, + }); + expect(resolveDynamicConfigs({}, forceDynamic)).toEqual({ + rendered: "force-dynamic", + route: "force-dynamic", + }); + }); + it("generated handler delegates request and ctx handling to createAppRscHandler", () => { const code = generateRscEntry("/tmp/test/app", minimalRoutes); expect(code).toContain("createAppRscHandler"); @@ -209,6 +653,145 @@ describe("generateRscEntry ISR code generation", () => { expect(code).not.toMatch(/__loadInterceptLayouts:\s*\[\s*"\/tmp\/test\/app/); }); + it("generated code loads the owner's default only with a slot intercept", async () => { + const appDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-owner-default-")); + try { + for (const file of [ + "layout.tsx", + "feed/page.tsx", + "feed/default.tsx", + "feed/@modal/default.tsx", + "feed/@modal/(.)photos/[id]/page.tsx", + "feed/@sidebar/page.tsx", + "feed/@sidebar/default.tsx", + "photos/[id]/page.tsx", + ]) { + fs.mkdirSync(path.dirname(path.join(appDir, file)), { recursive: true }); + fs.writeFileSync( + path.join(appDir, file), + "export default function Page() { return null; }\n", + ); + } + invalidateAppRouteCache(); + const routes = await appRouter(appDir); + const code = generateRscEntry(appDir, routes); + + const feedEntry = code.slice(code.indexOf('pattern: "/feed"')); + const slotEntry = (slotKey: string) => { + const start = feedEntry.indexOf(`${JSON.stringify(slotKey)}: {`); + return feedEntry + .slice(start, feedEntry.indexOf("\n ],", start)) + .split("intercepts: ["); + }; + // @modal intercepts, so app/feed/default.tsx replaces its children in + // the intercepting route's tree. Its loader sits on the intercept, which + // hydrates only once matched, not with the slot's modules on every /feed + // request. @sidebar doesn't intercept, so nothing loads it at all. + const [modalFields, modalIntercepts] = slotEntry("modal@feed/@modal"); + expect(modalFields).not.toContain("__loadOwnerDefault"); + const ownerDefaultLoader = modalIntercepts.match(/__loadOwnerDefault: (\w+),/)?.[1]; + expect(code).toContain( + `const ${ownerDefaultLoader} = () => import(${JSON.stringify(path.join(appDir, "feed/default.tsx"))});`, + ); + expect(slotEntry("sidebar@feed/@sidebar").join("")).not.toContain("__loadOwnerDefault"); + } finally { + invalidateAppRouteCache(); + fs.rmSync(appDir, { recursive: true, force: true }); + } + }); + + it("generated code carries a slot intercept's layouts above the marker", async () => { + const appDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-intercept-ancestors-")); + try { + for (const file of [ + "layout.tsx", + "gallery/page.tsx", + "gallery/photo/page.tsx", + "@modal/default.tsx", + "@modal/gallery/layout.tsx", + "@modal/gallery/(.)photo/layout.tsx", + "@modal/gallery/(.)photo/page.tsx", + ]) { + fs.mkdirSync(path.dirname(path.join(appDir, file)), { recursive: true }); + fs.writeFileSync( + path.join(appDir, file), + "export default function Page() { return null; }\n", + ); + } + invalidateAppRouteCache(); + const routes = await appRouter(appDir); + const code = generateRscEntry(appDir, routes); + + // app/@modal/gallery/layout.tsx sits above the (.)photo marker: Next.js's + // intercepting route tree holds it at the slot's first folder, outside + // the marker's own layout, so the intercept's layout chain starts with it. + const galleryEntry = code.slice(code.indexOf('pattern: "/gallery"')); + const intercept = galleryEntry.slice(galleryEntry.indexOf("interceptLayouts:")); + expect(intercept).toMatch(/^interceptLayouts: \[null, null\],/); + expect(intercept).toContain('interceptLayoutSegments: [["gallery"],["gallery","photo"]],'); + const layoutLoaders = intercept.match(/__loadInterceptLayouts: \[(\w+), (\w+)\],/); + const lazyImports = new Map( + Array.from(code.matchAll(/^const (\w+) = \(\) => import\(("[^"\n]*")\);$/gm), (match) => [ + match[1], + JSON.parse(match[2]) as string, + ]), + ); + expect(lazyImports.get(layoutLoaders?.[1] ?? "")).toBe( + toSlash(path.join(appDir, "@modal/gallery/layout.tsx")), + ); + expect(lazyImports.get(layoutLoaders?.[2] ?? "")).toBe( + toSlash(path.join(appDir, "@modal/gallery/(.)photo/layout.tsx")), + ); + } finally { + invalidateAppRouteCache(); + fs.rmSync(appDir, { recursive: true, force: true }); + } + }); + + it("generated code carries a sibling intercept's layouts between its source and the marker", async () => { + const appDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-sibling-intercept-ancestors-")); + try { + for (const file of [ + "layout.tsx", + "feed/page.tsx", + "photo/page.tsx", + "feed/(shell)/layout.tsx", + "feed/(shell)/(.)photo/page.tsx", + ]) { + fs.mkdirSync(path.dirname(path.join(appDir, file)), { recursive: true }); + fs.writeFileSync( + path.join(appDir, file), + "export default function Page() { return null; }\n", + ); + } + invalidateAppRouteCache(); + const routes = await appRouter(appDir); + const code = generateRscEntry(appDir, routes); + + // app/feed/(shell)/layout.tsx sits between the source page's folder and + // the (.)photo marker, so Next.js's intercepting route tree holds it and + // a force-dynamic export there makes the intercepted response dynamic. + const feedEntry = code.slice(code.indexOf('pattern: "/feed"')); + const intercept = feedEntry.slice(feedEntry.indexOf("interceptLayouts:")); + expect(intercept).toMatch(/^interceptLayouts: \[null\],/); + expect(intercept).toContain('interceptLayoutSegments: [["(shell)"]],'); + expect(intercept).toContain('interceptBranchSegments: ["(shell)","photo"],'); + const layoutLoader = intercept.match(/__loadInterceptLayouts: \[(\w+)\],/); + const lazyImports = new Map( + Array.from(code.matchAll(/^const (\w+) = \(\) => import\(("[^"\n]*")\);$/gm), (match) => [ + match[1], + JSON.parse(match[2]) as string, + ]), + ); + expect(lazyImports.get(layoutLoader?.[1] ?? "")).toBe( + toSlash(path.join(appDir, "feed/(shell)/layout.tsx")), + ); + } finally { + invalidateAppRouteCache(); + fs.rmSync(appDir, { recursive: true, force: true }); + } + }); + it("generated code seeds root params around prerender generateStaticParams", () => { const routeWithRootParams: AppRoute = { errorPath: null, diff --git a/tests/app-segment-config.test.ts b/tests/app-segment-config.test.ts index 6a84820a56..f241991859 100644 --- a/tests/app-segment-config.test.ts +++ b/tests/app-segment-config.test.ts @@ -2,12 +2,16 @@ import { describe, expect, it } from "vite-plus/test"; import { collectAppPageStaticGenerationRuntimes, collectAppPageStaticParamsWalkSegments, + hasAppPageAnyGenerateStaticParams, hasAppPageGenerateStaticParamsAtLastDynamicSegment, + isAppPageInterceptAttached, isAppPageStaticEligible, isEdgeRuntime, lastDynamicSegmentHasGenerateStaticParams, resolveAppPageDynamicConfig, resolveAppPageFetchCacheMode, + resolveAppPageInterceptSegmentConfig, + resolveAppPageInterceptTree, resolveAppPageSegmentConfig, resolveAppPageStaticGenerationRuntime, resolveAppRouteHandlerFetchCacheMode, @@ -1034,3 +1038,752 @@ describe("isAppPageStaticEligible", () => { } }); }); + +describe("resolveAppPageInterceptTree", () => { + // app/layout.tsx, app/feed/layout.tsx, app/feed/page.tsx and + // app/feed/@modal/default.tsx. + const layouts = [{}, {}]; + const layoutTreePositions = [0, 1]; + const routeSegments = ["feed"]; + const modalDefault = { + isDefault: true, + layout: null, + name: "modal", + ownerTreePosition: 1, + page: {}, + }; + + function classify( + interceptPage: Record, + options: { + isDynamicRoute?: boolean; + interceptOwnerDefault?: Record; + siblingBranches?: Parameters[0]["parallelBranches"]; + sourcePage?: Record; + slotIndex?: number; + } = {}, + ) { + // app/feed/@modal/(.)photos/[id]/page.tsx, or app/feed/(.)photos/[id]/ + // page.tsx for a sibling-page intercept. Like the generated entry, both + // Next.js's tree and the one vinext renders must be static. + return [false, true].every((keepActiveSiblings) => { + const tree = resolveAppPageInterceptTree({ + childrenSlot: { ownerTreePath: "/feed", state: "active" }, + interceptBranchSegments: ["(.)photos", "[id]"], + interceptLayouts: [], + interceptLayoutSegments: [], + interceptOwnerDefault: options.interceptOwnerDefault, + interceptPage, + keepActiveSiblings, + layouts, + layoutTreePositions, + page: options.sourcePage ?? {}, + parallelBranches: [modalDefault, ...(options.siblingBranches ?? [])], + routeSegments, + isSiblingPageIntercept: options.slotIndex === -1, + slotIndex: options.slotIndex ?? 0, + }); + const config = resolveAppPageSegmentConfig(tree); + return isAppPageStaticEligible({ + dynamicConfig: config.dynamicConfig, + hasGenerateStaticParams: hasAppPageGenerateStaticParamsAtLastDynamicSegment(tree), + isDynamicRoute: options.isDynamicRoute ?? false, + isStaticGenerationEdgeRuntime: isEdgeRuntime( + resolveAppPageStaticGenerationRuntime(collectAppPageStaticGenerationRuntimes(tree)) as + | string + | undefined, + ), + revalidateSeconds: config.revalidateSeconds, + }); + }); + } + + it("puts the intercepting branch in place of the intercepted slot's", () => { + const interceptPage = { dynamic: "force-dynamic" }; + const tree = resolveAppPageInterceptTree({ + interceptBranchSegments: ["(.)photos", "[id]"], + interceptLayouts: [{ revalidate: 60 }], + interceptLayoutSegments: [["(.)photos"]], + interceptPage, + layouts, + layoutTreePositions, + page: {}, + parallelBranches: [modalDefault], + routeSegments, + isSiblingPageIntercept: false, + slotIndex: 0, + }); + expect(tree.parallelBranches).toEqual([ + { + configLayouts: [{ revalidate: 60 }], + configLayoutTreePositions: [1], + isDefault: false, + layout: null, + name: "modal", + ownerTreePosition: 1, + page: interceptPage, + routeSegments: ["(.)photos", "[id]"], + }, + ]); + }); + + it("keeps the slot's layouts above the intercept marker in its branch", () => { + // app/layout.tsx, app/gallery/page.tsx, app/@modal/default.tsx, + // app/@modal/gallery/layout.tsx (force-dynamic), app/@modal/gallery/ + // (.)photo/layout.tsx and app/@modal/gallery/(.)photo/page.tsx. Next.js's + // intercepting route tree holds the @modal/gallery folder, and its layout, + // above the (.)photo branch. + const galleryLayout = { dynamic: "force-dynamic" }; + const photoLayout = {}; + const interceptPage = {}; + const source = { + childrenSlot: { ownerTreePath: "/gallery", state: "active" }, + interceptBranchSegments: ["gallery", "(.)photo"], + interceptLayouts: [galleryLayout, photoLayout], + interceptLayoutSegments: [["gallery"], ["gallery", "(.)photo"]], + interceptPage, + isSiblingPageIntercept: false, + layouts: [{}], + layoutTreePositions: [0], + page: {}, + parallelBranches: [{ ...modalDefault, ownerTreePosition: 0 }], + routeSegments: ["gallery"], + slotIndex: 0, + } as const; + + for (const keepActiveSiblings of [false, true]) { + const tree = resolveAppPageInterceptTree({ ...source, keepActiveSiblings }); + expect(tree.parallelBranches?.[0]).toEqual({ + configLayouts: [galleryLayout, photoLayout], + configLayoutTreePositions: [1, 2], + isDefault: false, + layout: null, + name: "modal", + ownerTreePosition: 0, + page: interceptPage, + routeSegments: ["gallery", "(.)photo"], + }); + expect(collectAppPageStaticParamsWalkSegments(tree)).toContainEqual({ + dynamic: false, + generateStaticParams: false, + identity: ["gallery", galleryLayout], + treePath: [1, 0], + }); + expect(resolveAppPageSegmentConfig(tree).dynamicConfig).toBe("force-dynamic"); + } + }); + + it("puts a sibling-page intercept in place of the source's page", () => { + const interceptLayout = {}; + const interceptPage = {}; + const tree = resolveAppPageInterceptTree({ + childrenSlot: { ownerTreePath: "/feed", state: "active" }, + interceptBranchSegments: ["(.)photos", "[id]"], + interceptLayouts: [interceptLayout], + interceptLayoutSegments: [["(.)photos"]], + interceptPage, + layouts, + layoutTreePositions, + page: { dynamic: "force-static" }, + parallelBranches: [modalDefault], + routeSegments, + isSiblingPageIntercept: true, + slotIndex: -1, + }); + expect(tree).toEqual({ + childrenSlot: null, + layoutTreePositions: [0, 1, 2], + layouts: [{}, {}, interceptLayout], + page: interceptPage, + parallelBranches: [ + { + configLayouts: [], + configLayoutTreePositions: [], + isDefault: true, + layout: null, + name: "modal", + ownerTreePosition: 1, + page: modalDefault.page, + routeSegments: [], + }, + ], + routeSegments: ["feed", "(.)photos", "[id]"], + }); + }); + + it("replaces active sibling slots at or above the intercept with their defaults", () => { + // For the source app/feed/nested/page.tsx, app/@global/feed/nested/page.tsx + // and app/feed/@sidebar/nested/page.tsx sit on the intercepting branch's + // path; app/feed/nested/@aside/page.tsx sits inside app/feed's children, + // which Next.js replaces with app/feed's default. + const globalDefault = { revalidate: 30 }; + const sidebarDefault = { runtime: "edge" }; + const aside = { + default: {}, + isDefault: false, + layout: {}, + name: "aside", + ownerTreePosition: 2, + page: { runtime: "nodejs" }, + routeSegments: [], + }; + const tree = resolveAppPageInterceptTree({ + interceptBranchSegments: ["(.)photos", "[id]"], + interceptPage: {}, + isSiblingPageIntercept: false, + layouts: [{}, {}, {}], + layoutTreePositions: [0, 1, 2], + page: {}, + parallelBranches: [ + { + configLayouts: [{}], + configLayoutTreePositions: [1], + default: globalDefault, + isDefault: false, + layout: { dynamic: "force-dynamic" }, + name: "global", + ownerTreePosition: 0, + page: {}, + routeSegments: ["feed", "nested"], + }, + { ...modalDefault, default: modalDefault.page }, + { + default: sidebarDefault, + isDefault: false, + layout: null, + name: "sidebar", + ownerTreePosition: 1, + page: { runtime: "nodejs" }, + routeSegments: ["nested"], + }, + aside, + ], + routeSegments: ["feed", "nested"], + slotIndex: 1, + }); + const leaf = { + configLayouts: [], + configLayoutTreePositions: [], + isDefault: true, + layout: null, + }; + expect(tree.parallelBranches).toEqual([ + { ...leaf, name: "global", ownerTreePosition: 0, page: globalDefault, routeSegments: [] }, + expect.objectContaining({ isDefault: false, name: "modal" }), + { ...leaf, name: "sidebar", ownerTreePosition: 1, page: sidebarDefault, routeSegments: [] }, + null, + ]); + }); + + it("replaces a slot intercept's children with the owner's default, but renders the source", () => { + // app/feed/nested/layout.tsx, app/feed/nested/page.tsx and + // app/feed/nested/@aside/page.tsx sit below app/feed, whose children + // Next.js replaces with app/feed/default.tsx as a __DEFAULT__ leaf. + const feedDefault = { revalidate: 30 }; + const sourcePage = { dynamic: "force-static" }; + const nestedLayout = { dynamic: "force-static" }; + const aside = { + default: {}, + isDefault: false, + layout: null, + name: "aside", + ownerTreePosition: 2, + page: {}, + routeSegments: [], + }; + const source = { + childrenSlot: { ownerTreePath: "/feed/nested", state: "active" }, + interceptBranchSegments: ["(.)photos", "[id]"], + interceptOwnerDefault: feedDefault, + interceptPage: {}, + isSiblingPageIntercept: false, + layouts: [layouts[0], layouts[1], nestedLayout], + layoutTreePositions: [0, 1, 2], + page: sourcePage, + parallelBranches: [modalDefault, aside], + routeSegments: ["feed", "nested"], + slotIndex: 0, + } as const; + const intercept = expect.objectContaining({ isDefault: false, name: "modal" }); + + const nextTree = resolveAppPageInterceptTree(source); + expect(nextTree).toEqual({ + childrenSlot: { ownerTreePath: "/feed", state: "default" }, + layoutTreePositions: [0, 1], + layouts: [layouts[0], layouts[1]], + page: feedDefault, + parallelBranches: [intercept, null], + routeSegments: ["feed"], + }); + expect(collectAppPageStaticParamsWalkSegments(nextTree)).toContainEqual({ + dynamic: false, + generateStaticParams: false, + identity: ["__DEFAULT__", feedDefault], + treePath: [0, 0], + }); + + const renderedTree = resolveAppPageInterceptTree({ ...source, keepActiveSiblings: true }); + expect(renderedTree).toEqual({ + childrenSlot: source.childrenSlot, + layoutTreePositions: source.layoutTreePositions, + layouts: source.layouts, + page: sourcePage, + parallelBranches: [intercept, aside], + routeSegments: source.routeSegments, + }); + }); + + it("drops a force-static source page from a slot intercept's tree", () => { + // app/feed/page.tsx sets dynamic = "force-static"; the intercepting + // app/feed/@modal/(.)photos/[id]/page.tsx has no generateStaticParams, + // and app/feed has no default.tsx, so its children are default-null. + const sourcePage = { dynamic: "force-static" }; + expect(classify({}, { isDynamicRoute: true, sourcePage })).toBe(false); + // A force-static app/feed/default.tsx takes the source page's place. + expect( + classify( + {}, + { interceptOwnerDefault: { dynamic: "force-static" }, isDynamicRoute: true, sourcePage }, + ), + ).toBe(true); + }); + + it("classifies a slot intercept with an active sibling's edge default as edge", () => { + // app/feed/@sidebar/page.tsx is Node; app/feed/@sidebar/default.tsx is edge. + const sidebar = { + default: { runtime: "edge" }, + isDefault: false, + layout: null, + name: "sidebar", + ownerTreePosition: 1, + page: { runtime: "nodejs" }, + routeSegments: [], + }; + expect(classify({}, { siblingBranches: [sidebar] })).toBe(false); + expect(classify({}, { siblingBranches: [{ ...sidebar, default: {} }] })).toBe(true); + }); + + it("keeps a slot intercept's rendered force-dynamic sibling page dynamic", () => { + // app/feed/@sidebar/page.tsx is force-dynamic and still renders beside + // the intercept; app/feed/@sidebar/default.tsx is static. + const sidebar = { + default: {}, + isDefault: false, + layout: null, + name: "sidebar", + ownerTreePosition: 1, + page: { dynamic: "force-dynamic" }, + routeSegments: [], + }; + expect(classify({}, { siblingBranches: [sidebar] })).toBe(false); + expect(classify({}, { siblingBranches: [sidebar], slotIndex: -1 })).toBe(false); + }); + + it("keeps an active sibling slot's page when resolving the tree vinext renders", () => { + const sidebar = { + default: {}, + isDefault: false, + layout: null, + name: "sidebar", + ownerTreePosition: 1, + page: { dynamic: "force-dynamic" }, + routeSegments: [], + }; + const tree = resolveAppPageInterceptTree({ + interceptBranchSegments: ["(.)photos", "[id]"], + interceptPage: {}, + isSiblingPageIntercept: false, + keepActiveSiblings: true, + layouts, + layoutTreePositions, + page: {}, + parallelBranches: [modalDefault, sidebar], + routeSegments, + slotIndex: 0, + }); + expect(tree.parallelBranches?.[1]).toBe(sidebar); + }); + + it("drops an active sibling slot's config from a sibling-page intercept's tree", () => { + // app/feed/@sidebar/page.tsx sets dynamic = "force-static"; the + // intercepting app/feed/(.)photos/[id]/page.tsx has no generateStaticParams. + const sidebar = { + default: {}, + isDefault: false, + layout: null, + name: "sidebar", + ownerTreePosition: 1, + page: { dynamic: "force-static" }, + routeSegments: [], + }; + expect(classify({}, { isDynamicRoute: true, siblingBranches: [sidebar], slotIndex: -1 })).toBe( + false, + ); + }); + + it("keeps the source's tree when the source route lacks the intercepted slot", () => { + // A route-group variant of app/feed matched as the source has no @modal, + // so the intercepting page doesn't render and the dynamic source page does. + const sourcePage = { dynamic: "force-dynamic" }; + const tree = resolveAppPageInterceptTree({ + interceptBranchSegments: ["(.)photos", "[id]"], + interceptPage: {}, + isSiblingPageIntercept: false, + layouts, + layoutTreePositions, + page: sourcePage, + parallelBranches: [], + routeSegments, + slotIndex: -1, + }); + expect(tree.page).toBe(sourcePage); + expect(tree.routeSegments).toEqual(routeSegments); + expect(resolveAppPageSegmentConfig(tree).dynamicConfig).toBe("force-dynamic"); + }); + + it("attaches a slot intercept only to a source that has the slot", () => { + const source = { layouts, layoutTreePositions, page: {}, routeSegments }; + expect( + isAppPageInterceptAttached({ + ...source, + isSiblingPageIntercept: false, + parallelBranches: [modalDefault], + slotIndex: 0, + }), + ).toBe(true); + expect( + isAppPageInterceptAttached({ + ...source, + isSiblingPageIntercept: false, + parallelBranches: [], + slotIndex: -1, + }), + ).toBe(false); + // A sibling-page intercept replaces the source's page, whatever its slots. + expect( + isAppPageInterceptAttached({ + ...source, + isSiblingPageIntercept: true, + parallelBranches: [], + slotIndex: -1, + }), + ).toBe(true); + }); + + it("keeps a static intercepting branch static", () => { + expect(classify({})).toBe(true); + }); + + it("makes the tree dynamic when the intercepting page is force-dynamic", () => { + expect(classify({ dynamic: "force-dynamic" })).toBe(false); + }); + + it("makes the tree dynamic when the intercepting page sets revalidate = 0", () => { + expect(classify({ revalidate: 0 })).toBe(false); + }); + + it("disables static generation when the intercepting page is edge", () => { + expect(classify({ runtime: "edge" })).toBe(false); + }); + + it("needs generateStaticParams on the intercepting branch of a dynamic intercepted route", () => { + expect(classify({}, { isDynamicRoute: true })).toBe(false); + expect(classify({ generateStaticParams: () => [] }, { isDynamicRoute: true })).toBe(true); + }); + + it("drops a force-static source page from a sibling-page intercept's tree", () => { + // app/feed/page.tsx sets dynamic = "force-static"; the intercepting + // app/feed/(.)photos/[id]/page.tsx has no generateStaticParams. + expect( + classify( + {}, + { + isDynamicRoute: true, + slotIndex: -1, + sourcePage: { dynamic: "force-static" }, + }, + ), + ).toBe(false); + }); + + it("drops a source page's generateStaticParams from a sibling-page intercept's tree", () => { + // app/u/[user]/page.tsx exports generateStaticParams; the intercepting + // app/u/[user]/(.)settings/page.tsx doesn't, so [user] has none left. + const tree = resolveAppPageInterceptTree({ + childrenSlot: { ownerTreePath: "/u/[user]", state: "active" }, + interceptBranchSegments: ["(.)settings"], + interceptPage: {}, + layouts: [{}], + layoutTreePositions: [0], + page: { generateStaticParams: () => [] }, + parallelBranches: [], + routeSegments: ["u", "[user]"], + isSiblingPageIntercept: true, + slotIndex: -1, + }); + expect(hasAppPageGenerateStaticParamsAtLastDynamicSegment(tree)).toBe(false); + }); + + it("counts a marker-prefixed intercepting folder as a dynamic segment", () => { + // app/[user]/layout.tsx exports generateStaticParams; the intercepting + // app/[user]/feed/@modal/(.)[photo]/page.tsx, or the sibling-page + // app/[user]/feed/(..)(..)[photo]/page.tsx, doesn't, so [photo] has none. + const source = { + childrenSlot: { ownerTreePath: "/[user]/feed", state: "active" }, + interceptPage: {}, + layouts: [{}, { generateStaticParams: () => [] }], + layoutTreePositions: [0, 1], + page: {}, + routeSegments: ["[user]", "feed"], + } as const; + const slotTree = resolveAppPageInterceptTree({ + ...source, + interceptBranchSegments: ["(.)[photo]"], + isSiblingPageIntercept: false, + parallelBranches: [{ ...modalDefault, ownerTreePosition: 2 }], + slotIndex: 0, + }); + const siblingTree = resolveAppPageInterceptTree({ + ...source, + interceptBranchSegments: ["(..)(..)[photo]"], + isSiblingPageIntercept: true, + parallelBranches: [], + slotIndex: -1, + }); + expect(hasAppPageGenerateStaticParamsAtLastDynamicSegment(slotTree)).toBe(false); + expect(hasAppPageGenerateStaticParamsAtLastDynamicSegment(siblingTree)).toBe(false); + }); +}); + +describe("hasAppPageAnyGenerateStaticParams", () => { + const generator = () => []; + + it("reads a generator from any segment, not only at or below the last dynamic one", () => { + // app/[lang]/layout.tsx exports generateStaticParams; app/[lang]/[slug] + // has none. + const route = { + layouts: [{}, { generateStaticParams: generator }], + layoutTreePositions: [0, 1], + page: {}, + routeSegments: ["[lang]", "[slug]"], + }; + expect(hasAppPageAnyGenerateStaticParams(route)).toBe(true); + expect(hasAppPageGenerateStaticParamsAtLastDynamicSegment(route)).toBe(false); + expect(hasAppPageAnyGenerateStaticParams({ ...route, layouts: [{}, {}] })).toBe(false); + }); + + // app/layout.tsx, app/feed/layout.tsx, app/feed/page.tsx, + // app/feed/@modal/default.tsx, app/feed/@modal/gallery/layout.tsx, + // app/feed/@modal/gallery/(.)photo/page.tsx and app/feed/@sidebar/page.tsx. + function resolveInterceptTree( + modules: { + galleryLayout?: object; + interceptPage?: object; + rootLayout?: object; + sidebarPage?: object; + sourcePage?: object; + }, + keepActiveSiblings = false, + ) { + return resolveAppPageInterceptTree({ + childrenSlot: { ownerTreePath: "/feed", state: "active" }, + interceptBranchSegments: ["gallery", "(.)photo"], + interceptLayouts: [modules.galleryLayout ?? {}], + interceptLayoutSegments: [["gallery"]], + interceptPage: modules.interceptPage ?? {}, + isSiblingPageIntercept: false, + keepActiveSiblings, + layouts: [modules.rootLayout ?? {}, {}], + layoutTreePositions: [0, 1], + page: modules.sourcePage ?? {}, + parallelBranches: [ + { isDefault: true, layout: null, name: "modal", ownerTreePosition: 1, page: {} }, + { + default: {}, + isDefault: false, + layout: null, + name: "sidebar", + ownerTreePosition: 1, + page: modules.sidebarPage ?? {}, + routeSegments: [], + }, + ], + routeSegments: ["feed"], + slotIndex: 0, + }); + } + + it.each([ + ["a shared ancestor layout", { rootLayout: { generateStaticParams: generator } }], + ["the intercepting page", { interceptPage: { generateStaticParams: generator } }], + [ + "a slot layout above the intercept marker", + { galleryLayout: { generateStaticParams: generator } }, + ], + ])("reads a generator from %s of an intercepting route's tree", (_name, modules) => { + expect(hasAppPageAnyGenerateStaticParams(resolveInterceptTree(modules))).toBe(true); + expect(hasAppPageAnyGenerateStaticParams(resolveInterceptTree({}))).toBe(false); + }); + + it.each([ + ["the source page the intercept's children default replaces", "sourcePage"], + ["an active sibling page Next.js's tree has the default of", "sidebarPage"], + ])("does not read a generator from %s", (_name, module) => { + const modules = { [module]: { generateStaticParams: generator } }; + expect(hasAppPageAnyGenerateStaticParams(resolveInterceptTree(modules))).toBe(false); + // vinext still renders it, but the intercepting route's tree doesn't. + expect(hasAppPageAnyGenerateStaticParams(resolveInterceptTree(modules, true))).toBe(true); + }); +}); + +describe("resolveAppPageInterceptSegmentConfig", () => { + // app/layout.tsx, app/feed/layout.tsx, app/feed/page.tsx, the intercepting + // app/feed/@modal/(.)photos/[id]/page.tsx, and app/feed/@sidebar, whose + // page vinext renders beside the intercept where Next.js renders its default. + const modalDefault = { + isDefault: true, + layout: null, + name: "modal", + ownerTreePosition: 1, + page: {}, + }; + + function resolve( + interceptPage: Record, + sidebar: { default?: Record; page: Record }, + options: { isSiblingPageIntercept?: boolean; page?: Record } = {}, + ) { + const [interceptTree, renderedTree] = [false, true].map((keepActiveSiblings) => + resolveAppPageInterceptTree({ + interceptBranchSegments: ["(.)photos", "[id]"], + interceptPage, + isSiblingPageIntercept: options.isSiblingPageIntercept ?? false, + keepActiveSiblings, + layouts: [{}, {}], + layoutTreePositions: [0, 1], + page: options.page ?? {}, + parallelBranches: [ + modalDefault, + { + default: sidebar.default ?? {}, + isDefault: false, + layout: null, + name: "sidebar", + ownerTreePosition: 1, + page: sidebar.page, + routeSegments: [], + }, + ], + routeSegments: ["feed"], + slotIndex: options.isSiblingPageIntercept ? -1 : 0, + }), + ); + return resolveAppPageInterceptSegmentConfig(interceptTree, renderedTree); + } + + it("takes the shorter revalidate of an active sibling page", () => { + // app/feed/@sidebar/page.tsx sets revalidate = 10 beside an intercepting + // page at 30. + expect(resolve({ revalidate: 30 }, { page: { revalidate: 10 } }).revalidateSeconds).toBe(10); + expect( + resolve({ revalidate: 30 }, { page: { revalidate: 10 } }, { isSiblingPageIntercept: true }) + .revalidateSeconds, + ).toBe(10); + // A longer active sibling revalidate leaves the intercepting page's. + expect(resolve({ revalidate: 30 }, { page: { revalidate: 60 } }).revalidateSeconds).toBe(30); + }); + + it("keeps the shorter revalidate of the default Next.js renders instead", () => { + // app/feed/@sidebar/default.tsx sets revalidate = 5. + expect( + resolve({ revalidate: 30 }, { default: { revalidate: 5 }, page: {} }).revalidateSeconds, + ).toBe(5); + }); + + it("makes the render force-dynamic for a force-dynamic active sibling page", () => { + expect(resolve({ dynamic: "force-static" }, { page: { dynamic: "force-dynamic" } })).toEqual( + expect.objectContaining({ dynamicConfig: "force-dynamic", revalidateSeconds: 0 }), + ); + }); + + it("keeps the intercepting branch's dynamic mode over an active sibling's", () => { + expect(resolve({ dynamic: "force-static" }, { page: { dynamic: "error" } }).dynamicConfig).toBe( + "force-static", + ); + // With none of its own, the active sibling's applies. + expect(resolve({}, { page: { dynamic: "force-static" } }).dynamicConfig).toBe("force-static"); + }); + + it("takes an active sibling page's route-wide fetchCache mode", () => { + expect( + resolve({ fetchCache: "default-cache" }, { page: { fetchCache: "force-no-store" } }) + .fetchCache, + ).toBe("force-no-store"); + expect(resolve({}, { page: { fetchCache: "default-no-store" } }).fetchCache).toBe( + "default-no-store", + ); + expect(() => + resolve({ fetchCache: "force-cache" }, { page: { fetchCache: "force-no-store" } }), + ).toThrow(/incompatible fetchCache values/); + }); + + it("never reduces a slot's default together with the active page it replaces", () => { + // app/feed/@sidebar/default.tsx and app/feed/@sidebar/page.tsx never + // render together, so conflicting fetchCache modes don't throw. The + // no-store mode wins between the two trees. + expect( + resolve( + {}, + { default: { fetchCache: "force-cache" }, page: { fetchCache: "force-no-store" } }, + ).fetchCache, + ).toBe("force-no-store"); + expect( + resolve( + {}, + { default: { fetchCache: "only-no-store" }, page: { fetchCache: "only-cache" } }, + { isSiblingPageIntercept: true }, + ).fetchCache, + ).toBe("only-no-store"); + // A force mode of either tree overrides an only mode of the other. + expect( + resolve({}, { default: { fetchCache: "only-no-store" }, page: { fetchCache: "force-cache" } }) + .fetchCache, + ).toBe("force-cache"); + }); + + it("applies the dynamic = error fetchCache default to the merged dynamic mode only", () => { + // app/feed/page.tsx sets dynamic = "error", which only vinext's tree + // renders beside the force-static intercepting page, whose mode wins. + const merged = resolve( + { dynamic: "force-static" }, + { page: {} }, + { page: { dynamic: "error" } }, + ); + expect(merged.dynamicConfig).toBe("force-static"); + expect(merged).not.toHaveProperty("fetchCache"); + // With none of its own, app/feed/page.tsx's dynamic = "error" applies. + expect(resolve({}, { page: {} }, { page: { dynamic: "error" } }).fetchCache).toBe("only-cache"); + }); + + it("takes the shortest unstable_dynamicStaleTime of the pages either tree renders", () => { + const staleTime = (...args: Parameters) => + resolve(...args).dynamicStaleTimeSeconds; + // The intercepting page, in the modal slot or in place of app/feed/page.tsx. + expect(staleTime({ unstable_dynamicStaleTime: 30 }, { page: {} })).toBe(30); + expect( + staleTime({ unstable_dynamicStaleTime: 30 }, { page: {} }, { isSiblingPageIntercept: true }), + ).toBe(30); + // An active sibling page, or the default Next.js renders in its place. + expect( + staleTime({ unstable_dynamicStaleTime: 30 }, { page: { unstable_dynamicStaleTime: 10 } }), + ).toBe(10); + expect( + staleTime( + { unstable_dynamicStaleTime: 30 }, + { default: { unstable_dynamicStaleTime: 5 }, page: {} }, + { isSiblingPageIntercept: true }, + ), + ).toBe(5); + expect(staleTime({}, { page: {} })).toBeUndefined(); + }); +}); diff --git a/tests/app-server-action-execution.test.ts b/tests/app-server-action-execution.test.ts index 2bf306f5cc..1526586029 100644 --- a/tests/app-server-action-execution.test.ts +++ b/tests/app-server-action-execution.test.ts @@ -3500,6 +3500,90 @@ describe("app server action execution helpers", () => { revalidateSpy.mockRestore(); forceDynamicSpy.mockRestore(); }); + + it("resolves an intercepted rerender's config from the intercepting tree it renders", async () => { + const fetchCacheShims = await import("../packages/vinext/src/shims/fetch-cache.js"); + const modeSpy = vi.spyOn(fetchCacheShims, "setCurrentFetchCacheMode"); + const revalidateSpy = vi.spyOn(fetchCacheShims, "setCurrentFetchRevalidate"); + const forceDynamicSpy = vi.spyOn(fetchCacheShims, "setCurrentForceDynamicFetchDefault"); + // app/feed/@modal/(..)photos/[id] intercepting /photos/42 from app/feed. + const feedRoute: TestRoute = { id: "feed", page: {}, params: [], pattern: "/feed" }; + const photoRoute: TestRoute = { + id: "photo", + page: {}, + params: ["id"], + pattern: "/photos/:id", + }; + const modalPage = { default: "modal-photo" }; + const feedDefault = { default: "feed-default" }; + const isInterceptingTree = ( + route: TestRoute, + intercept?: { + interceptOwnerDefault?: unknown; + interceptPage?: unknown; + interceptSlotKey?: string; + interceptTargetPatternParts?: readonly string[] | null; + }, + ) => + route === feedRoute && + intercept?.interceptOwnerDefault === feedDefault && + intercept.interceptPage === modalPage && + intercept.interceptSlotKey === "modal" && + intercept.interceptTargetPatternParts?.join("/") === "photos/:id"; + + const response = await handleServerActionRscRequest( + createRscOptions({ + cleanPathname: "/photos/42", + currentRouteMatch: { params: { id: "42" }, route: photoRoute }, + currentRoutePathname: "/photos/42", + findIntercept() { + return { + matchedParams: { id: "42" }, + page: modalPage, + slotKey: "modal", + sourceMatchedParams: {}, + sourceRouteIndex: 0, + }; + }, + getSourceRoute() { + return feedRoute; + }, + loadServerAction() { + return Promise.resolve(async () => { + await Promise.resolve(revalidatePath("/photos/42")); + return "revalidated"; + }); + }, + resolveRouteFetchCacheMode(route, intercept) { + return isInterceptingTree(route, intercept) ? "force-no-store" : null; + }, + resolveRouteRevalidateSeconds(route, intercept) { + return isInterceptingTree(route, intercept) ? 45 : null; + }, + resolveRouteDynamicConfig(route, intercept) { + return isInterceptingTree(route, intercept) ? "force-dynamic" : null; + }, + toInterceptOpts(intercept) { + return { + interceptOwnerDefault: feedDefault, + interceptPage: intercept.page, + interceptSlotKey: intercept.slotKey, + interceptTargetPatternParts: ["photos", ":id"], + slot: intercept.slotKey, + } as TestInterceptOptions; + }, + }), + ); + + expect(response?.status).toBe(200); + expect(modeSpy).toHaveBeenCalledWith("force-no-store"); + expect(revalidateSpy).toHaveBeenCalledWith(45); + expect(forceDynamicSpy).toHaveBeenCalledWith(true); + + modeSpy.mockRestore(); + revalidateSpy.mockRestore(); + forceDynamicSpy.mockRestore(); + }); }); // The client-side counterpart of `createServerActionNotFoundResponse`: when the diff --git a/tests/entry-templates.test.ts b/tests/entry-templates.test.ts index 19b936ee97..171e1f990f 100644 --- a/tests/entry-templates.test.ts +++ b/tests/entry-templates.test.ts @@ -1331,7 +1331,7 @@ describe("App Router entry templates", () => { code.match( /findIntercept\(\s*interceptionPathname,\s*interceptionContext,\s*interceptionId,?\s*\)/g, ), - ).toHaveLength(3); + ).toHaveLength(4); expect(code).toContain("const route = routes[intercept.sourceRouteIndex]"); expect(code).toContain("intercept.sourceMatchedParams"); }); From 18f1d5464ee3c31e0c478004789ec70b764c4dfd Mon Sep 17 00:00:00 2001 From: James Date: Fri, 25 Sep 2026 22:35:30 +0100 Subject: [PATCH 2/7] fix(app-router): load a slot intercept's owner default for a Server Action rerender --- .../src/server/app-server-action-execution.ts | 4 ++++ tests/app-server-action-execution.test.ts | 17 ++++++++++++++--- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/packages/vinext/src/server/app-server-action-execution.ts b/packages/vinext/src/server/app-server-action-execution.ts index 3c9a6e7e12..ad1f4e3882 100644 --- a/packages/vinext/src/server/app-server-action-execution.ts +++ b/packages/vinext/src/server/app-server-action-execution.ts @@ -1874,6 +1874,10 @@ export async function handleServerActionRscRequest< getRouteParamNames: options.getRouteParamNames, getSourceRoute: options.getSourceRoute, isRscRequest: actionRerenderIsRscRequest, + // As in dispatch: an attached slot intercept loads the owner default + // its tree's config reads, and one the route has no slot for loads + // nothing. + routeHasSlot: (route, slotKey) => !!route.slots && Object.hasOwn(route.slots, slotKey), toInterceptOpts: options.toInterceptOpts, }); diff --git a/tests/app-server-action-execution.test.ts b/tests/app-server-action-execution.test.ts index 1526586029..c5ecea2265 100644 --- a/tests/app-server-action-execution.test.ts +++ b/tests/app-server-action-execution.test.ts @@ -56,6 +56,7 @@ type TestRoute = { routeHandler?: unknown; routeSegments?: readonly string[]; runtime?: "edge" | "experimental-edge" | "nodejs" | null; + slots?: Readonly>; /** Manifest lazy-module thunk; set when the route's page is not yet hydrated. */ __loadPage?: unknown; }; @@ -3506,8 +3507,15 @@ describe("app server action execution helpers", () => { const modeSpy = vi.spyOn(fetchCacheShims, "setCurrentFetchCacheMode"); const revalidateSpy = vi.spyOn(fetchCacheShims, "setCurrentFetchRevalidate"); const forceDynamicSpy = vi.spyOn(fetchCacheShims, "setCurrentForceDynamicFetchDefault"); - // app/feed/@modal/(..)photos/[id] intercepting /photos/42 from app/feed. - const feedRoute: TestRoute = { id: "feed", page: {}, params: [], pattern: "/feed" }; + // app/feed/@modal/(..)photos/[id] intercepting /photos/42 from app/feed, + // whose tree renders app/feed/default.tsx in place of app/feed's children. + const feedRoute: TestRoute = { + id: "feed", + page: {}, + params: [], + pattern: "/feed", + slots: { modal: {} }, + }; const photoRoute: TestRoute = { id: "photo", page: {}, @@ -3537,8 +3545,11 @@ describe("app server action execution helpers", () => { currentRouteMatch: { params: { id: "42" }, route: photoRoute }, currentRoutePathname: "/photos/42", findIntercept() { + // The owner default loads with the rest of the intercepting tree. return { + __loadOwnerDefault: async () => feedDefault, matchedParams: { id: "42" }, + ownerDefault: null, page: modalPage, slotKey: "modal", sourceMatchedParams: {}, @@ -3565,7 +3576,7 @@ describe("app server action execution helpers", () => { }, toInterceptOpts(intercept) { return { - interceptOwnerDefault: feedDefault, + interceptOwnerDefault: (intercept as { ownerDefault?: unknown }).ownerDefault, interceptPage: intercept.page, interceptSlotKey: intercept.slotKey, interceptTargetPatternParts: ["photos", ":id"], From b03eb34e4ab8fb2e6bd7a9377e786e50231b5d2b Mon Sep 17 00:00:00 2001 From: James Date: Fri, 25 Sep 2026 22:42:19 +0100 Subject: [PATCH 3/7] fix(app-router): probe an inherited slot of an intercepted source with its rematched params --- packages/vinext/src/entries/app-rsc-entry.ts | 8 ++++- packages/vinext/src/server/app-page-probe.ts | 9 +++++- tests/app-page-probe.test.ts | 31 ++++++++++++++++++++ tests/app-router-isr-codegen.test.ts | 3 ++ 4 files changed, 49 insertions(+), 2 deletions(-) diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index 6f4df9f85c..d85e319446 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -820,7 +820,10 @@ import { probeAppPageLayoutWithTracking as __probeAppPageLayoutWithTracking, resolveAppPageChildSegments as __resolveAppPageChildSegments, } from ${JSON.stringify(appPageRouteWiringPath)}; -import { buildPageElements as __buildPageElements } from ${JSON.stringify(appPageElementBuilderPath)}; +import { + buildPageElements as __buildPageElements, + resolveSlotParamOverrides as __resolveSlotParamOverrides, +} from ${JSON.stringify(appPageElementBuilderPath)}; import { buildAppPageInterceptSourceProbes as __buildAppPageInterceptSourceProbes, buildAppPageProbes as __buildAppPageProbes, @@ -1677,6 +1680,9 @@ ${responseStageOnly ? "const __responseStageOptions = {" : "const __appRscHandle pageComponent: sourceRoute.page?.default, intercept: __probeIntercept, sourceParams, + // The intercepted render matches inherited slots' params against + // the request path, as buildPageElements does. + slotParamOverrides: __resolveSlotParamOverrides(sourceRoute, cleanPathname), searchParams: sourceSearchParams, mountedSlotsHeader, renderMode, diff --git a/packages/vinext/src/server/app-page-probe.ts b/packages/vinext/src/server/app-page-probe.ts index fafc2babba..ce526e0e53 100644 --- a/packages/vinext/src/server/app-page-probe.ts +++ b/packages/vinext/src/server/app-page-probe.ts @@ -537,6 +537,11 @@ export function buildAppPageInterceptSourceProbes(options: { intercept?: AppPageProbeIntercept; /** The source route's params, which the intercepted render passes its tree. */ sourceParams: AppPageParams; + /** + * The params an inherited slot renders with in place of `sourceParams`, + * by slot key, as `resolveSlotParamOverrides` matches them for the request. + */ + slotParamOverrides?: Readonly> | null; searchParams: URLSearchParams | null | undefined; mountedSlotsHeader: string | null | undefined; renderMode: AppRscRenderMode | undefined; @@ -735,7 +740,9 @@ export function buildAppPageInterceptSourceProbes(options: { targetTreePosition, sourceParams, ); - const slotParams = isIntercepted ? (intercept?.matchedParams ?? sourceParams) : sourceParams; + const slotParams = isIntercepted + ? (intercept?.matchedParams ?? sourceParams) + : (options.slotParamOverrides?.[slotKey] ?? sourceParams); // The slot's own layout wraps its page, not its default. const slotLayouts: AppPageProbedLayout[] = pageComponent ? [{ layoutModule: slot.layout, params: slotOwnerParams, treePosition: 0 }] diff --git a/tests/app-page-probe.test.ts b/tests/app-page-probe.test.ts index 47a96450ff..ed63a8187b 100644 --- a/tests/app-page-probe.test.ts +++ b/tests/app-page-probe.test.ts @@ -1240,6 +1240,37 @@ describe("buildAppPageInterceptSourceProbes", () => { expect(consumeDynamicUsage()).toBe(false); }); + // app/[id]/@sidebar/[slug]/page.tsx inherited by app/[id]/page.tsx + it("passes an inherited slot the params it rematches for the request", async () => { + const received: Record[] = []; + await probeSource({ + route: { + layoutTreePositions: [0], + routeSegments: ["[id]"], + slots: { + sidebar: { + name: "sidebar", + page: { + default: async function SidebarPage(props: { + params: Promise>; + }) { + const params = await props.params; + received.push({ ...params }); + if (params.slug) markDynamicUsage(); + return null; + }, + }, + }, + }, + }, + sourceParams: { id: "1" }, + slotParamOverrides: { sidebar: { slug: "1" } }, + }); + + expect(received).toEqual([{ slug: "1" }]); + expect(consumeDynamicUsage()).toBe(true); + }); + it("probes a slot's default where route wiring renders it", async () => { const probed: string[] = []; const defaultOnly = (name: string, extra: Record = {}) => ({ diff --git a/tests/app-router-isr-codegen.test.ts b/tests/app-router-isr-codegen.test.ts index 4f99e97094..c32acfaf2e 100644 --- a/tests/app-router-isr-codegen.test.ts +++ b/tests/app-router-isr-codegen.test.ts @@ -185,6 +185,9 @@ describe("generateRscEntry ISR code generation", () => { pageComponent: sourceRoute.page?.default, intercept: __probeIntercept, sourceParams, + // The intercepted render matches inherited slots' params against + // the request path, as buildPageElements does. + slotParamOverrides: __resolveSlotParamOverrides(sourceRoute, cleanPathname), searchParams: sourceSearchParams, mountedSlotsHeader, renderMode, From 385a4697490eadf92dcc81206d6032bae33ed71e Mon Sep 17 00:00:00 2001 From: James Date: Fri, 25 Sep 2026 22:42:19 +0100 Subject: [PATCH 4/7] fix(app-router): probe the loading fallbacks an intercepted source renders --- packages/vinext/src/server/app-page-probe.ts | 16 +++- tests/app-page-probe.test.ts | 78 ++++++++++++++++++++ 2 files changed, 91 insertions(+), 3 deletions(-) diff --git a/packages/vinext/src/server/app-page-probe.ts b/packages/vinext/src/server/app-page-probe.ts index ce526e0e53..b267ca9c83 100644 --- a/packages/vinext/src/server/app-page-probe.ts +++ b/packages/vinext/src/server/app-page-probe.ts @@ -521,7 +521,8 @@ function ignoreProbeOutcome(): void {} * Probes what a direct intercepted RSC response renders ahead of its loading * boundaries, since it sets its headers before its render: the source route's * layouts and templates, its page (or a sibling-page intercept's layouts and - * page), and each parallel slot's layout chain and page or default, with the + * page), each parallel slot's layout chain and page or default, and the first + * loading component each of those branches renders as its fallback, with the * intercepting branch in the slot it intercepts. What renders follows route * wiring (app-page-route-wiring.tsx), including the render mode's prefetch * plan (a loading-shell prefetch's selected route and slot loading components @@ -592,7 +593,9 @@ export function buildAppPageInterceptSourceProbes(options: { const firstLoadingTreePosition = (entries: readonly AppPageLoadingEntry[]) => getFirstLoadingEntry(entries)?.treePosition ?? Infinity; // A loading-shell prefetch renders its selected loading components directly, - // in place of the page or slot page they'd wrap. + // in place of the page or slot page they'd wrap. Otherwise each branch's + // first loading component renders as its Suspense fallback, ahead of what + // that boundary wraps. const probeLoading = (entry: AppPageLoadingEntry | null) => { const LoadingComponent = entry?.loadingModule?.default; if (typeof LoadingComponent !== "function") return; @@ -632,7 +635,11 @@ export function buildAppPageInterceptSourceProbes(options: { routeLayouts.filter(({ treePosition }) => prefetchPlan.includesTreePosition(treePosition)), prefetchPlan.isPrefetchLoadingShell ? Infinity : routeLoadingTreePosition, ); - probeLoading(prefetchPlan.prefetchLoadingEntry); + probeLoading( + prefetchPlan.isPrefetchLoadingShell + ? prefetchPlan.prefetchLoadingEntry + : getFirstLoadingEntry(routeLoadingEntries), + ); // Every route loading boundary wraps the page, which a loading-shell // prefetch omits. @@ -663,6 +670,7 @@ export function buildAppPageInterceptSourceProbes(options: { }), firstLoadingTreePosition(interceptLoadingEntries), ); + probeLoading(getFirstLoadingEntry(interceptLoadingEntries)); if (interceptLoadingEntries.length === 0) { probe(() => probeAppPage({ @@ -733,6 +741,8 @@ export function buildAppPageInterceptSourceProbes(options: { // The route's loading UI, when the slot is owned at its cutoff, is // already probed above. if (!shellLoading.isOwnedAtRoutePrefetchCutoff) probeLoading(shellLoading.entry); + } else { + probeLoading(getFirstLoadingEntry(slotLoadingEntries)); } const slotOwnerParams = resolveAppPageSegmentParams( diff --git a/tests/app-page-probe.test.ts b/tests/app-page-probe.test.ts index ed63a8187b..83cd7087fc 100644 --- a/tests/app-page-probe.test.ts +++ b/tests/app-page-probe.test.ts @@ -1240,6 +1240,84 @@ describe("buildAppPageInterceptSourceProbes", () => { expect(consumeDynamicUsage()).toBe(false); }); + // app/feed/loading.tsx + it("probes the source's loading component, its fallback", async () => { + const probed: string[] = []; + await probeSource({ + route: { + layouts: [{ default: recording("feed", probed) }], + layoutTreePositions: [1], + loadings: [{ default: recording("feed-loading", probed, true) }], + loadingTreePositions: [1], + routeSegments: ["feed"], + }, + pageComponent: neverSettling("page", probed), + }); + + expect(probed.sort()).toEqual(["feed", "feed-loading"]); + expect(consumeDynamicUsage()).toBe(true); + }); + + // app/feed/@sidebar/loading.tsx and app/feed/@modal/(.)photos/loading.tsx + it("probes each slot's first loading component, its fallback", async () => { + const probed: string[] = []; + await probeSource({ + route: { + slots: { + modal: { layout: { default: recording("modal", probed) } }, + sidebar: { + layout: { default: recording("sidebar", probed) }, + loadings: [ + { default: recording("sidebar-loading", probed, true) }, + { default: recording("settings-loading", probed) }, + ], + loadingTreePositions: [0, 1], + page: { default: neverSettling("sidebar-page", probed) }, + }, + }, + }, + intercept: { + interceptBranchSegments: ["(.)photos"], + interceptLayouts: [{ default: recording("photos", probed) }], + interceptLayoutSegments: [["(.)photos"]], + interceptLoadings: [{ default: recording("photos-loading", probed) }], + interceptLoadingTreePositions: [1], + page: { default: neverSettling("intercept", probed) }, + slotKey: "modal", + }, + }); + + expect(probed.sort()).toEqual([ + "modal", + "photos", + "photos-loading", + "sidebar", + "sidebar-loading", + ]); + expect(consumeDynamicUsage()).toBe(true); + }); + + // app/feed/(..)photos/loading.tsx + it("probes a sibling-page intercept's loading component, its fallback", async () => { + const probed: string[] = []; + await probeSource({ + route: {}, + intercept: { + interceptBranchSegments: ["(..)photos", "[id]"], + interceptLayouts: [{ default: recording("photos", probed) }], + interceptLayoutSegments: [["(..)photos"]], + interceptLoadings: [{ default: recording("photos-loading", probed, true) }], + interceptLoadingTreePositions: [1], + page: { default: neverSettling("intercept", probed) }, + slotKey: SIBLING_PAGE_INTERCEPT_SLOT_KEY, + }, + pageComponent: neverSettling("page", probed), + }); + + expect(probed.sort()).toEqual(["photos", "photos-loading"]); + expect(consumeDynamicUsage()).toBe(true); + }); + // app/[id]/@sidebar/[slug]/page.tsx inherited by app/[id]/page.tsx it("passes an inherited slot the params it rematches for the request", async () => { const received: Record[] = []; From 450178bea5c3f5a422297e6a2b3057783711df43 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 25 Sep 2026 23:08:33 +0100 Subject: [PATCH 5/7] fix(app-router): load an intercept for a source probe only when the source has its slot --- packages/vinext/src/entries/app-rsc-entry.ts | 9 +++++---- tests/app-router-isr-codegen.test.ts | 5 +++++ 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index d85e319446..dc74a2d529 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -1664,10 +1664,11 @@ ${responseStageOnly ? "const __responseStageOptions = {" : "const __appRscHandle })); }, async probeInterceptSource(sourceRoute, sourceParams, sourceSearchParams) { - const __probeIntercept = findIntercept( - interceptionPathname, - interceptionContext, - interceptionId, + // A source without the intercept's slot renders unchanged, so the + // intercept's modules are neither loaded nor probed. + const __probeIntercept = __resolveAppPageProbeIntercept( + sourceRoute, + findIntercept(interceptionPathname, interceptionContext, interceptionId), ); if (__probeIntercept) { await Promise.all([ diff --git a/tests/app-router-isr-codegen.test.ts b/tests/app-router-isr-codegen.test.ts index c32acfaf2e..f28bf9e658 100644 --- a/tests/app-router-isr-codegen.test.ts +++ b/tests/app-router-isr-codegen.test.ts @@ -180,6 +180,11 @@ describe("generateRscEntry ISR code generation", () => { ); // Its headers precede its render, so the direct intercept probes run what // that render includes for the request's mounted slots and render mode. + // A source without the intercept's slot loads none of its modules. + expect(code).toContain(`const __probeIntercept = __resolveAppPageProbeIntercept( + sourceRoute, + findIntercept(interceptionPathname, interceptionContext, interceptionId), + );`); expect(code).toContain(`return Promise.all(__buildAppPageInterceptSourceProbes({ route: sourceRoute, pageComponent: sourceRoute.page?.default, From 2ed0ba7f228b819977c07cc23f54c000ca638c72 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 25 Sep 2026 23:08:35 +0100 Subject: [PATCH 6/7] fix(app-router): read an intercepting segment's param through its interception marker --- .../vinext/src/server/app-page-dispatch.ts | 7 ++++++- packages/vinext/src/server/app-page-params.ts | 13 ++++++++++++ .../vinext/src/server/app-page-request.ts | 14 +++---------- .../vinext/src/server/app-segment-config.ts | 12 +++-------- tests/app-page-dispatch.test.ts | 20 +++++++++++++++++++ 5 files changed, 45 insertions(+), 21 deletions(-) diff --git a/packages/vinext/src/server/app-page-dispatch.ts b/packages/vinext/src/server/app-page-dispatch.ts index 5a1d243f6a..466001e45f 100644 --- a/packages/vinext/src/server/app-page-dispatch.ts +++ b/packages/vinext/src/server/app-page-dispatch.ts @@ -90,7 +90,11 @@ import { type AppRscRenderMode, } from "./app-rsc-render-mode.js"; import { shouldServeStreamingMetadata } from "./streaming-metadata.js"; -import { createAppPageTreePath, getAppPageSegmentParamName } from "./app-page-params.js"; +import { + createAppPageTreePath, + getAppPageSegmentParamName, + stripAppPageInterceptionMarker, +} from "./app-page-params.js"; import { createAppPageRscErrorTracker, type AppPageSsrHandler } from "./app-page-stream.js"; import { VINEXT_INTERCEPTION_ID_HEADER, VINEXT_PRERENDER_SPECULATIVE_HEADER } from "./headers.js"; import type { ClientReuseManifestParseResult } from "./client-reuse-manifest.js"; @@ -1354,6 +1358,7 @@ async function dispatchAppPageInner( isDynamicRoute: route.isDynamic || hasAppPageInterceptDynamicSegment(interceptBranchSegments), optionalCatchAllParamNames: attachedCurrentRouteIntercept ? [...route.routeSegments, ...interceptBranchSegments] + .map(stripAppPageInterceptionMarker) .filter((segment) => segment.startsWith("[[...")) .flatMap((segment) => getAppPageSegmentParamName(segment) ?? []) : undefined, diff --git a/packages/vinext/src/server/app-page-params.ts b/packages/vinext/src/server/app-page-params.ts index fcb5c96101..da12f27d60 100644 --- a/packages/vinext/src/server/app-page-params.ts +++ b/packages/vinext/src/server/app-page-params.ts @@ -1,4 +1,17 @@ import type { AppPageParams } from "./app-page-boundary.js"; +import { APP_PAGE_INTERCEPTION_MARKER_TRAVERSALS } from "./app-page-interception-markers.js"; + +/** + * A loader-tree segment without its interception marker. An intercepting + * route's tree keeps its markers, and `(.)[photo]` names the `photo` param. + * https://github.com/vercel/next.js/blob/v16.2.7/packages/next/src/shared/lib/router/utils/get-segment-param.tsx + */ +export function stripAppPageInterceptionMarker(segment: string): string { + const marker = APP_PAGE_INTERCEPTION_MARKER_TRAVERSALS.find(({ prefix }) => + segment.startsWith(prefix), + ); + return marker ? segment.slice(marker.prefix.length) : segment; +} export function getAppPageSegmentParamName(segment: string): string | null { if (segment.startsWith("[[...") && segment.endsWith("]]") && segment.length > 7) { diff --git a/packages/vinext/src/server/app-page-request.ts b/packages/vinext/src/server/app-page-request.ts index 44e0810a15..60e4447185 100644 --- a/packages/vinext/src/server/app-page-request.ts +++ b/packages/vinext/src/server/app-page-request.ts @@ -1,7 +1,6 @@ import type { AppPageSpecialError } from "./app-page-execution.js"; import { runWithFetchDedupe } from "vinext/shims/fetch-cache"; -import { getAppPageSegmentParamName } from "./app-page-params.js"; -import { APP_PAGE_INTERCEPTION_MARKER_TRAVERSALS } from "./app-page-interception-markers.js"; +import { getAppPageSegmentParamName, stripAppPageInterceptionMarker } from "./app-page-params.js"; import { matchRoutePattern } from "../routing/route-pattern.js"; import { notFoundResponse } from "./http-error-responses.js"; import type { AppLayoutParamAccessTracker } from "./app-layout-param-observation.js"; @@ -271,16 +270,9 @@ function remapRouteParams( return params; } -/** - * The param a loader-tree segment names. An intercepting route's tree keeps - * its interception markers, and `(.)[photo]` names the `photo` param. - * https://github.com/vercel/next.js/blob/v16.2.7/packages/next/src/shared/lib/router/utils/get-segment-param.tsx - */ +/** The param a loader-tree segment names, interception marker included. */ function getGenerateStaticParamsSegmentParamName(segment: string): string | null { - const marker = APP_PAGE_INTERCEPTION_MARKER_TRAVERSALS.find(({ prefix }) => - segment.startsWith(prefix), - ); - return getAppPageSegmentParamName(marker ? segment.slice(marker.prefix.length) : segment); + return getAppPageSegmentParamName(stripAppPageInterceptionMarker(segment)); } function collectParentParamNames( diff --git a/packages/vinext/src/server/app-segment-config.ts b/packages/vinext/src/server/app-segment-config.ts index 687e4c186b..890aafb969 100644 --- a/packages/vinext/src/server/app-segment-config.ts +++ b/packages/vinext/src/server/app-segment-config.ts @@ -1,7 +1,6 @@ import type { FetchCacheMode } from "vinext/shims/fetch-cache"; -import { APP_PAGE_INTERCEPTION_MARKER_TRAVERSALS } from "./app-page-interception-markers.js"; import { isEdgeApiRuntime } from "./edge-api-runtime.js"; -import { getAppPageSegmentParamName } from "./app-page-params.js"; +import { getAppPageSegmentParamName, stripAppPageInterceptionMarker } from "./app-page-params.js"; type AppRouteSegmentDynamic = "auto" | "error" | "force-dynamic" | "force-static"; @@ -118,12 +117,7 @@ function resolveDynamicStaleTimeSeconds( } function isDynamicSegment(segment: string): boolean { - // An intercepting folder keeps its marker: `(.)[photo]` is the `photo` param. - // https://github.com/vercel/next.js/blob/v16.2.7/packages/next/src/shared/lib/router/utils/get-segment-param.tsx - const marker = APP_PAGE_INTERCEPTION_MARKER_TRAVERSALS.find(({ prefix }) => - segment.startsWith(prefix), - ); - const name = marker ? segment.slice(marker.prefix.length) : segment; + const name = stripAppPageInterceptionMarker(segment); return name.startsWith("[") && name.endsWith("]"); } @@ -570,7 +564,7 @@ export function hasAppPageInterceptDynamicSegment( interceptBranchSegments: readonly string[] | null | undefined, ): boolean { return (interceptBranchSegments ?? []).some( - (segment) => getAppPageSegmentParamName(segment) !== null, + (segment) => getAppPageSegmentParamName(stripAppPageInterceptionMarker(segment)) !== null, ); } diff --git a/tests/app-page-dispatch.test.ts b/tests/app-page-dispatch.test.ts index a691c0068a..c081d0409d 100644 --- a/tests/app-page-dispatch.test.ts +++ b/tests/app-page-dispatch.test.ts @@ -3952,6 +3952,26 @@ describe("app page dispatch", () => { await expect(empty.response.text()).resolves.toBe("fresh-flight"); }); + it("gates an intercepting tree's marker-prefixed dynamic segments", async () => { + // The route graph keeps the markers of app/feed/@modal/(.)[id] and + // app/feed/@modal/(.)[[...photo]]. + const dynamic = await dispatchStaticSourceIntercept( + ["(.)[id]"], + { id: "unknown" }, + async () => [{ id: "known" }], + ); + + expect(dynamic.response.status).toBe(404); + expect(dynamic.buildPageElement).not.toHaveBeenCalled(); + + const omitted = await dispatchStaticSourceIntercept(["(.)[[...photo]]"], {}, async () => [ + { photo: ["known"] }, + ]); + + expect(omitted.response.status).toBe(404); + expect(omitted.buildPageElement).not.toHaveBeenCalled(); + }); + it("renders a generated-params miss of an intercepting tree through that tree's not-found", async () => { // app/feed/[slug]/@modal/(..)[slug]/not-found.tsx is the intercepting // tree's own not-found boundary. From 8d36e01d75eba066dcbc497ccfa85ca5b645f80a Mon Sep 17 00:00:00 2001 From: James Date: Fri, 25 Sep 2026 23:08:35 +0100 Subject: [PATCH 7/7] fix(app-router): gate a prerendered source's intercept by the intercepting branch's params too --- .../vinext/src/server/app-page-dispatch.ts | 12 ++++--- tests/app-page-dispatch.test.ts | 32 +++++++++++++++++++ 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/packages/vinext/src/server/app-page-dispatch.ts b/packages/vinext/src/server/app-page-dispatch.ts index 466001e45f..c06f26fae4 100644 --- a/packages/vinext/src/server/app-page-dispatch.ts +++ b/packages/vinext/src/server/app-page-dispatch.ts @@ -1362,10 +1362,14 @@ async function dispatchAppPageInner( .filter((segment) => segment.startsWith("[[...")) .flatMap((segment) => getAppPageSegmentParamName(segment) ?? []) : undefined, - params: - options.staticParamsValidationParams ?? - attachedCurrentRouteIntercept?.matchedParams ?? - options.params, + // The trusted prerender values cover only the source route's params, so + // the intercepting branch's own matched params join them. + params: attachedCurrentRouteIntercept + ? { + ...attachedCurrentRouteIntercept.matchedParams, + ...options.staticParamsValidationParams, + } + : (options.staticParamsValidationParams ?? options.params), }); if (dynamicParamsResponse) { // A generated-param miss belongs to a matched App route, so render the diff --git a/tests/app-page-dispatch.test.ts b/tests/app-page-dispatch.test.ts index c081d0409d..e7a01ff97d 100644 --- a/tests/app-page-dispatch.test.ts +++ b/tests/app-page-dispatch.test.ts @@ -3886,6 +3886,38 @@ describe("app page dispatch", () => { expect(interceptGenerator).toHaveBeenCalledTimes(1); }); + it("checks the intercepting branch's params alongside a prerendered source's", async () => { + // A prerendered /feed/known renders app/feed/[slug]/@modal/(.)[photo], + // whose generator lists only the known photo. + const interceptGenerator = vi.fn(async () => [{ photo: "known", slug: "known" }]); + const interceptPage = { + default: "modal-page", + dynamicParams: false, + generateStaticParams: interceptGenerator, + }; + const dispatch = (photo: string) => { + const { options } = createGeneratedParamsDispatch({ + cleanPathname: "/feed/known", + findIntercept: () => ({ + interceptBranchSegments: ["(.)[photo]"], + matchedParams: { photo, slug: "known" }, + page: interceptPage, + slotKey: "modal@app/feed/[slug]/@modal", + sourceRouteIndex: 0, + }), + isrGet: vi.fn(async () => null), + params: { slug: "known" }, + resolveRouteGenerateStaticParams: () => [interceptGenerator], + }); + return dispatchAppPage({ ...options, staticParamsValidationParams: { slug: "known" } }); + }; + + expect((await dispatch("unknown")).status).toBe(404); + const known = await dispatch("known"); + expect(known.status).toBe(200); + await known.text(); + }); + // A cache miss of the static /feed rendering its @modal intercept with the // given branch, params and generators, under dynamicParams = false. async function dispatchStaticSourceIntercept(