Skip to content
2 changes: 1 addition & 1 deletion packages/vinext/src/build/layout-classification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
311 changes: 264 additions & 47 deletions packages/vinext/src/entries/app-rsc-entry.ts

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions packages/vinext/src/entries/app-rsc-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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)},
}`,
);
Expand Down
126 changes: 108 additions & 18 deletions packages/vinext/src/routing/app-route-graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[][];
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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,
Expand All @@ -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);
Expand All @@ -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);
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -2887,6 +2976,7 @@ function collectInterceptingPages(
layoutPaths,
loadingEntries,
treePositionOffset,
siblingSourceDir,
);
}
}
Expand Down
16 changes: 9 additions & 7 deletions packages/vinext/src/server/app-page-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -568,17 +569,18 @@ 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
: revalidatedPage.revalidateSeconds,
});
// 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 &&
Expand Down
Loading
Loading