Skip to content
Open
Show file tree
Hide file tree
Changes from 38 commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
7f4f8eb
Extract app page route wiring helpers
NathanDrake2406 Apr 2, 2026
5d8525b
Add slot client primitives
NathanDrake2406 Apr 2, 2026
be33773
Fix app page error boundary serialization
NathanDrake2406 Apr 2, 2026
ca40d05
Fix client error boundary pathname reset
NathanDrake2406 Apr 2, 2026
bddda39
Document Next.js error boundary verification
NathanDrake2406 Apr 2, 2026
38d33ca
Merge local PR 2a into PR 2c base
NathanDrake2406 Apr 2, 2026
8c22db3
Merge local PR 2b into PR 2c base
NathanDrake2406 Apr 2, 2026
d488978
Implement flat App Router payload for layout persistence
NathanDrake2406 Apr 2, 2026
ec008fa
fix: address review findings in flat payload implementation
NathanDrake2406 Apr 2, 2026
5395efc
fix: normalize flat payload after use(), not before
NathanDrake2406 Apr 2, 2026
955f577
fix: produce flat RSC payload on all rendering paths
NathanDrake2406 Apr 2, 2026
ce76239
test: update unit tests for flat RSC payload on all paths
NathanDrake2406 Apr 2, 2026
c7a03d5
fix: wrap Flight thenable in Promise.resolve() before chaining .then()
NathanDrake2406 Apr 2, 2026
7fead69
fix: eliminate Promise from ElementsContext to fix React 19 hydration
NathanDrake2406 Apr 2, 2026
311b10a
test: update slot and browser state tests for resolved ElementsContext
NathanDrake2406 Apr 2, 2026
2b5f68c
ci: retrigger
NathanDrake2406 Apr 2, 2026
7554b20
fix: address code review findings (P1-P3)
NathanDrake2406 Apr 2, 2026
1014aed
fix: avoid serializing app render dependency wrappers
NathanDrake2406 Apr 2, 2026
5e516bd
Fix flat payload dependency barriers
NathanDrake2406 Apr 2, 2026
ee2fbdd
Fix template-only route wrappers
NathanDrake2406 Apr 2, 2026
f8e2276
chore: trigger CI review
NathanDrake2406 Apr 2, 2026
c346485
fix: skip Slot wrapping for layout entries without a default export
NathanDrake2406 Apr 2, 2026
d2a4d13
Merge upstream/main into feat/layout-persistence-pr-2c
NathanDrake2406 Apr 4, 2026
9b9a6d5
fix: restore merged app router entry behavior
NathanDrake2406 Apr 4, 2026
f4949b4
fix: address app router review regressions
NathanDrake2406 Apr 4, 2026
c606e62
Merge upstream/main into feat/layout-persistence-pr-2c
NathanDrake2406 Apr 5, 2026
a220f3d
Fix app-page-request intercept tests
NathanDrake2406 Apr 5, 2026
9959097
Address PR 2c review follow-ups
NathanDrake2406 Apr 6, 2026
7c62628
Refactor same-url payload commits
NathanDrake2406 Apr 6, 2026
29ca8ea
Clarify PR 2c browser invariants
NathanDrake2406 Apr 7, 2026
a345706
Update App Router entry snapshots
NathanDrake2406 Apr 7, 2026
b529eb9
Tighten App Router review follow-ups
NathanDrake2406 Apr 7, 2026
e77d9a1
Align App Router entry helper usage
NathanDrake2406 Apr 7, 2026
6939d8b
Tighten browser commit invariants
NathanDrake2406 Apr 7, 2026
12014b1
fix(app-router): preserve scoped parallel slot identity
NathanDrake2406 Apr 8, 2026
c82366f
test(app-router): align slot identity assertions
NathanDrake2406 Apr 8, 2026
fc8b9bc
Merge upstream/main into feat/layout-persistence-pr-2c
NathanDrake2406 Apr 8, 2026
cd0fcbf
fix(app-router): keep boundary root layout metadata unknown
NathanDrake2406 Apr 9, 2026
7379142
docs(app-router): refresh stale review comments
NathanDrake2406 Apr 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 89 additions & 75 deletions packages/vinext/src/entries/app-rsc-entry.ts

Large diffs are not rendered by default.

85 changes: 38 additions & 47 deletions packages/vinext/src/routing/app-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ export type InterceptingRoute = {
};

export type ParallelSlot = {
/** Stable slot identity (name + owning directory), used for route serialization keys. */
key: string;
/** Slot name (e.g. "team" from @team) */
name: string;
/** Absolute path to the @slot directory that owns this slot. Internal routing metadata. */
Expand Down Expand Up @@ -77,8 +79,8 @@ export type AppRoute = {
routePath: string | null;
/** Ordered list of layout files from root to leaf */
layouts: string[];
/** Template files aligned with layouts array (null where no template exists at that level) */
templates: (string | null)[];
/** Ordered list of all discovered template files from root to leaf (not necessarily aligned 1:1 with layouts) */
templates: string[];
/** Parallel route slots (from @slot directories at the route's directory level) */
Comment on lines 80 to 84
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The templates field is documented as "parallel to layouts", but discoverTemplates() now collects templates independently of layouts (templates can exist at levels without a layout). This comment is misleading and makes it harder to reason about templateTreePositions/template wiring. Update the docstring to describe templates as an ordered list of all discovered templates from root to leaf, not necessarily aligned 1:1 with layouts.

Copilot uses AI. Check for mistakes.
parallelSlots: ParallelSlot[];
/** Loading component path */
Expand Down Expand Up @@ -112,6 +114,8 @@ export type AppRoute = {
* Used at render time to compute the child segments for useSelectedLayoutSegments().
*/
routeSegments: string[];
/** Tree position (directory depth from app/ root) for each template. */
templateTreePositions?: number[];
/**
* Tree position (directory depth from app/ root) for each layout.
* Used to slice routeSegments and determine which segments are below each layout.
Expand Down Expand Up @@ -181,9 +185,6 @@ export async function appRouter(
routes.push(...slotSubRoutes);

validateRoutePatterns(routes.map((route) => route.pattern));
// Deduplicate intercept target patterns: child routes inherit parent slots
// (including their intercepting routes), so the same target pattern can appear
// on both the parent and child route. Collect unique patterns only.
const interceptTargetPatterns = [
...new Set(
routes.flatMap((route) =>
Expand Down Expand Up @@ -226,15 +227,13 @@ function discoverSlotSubRoutes(
// Updated as new synthetic routes are pushed so that later parents can see earlier synthetic entries.
const routesByPattern = new Map<string, AppRoute>(routes.map((r) => [r.pattern, r]));

const slotKey = (slotName: string, ownerDir: string): string => `${slotName}\u0000${ownerDir}`;

const applySlotSubPages = (
route: AppRoute,
slotPages: Map<string, string>,
rawSegments: string[],
): void => {
route.parallelSlots = route.parallelSlots.map((slot) => {
const subPage = slotPages.get(slotKey(slot.name, slot.ownerDir));
const subPage = slotPages.get(slot.key);
if (subPage !== undefined) {
return { ...slot, pagePath: subPage, routeSegments: rawSegments };
}
Expand All @@ -258,17 +257,18 @@ function discoverSlotSubRoutes(
// that useSelectedLayoutSegments() sees the correct segment list at runtime.
rawSegments: string[];
// Pre-computed URL parts, params, isDynamic from convertSegmentsToRouteParts.
converted: {
urlSegments: string[];
params: string[];
isDynamic: boolean;
};
converted: { urlSegments: string[]; params: string[]; isDynamic: boolean };
slotPages: Map<string, string>;
}
>();

for (const slot of parentRoute.parallelSlots) {
const slotDir = path.join(parentPageDir, `@${slot.name}`);
// Only scan sub-pages from slots owned by this route directory.
// Inherited slots with the same name live in different owner dirs.
if (path.dirname(slot.ownerDir) !== parentPageDir) {
continue;
}
const slotDir = slot.ownerDir;
if (!fs.existsSync(slotDir)) continue;

const subPages = findSlotSubPages(slotDir, matcher);
Expand All @@ -290,16 +290,15 @@ function discoverSlotSubRoutes(
subPathMap.set(normalizedSubPath, subPathEntry);
}

const slotId = slotKey(slot.name, slot.ownerDir);
const existingSlotPage = subPathEntry.slotPages.get(slotId);
const existingSlotPage = subPathEntry.slotPages.get(slot.key);
if (existingSlotPage) {
const pattern = joinRoutePattern(parentRoute.pattern, normalizedSubPath);
throw new Error(
`You cannot have two routes that resolve to the same path ("${pattern}").`,
);
}

subPathEntry.slotPages.set(slotId, pagePath);
subPathEntry.slotPages.set(slot.key, pagePath);
}
}

Expand Down Expand Up @@ -333,7 +332,7 @@ function discoverSlotSubRoutes(
// Build parallel slots for this sub-route: matching slots get the sub-page,
// non-matching slots get null pagePath (rendering falls back to defaultPath)
const subSlots: ParallelSlot[] = parentRoute.parallelSlots.map((slot) => {
const subPage = slotPages.get(slotKey(slot.name, slot.ownerDir));
const subPage = slotPages.get(slot.key);
return {
...slot,
pagePath: subPage || null,
Expand All @@ -356,6 +355,7 @@ function discoverSlotSubRoutes(
forbiddenPath: parentRoute.forbiddenPath,
unauthorizedPath: parentRoute.unauthorizedPath,
routeSegments: [...parentRoute.routeSegments, ...rawSegments],
templateTreePositions: parentRoute.templateTreePositions,
layoutTreePositions: parentRoute.layoutTreePositions,
isDynamic: parentRoute.isDynamic || subIsDynamic,
params: [...parentRoute.params, ...subParams],
Expand Down Expand Up @@ -431,9 +431,10 @@ function fileToAppRoute(

const pattern = "/" + urlSegments.join("/");

// Discover layouts and layout-aligned templates from root to leaf
// Discover layouts and templates from root to leaf
const layouts = discoverLayouts(segments, appDir, matcher);
const templates = discoverLayoutAlignedTemplates(segments, appDir, matcher);
const templates = discoverTemplates(segments, appDir, matcher);
const templateTreePositions = computeLayoutTreePositions(appDir, templates);

// Compute the tree position (directory depth) for each layout.
const layoutTreePositions = computeLayoutTreePositions(appDir, layouts);
Expand Down Expand Up @@ -478,6 +479,7 @@ function fileToAppRoute(
forbiddenPath,
unauthorizedPath,
routeSegments: segments,
templateTreePositions,
layoutTreePositions,
isDynamic,
params,
Expand Down Expand Up @@ -522,37 +524,27 @@ function discoverLayouts(segments: string[], appDir: string, matcher: ValidFileM
}

/**
* Discover template files aligned with the layouts array.
* Walks the same directory levels as discoverLayouts and, for each level
* that contributes a layout entry, checks whether template.tsx also exists.
* Returns an array of the same length as discoverLayouts() would return,
* with the template path (or null) at each corresponding layout level.
*
* This enables interleaving templates with their corresponding layouts,
* matching Next.js behavior where each segment's hierarchy is
* Layout > Template > ErrorBoundary > children.
* Discover all template files from root to the given directory.
* Each level of the directory tree may have a template.tsx.
* Templates are like layouts but re-mount on navigation.
*/
function discoverLayoutAlignedTemplates(
function discoverTemplates(
segments: string[],
appDir: string,
matcher: ValidFileMatcher,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice simplification. The old discoverLayoutAlignedTemplates tied template discovery to layout presence (only checking for template.tsx at levels that had layout.tsx), which created the null-padded array. The new discoverTemplates independently discovers all templates and uses templateTreePositions for alignment.

This correctly handles the edge case where a template exists at a directory level without a layout — Next.js supports this (template.tsx without layout.tsx still re-mounts children on navigation).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clean improvement. The old discoverLayoutAlignedTemplates was coupled to layout discovery — it only checked for template.tsx at levels that had layout.tsx, producing null-padded arrays. The new discoverTemplates independently discovers all templates and uses templateTreePositions for alignment.

This correctly handles the edge case where a template exists without a layout at the same directory level — Next.js supports this (template.tsx re-mounts children on navigation regardless of layout presence). The old code would miss these templates entirely.

): (string | null)[] {
const templates: (string | null)[] = [];
): string[] {
const templates: string[] = [];

// Root level (only if root has a layout — matching discoverLayouts logic)
const rootLayout = findFile(appDir, "layout", matcher);
if (rootLayout) {
templates.push(findFile(appDir, "template", matcher));
}
// Check root template
const rootTemplate = findFile(appDir, "template", matcher);
if (rootTemplate) templates.push(rootTemplate);

// Check each directory level
let currentDir = appDir;
for (const segment of segments) {
currentDir = path.join(currentDir, segment);
const layout = findFile(currentDir, "layout", matcher);
if (layout) {
templates.push(findFile(currentDir, "template", matcher));
}
const template = findFile(currentDir, "template", matcher);
if (template) templates.push(template);
}

return templates;
Expand Down Expand Up @@ -687,20 +679,18 @@ function discoverInheritedParallelSlots(
if (isOwnDir) {
// At the route's own directory: use page.tsx (normal behavior)
slot.layoutIndex = lvlLayoutIdx;
slotMap.set(slot.name, slot);
slotMap.set(slot.key, slot);
} else {
// At an ancestor directory: use default.tsx as the page, not page.tsx
// (the slot's page.tsx is for the parent route, not this child route)
const inheritedSlot: ParallelSlot = {
...slot,
pagePath: null, // Don't use ancestor's page.tsx
layoutIndex: lvlLayoutIdx,
routeSegments: null, // Inherited slot shows default.tsx, not an active page
routeSegments: null,
// defaultPath, loadingPath, errorPath, interceptingRoutes remain
};
// Iteration goes root-to-leaf, so later (closer) ancestors overwrite
// earlier (farther) ones — the closest ancestor's slot wins.
slotMap.set(slot.name, inheritedSlot);
slotMap.set(slot.key, inheritedSlot);
}
}
}
Expand Down Expand Up @@ -736,6 +726,7 @@ function discoverParallelSlots(
if (!pagePath && !defaultPath && interceptingRoutes.length === 0) continue;

slots.push({
key: `${slotName}@${path.relative(appDir, slotDir).replace(/\\/g, "/")}`,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The slot key format modal@parent/@modal embeds the @ prefix of the slot directory into the path portion, making it look like there's a second slot name. It works correctly, but when reading code at the serialization sites (app-page-route-wiring.tsx:264, app-rsc-entry.ts slot entries) and the override lookup (resolveSlotOverride), the key structure isn't immediately obvious.

Consider adding a brief doc comment on the key field in the ParallelSlot type (line 40) with the format: // Format: "{slotName}@{relativePath}", e.g. "modal@parent/@modal". Not blocking.

name: slotName,
ownerDir: slotDir,
pagePath,
Expand All @@ -745,7 +736,7 @@ function discoverParallelSlots(
errorPath: findFile(slotDir, "error", matcher),
interceptingRoutes,
layoutIndex: -1, // Will be set by discoverInheritedParallelSlots
routeSegments: pagePath ? [] : null, // Root page = [], no page = null (default fallback)
routeSegments: pagePath ? [] : null,
});
}

Expand Down
Loading
Loading