Skip to content

Commit 0ca967b

Browse files
authored
Add group depth tracking to instant validation boundary discovery (#91208)
The depth-based instant validation previously only iterated URL-consuming segments as potential shared/new boundaries. Route groups between URL segments were invisible — when a route group layout with Suspense was shared in a client navigation, its Suspense appeared to cover blocking code in the validation render even though it wouldn't in reality. This adds a second dimension to the validation iteration: group depth. At each URL depth, we now also step through route group boundaries. A new `discoverValidationDepths` function walks the LoaderTree and returns an array where each entry represents the max group depth at that URL depth. The validation loop iterates from deepest group depth to shallowest within each URL depth, stopping on the first error. Key design decisions: - URL depth and group depth counters are advanced before the boundary check, making the boundary condition a simple `nextUrlDepth > depth && currentGroupDepth >= groupDepth` - The synthetic `(slot)` segment that Next.js inserts for parallel slots is excluded from group depth counting since it doesn't represent a real navigation boundary - Group depths are discovered from the LoaderTree rather than the URL pathname, making the tree the source of truth for validation bounds
1 parent a41bef9 commit 0ca967b

File tree

22 files changed

+640
-90
lines changed

22 files changed

+640
-90
lines changed

crates/next-core/src/app_structure.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1264,7 +1264,7 @@ async fn directory_tree_to_loader_tree_internal(
12641264
let current_level_is_parallel_route = is_parallel_route(&directory_name);
12651265

12661266
if current_level_is_parallel_route {
1267-
tree.segment = rcstr!("(slot)");
1267+
tree.segment = rcstr!("(__SLOT__)");
12681268
}
12691269

12701270
if let Some(page) = (app_path == for_app_path || app_path.is_catchall())

packages/next/src/build/webpack/loaders/next-app-loader/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -429,7 +429,7 @@ async function createTreeCodeFromPath(
429429
parallelSegmentKey =
430430
parallelSegmentKey === PARALLEL_VIRTUAL_SEGMENT ||
431431
parallelSegmentKey === PAGE_SEGMENT
432-
? '(slot)'
432+
? '(__SLOT__)'
433433
: parallelSegmentKey
434434

435435
const normalizedParallelKey = normalizeParallelKey(parallelKey)

packages/next/src/client/components/layout-router.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -877,9 +877,9 @@ function getBoundaryDebugNameFromSegment(segment: Segment): string | undefined {
877877

878878
function isVirtualLayout(segment: string): boolean {
879879
return (
880-
// This is inserted by the loader. We should consider encoding these
881-
// in a more special way instead of checking the name, to distinguish them
882-
// from app-defined groups.
883-
segment === '(slot)'
880+
// This is inserted by the loader. Uses double-underscore convention
881+
// (like __PAGE__ and __DEFAULT__) to avoid collisions with
882+
// user-defined route groups.
883+
segment === '(__SLOT__)'
884884
)
885885
}

packages/next/src/server/app-render/app-render.tsx

Lines changed: 42 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4662,6 +4662,7 @@ async function validateInstantConfigs(
46624662
createCombinedPayloadAtDepth,
46634663
createCombinedPayloadStream,
46644664
collectStagedSegmentData,
4665+
discoverValidationDepths,
46654666
} = ctx.componentMod.InstantValidation()!
46664667

46674668
const { createValidationSampleTracking } =
@@ -4707,13 +4708,15 @@ async function validateInstantConfigs(
47074708
* runtime and dynamic errors, returning the more specific result.
47084709
*/
47094710
async function validateAtDepth(
4710-
depth: number
4711+
depth: number,
4712+
groupDepthForValidation: number
47114713
): Promise<Array<unknown> | null> {
4712-
return validateAtDepthImpl(depth, null)
4714+
return validateAtDepthImpl(depth, groupDepthForValidation, null)
47134715
}
47144716

47154717
async function validateAtDepthImpl(
47164718
depth: number,
4719+
groupDepthForValidation: number,
47174720
previousBoundaryState: null | ValidationBoundaryTracking
47184721
): Promise<null | Array<unknown>> {
47194722
const extraChunksController = new AbortController()
@@ -4735,6 +4738,7 @@ async function validateInstantConfigs(
47354738
ctx.getDynamicParamFromSegment,
47364739
ctx.query,
47374740
depth,
4741+
groupDepthForValidation,
47384742
extraChunksController.signal,
47394743
boundaryState,
47404744
clientReferenceManifest,
@@ -4914,7 +4918,11 @@ async function validateInstantConfigs(
49144918
if (previousBoundaryState === null && payloadResult.hasAmbiguousErrors) {
49154919
// This is the first validation attempt. we prepared a payload where dynamic holes might be runtime data dependencies
49164920
// or dynamic data dependencies. We do a followup validation using a payload with only Runtime segments to discriminate
4917-
const dynamicOnlyErrors = await validateAtDepthImpl(depth, boundaryState)
4921+
const dynamicOnlyErrors = await validateAtDepthImpl(
4922+
depth,
4923+
groupDepthForValidation,
4924+
boundaryState
4925+
)
49184926

49194927
if (dynamicOnlyErrors !== null && dynamicOnlyErrors.length > 0) {
49204928
// The dynamic errors only validation found errors to report so we favor those
@@ -4926,25 +4934,43 @@ async function validateInstantConfigs(
49264934
return errors
49274935
}
49284936

4929-
const urlSegments = ctx.url.pathname.split('/').filter(Boolean)
4930-
const maxDepth = urlSegments.length + 1 // +1 for root
4937+
// Discover validation depth bounds from the LoaderTree. The array
4938+
// length is the max URL depth; each entry is the max group depth
4939+
// (route group segments) between that URL depth and the next.
4940+
const groupDepthsByUrlDepth = discoverValidationDepths(loaderTree)
4941+
const maxDepth = groupDepthsByUrlDepth.length
49314942

49324943
for (let depth = maxDepth - 1; depth >= 0; depth--) {
4933-
debug?.(`Trying depth ${depth}...`)
4944+
const maxGroupDepth = groupDepthsByUrlDepth[depth]
49344945

4935-
const errors = await validateAtDepth(depth)
4946+
for (
4947+
let currentGroupDepth = maxGroupDepth;
4948+
currentGroupDepth >= 0;
4949+
currentGroupDepth--
4950+
) {
4951+
debug?.(
4952+
`Trying depth ${depth}` +
4953+
(currentGroupDepth > 0
4954+
? ` + groupDepth ${currentGroupDepth}...`
4955+
: '...')
4956+
)
49364957

4937-
if (errors === null) {
4938-
debug?.(` No config at depth ${depth}, skipping.`)
4939-
continue
4940-
}
4958+
const errors = await validateAtDepth(depth, currentGroupDepth)
49414959

4942-
if (errors.length > 0) {
4943-
debug?.(` Depth ${depth}: ❌ Failed (${errors.length} errors)`)
4944-
return errors
4945-
}
4960+
if (errors === null) {
4961+
debug?.(` No config at depth ${depth}+${currentGroupDepth}, skipping.`)
4962+
continue
4963+
}
4964+
4965+
if (errors.length > 0) {
4966+
debug?.(
4967+
` Depth ${depth}+${currentGroupDepth}: ❌ Failed (${errors.length} errors)`
4968+
)
4969+
return errors
4970+
}
49464971

4947-
debug?.(` Depth ${depth}: ✅ Passed`)
4972+
debug?.(` Depth ${depth}+${currentGroupDepth}: ✅ Passed`)
4973+
}
49484974
}
49494975

49504976
debug?.(`✅ All depths passed`)

packages/next/src/server/app-render/instant-validation/instant-validation.tsx

Lines changed: 117 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -785,6 +785,86 @@ function segmentConsumesURLDepth(segment: Segment): boolean {
785785
return true
786786
}
787787

788+
/**
789+
* Walks the LoaderTree to discover validation depth bounds.
790+
*
791+
* Each route group between URL segments represents a potential
792+
* shared/new boundary in a client navigation. When a user navigates
793+
* between sibling routes that share a route group layout, that
794+
* layout is already mounted — its Suspense boundaries are revealed
795+
* and don't cover new content below. By tracking the max group
796+
* depth at each URL depth, we can iterate all possible group
797+
* boundaries and validate that blocking code is always covered by
798+
* Suspense in the new tree. This is conservative: some boundaries
799+
* may not correspond to real navigations (e.g. a route group with
800+
* no siblings), but it ensures we don't miss real violations.
801+
*
802+
* The max is taken across all parallel slots. When slots have
803+
* different numbers of groups, the deepest slot determines the
804+
* iteration range. Shallower slots simply stay entirely shared
805+
* at group depths beyond their own group count — they run out
806+
* of groups before reaching the boundary, so their content
807+
* remains in the Dynamic stage.
808+
*
809+
* Returns an array where:
810+
* - length = max URL depth (number of URL-consuming segments)
811+
* - array[i] = max group depth at URL depth i (number of route group
812+
* segments between this URL depth and the next)
813+
*
814+
* For example, a tree like:
815+
* '' / (outer) / (inner) / dashboard / page
816+
* returns [2, 0] — URL depth 0 (root) has 2 group layers before
817+
* the next URL segment (dashboard), and URL depth 1 (dashboard) has
818+
* 0 group layers before the leaf.
819+
*/
820+
export function discoverValidationDepths(loaderTree: LoaderTree): number[] {
821+
const groupDepthsByUrlDepth: number[] = []
822+
823+
function recordGroupDepth(urlDepth: number, groupDepth: number): void {
824+
while (groupDepthsByUrlDepth.length <= urlDepth) {
825+
groupDepthsByUrlDepth.push(0)
826+
}
827+
if (groupDepth > groupDepthsByUrlDepth[urlDepth]) {
828+
groupDepthsByUrlDepth[urlDepth] = groupDepth
829+
}
830+
}
831+
832+
// urlDepth tracks the index of the current URL-consuming segment.
833+
// Groups accumulate at the same index. When the next URL segment
834+
// is reached, it increments the index and resets the group counter.
835+
// We start at -1 so the root segment '' increments to 0.
836+
function walk(tree: LoaderTree, urlDepth: number, groupDepth: number): void {
837+
const segment = tree[0]
838+
const { parallelRoutes } = parseLoaderTree(tree)
839+
const consumesDepth = segmentConsumesURLDepth(segment)
840+
841+
let nextUrlDepth = urlDepth
842+
let nextGroupDepth = groupDepth
843+
if (consumesDepth) {
844+
nextUrlDepth = urlDepth + 1
845+
nextGroupDepth = 0
846+
recordGroupDepth(nextUrlDepth, 0)
847+
} else if (
848+
typeof segment === 'string' &&
849+
isGroupSegment(segment) &&
850+
segment !== '(__SLOT__)'
851+
) {
852+
// Count real route groups but not the synthetic '(__SLOT__)' segment
853+
// that Next.js inserts for parallel slots. The synthetic group
854+
// can't be a real navigation boundary.
855+
nextGroupDepth++
856+
recordGroupDepth(urlDepth, nextGroupDepth)
857+
}
858+
859+
for (const key in parallelRoutes) {
860+
walk(parallelRoutes[key], nextUrlDepth, nextGroupDepth)
861+
}
862+
}
863+
864+
walk(loaderTree, -1, 0)
865+
return groupDepthsByUrlDepth
866+
}
867+
788868
/**
789869
* Builds a combined RSC payload for validation at a given URL depth.
790870
*
@@ -815,6 +895,7 @@ export async function createCombinedPayloadAtDepth(
815895
getDynamicParamFromSegment: GetDynamicParamFromSegment,
816896
query: NextParsedUrlQuery | null,
817897
depth: number,
898+
groupDepth: number,
818899
releaseSignal: AbortSignal,
819900
boundaryState: ValidationBoundaryTracking,
820901
clientReferenceManifest: ClientReferenceManifest,
@@ -837,7 +918,8 @@ export async function createCombinedPayloadAtDepth(
837918
loaderTree: LoaderTree,
838919
parentPath: SegmentPath | null,
839920
key: string | null,
840-
urlDepthConsumed: number
921+
urlDepthConsumed: number,
922+
groupDepthConsumed: number
841923
): Promise<TreeResult> {
842924
const { parallelRoutes } = parseLoaderTree(loaderTree)
843925

@@ -862,12 +944,35 @@ export async function createCombinedPayloadAtDepth(
862944
null
863945
)
864946

865-
const consumesDepth = segmentConsumesURLDepth(segment)
947+
const consumesUrlDepth = segmentConsumesURLDepth(segment)
948+
const isGroup =
949+
typeof segment === 'string' &&
950+
isGroupSegment(segment) &&
951+
segment !== '(__SLOT__)'
952+
953+
// Advance counters for this segment before the boundary check,
954+
// mirroring how discoverValidationDepths counts. URL segments
955+
// increment urlDepthConsumed, groups increment groupDepthConsumed.
956+
// The synthetic '(__SLOT__)' segment is excluded — it can't be a
957+
// real navigation boundary.
958+
let nextUrlDepth = urlDepthConsumed
959+
let currentGroupDepth = groupDepthConsumed
960+
if (consumesUrlDepth) {
961+
nextUrlDepth++
962+
currentGroupDepth = 0
963+
} else if (isGroup) {
964+
currentGroupDepth++
965+
}
966+
967+
const pastUrlBoundary = nextUrlDepth > depth
968+
const isBoundary = pastUrlBoundary && currentGroupDepth >= groupDepth
866969

867-
if (consumesDepth && urlDepthConsumed === depth) {
868-
debug?.(` ['${path}' is the boundary]`)
970+
if (isBoundary) {
971+
debug?.(
972+
` ['${path}' is the boundary (url=${nextUrlDepth}, group=${currentGroupDepth})]`
973+
)
869974
boundaryState.expectedIds.add(path)
870-
const wrappedSegmentData: SegmentData = {
975+
const finalSegmentData: SegmentData = {
871976
...segmentData,
872977
node: (
873978
// eslint-disable-next-line @next/internal/no-ambiguous-jsx -- bundled in the server layer
@@ -876,6 +981,7 @@ export async function createCombinedPayloadAtDepth(
876981
</PlaceValidationBoundaryBelowThisLevel>
877982
),
878983
}
984+
879985
const slots: CacheNodeSeedDataSlots = {}
880986
let requiresInstantUI = false
881987
let createInstantStack: (() => Error) | null = null
@@ -895,13 +1001,13 @@ export async function createCombinedPayloadAtDepth(
8951001
}
8961002
}
8971003
return {
898-
seedData: getCacheNodeSeedDataFromSegment(wrappedSegmentData, slots),
1004+
seedData: getCacheNodeSeedDataFromSegment(finalSegmentData, slots),
8991005
requiresInstantUI,
9001006
createInstantStack,
9011007
}
9021008
}
9031009

904-
// Not the boundary yet — keep walking the shared tree.
1010+
// Not at the boundary yet — keep walking as shared.
9051011
const slots: CacheNodeSeedDataSlots = {}
9061012
let requiresInstantUI = false
9071013
let createInstantStack: (() => Error) | null = null
@@ -910,7 +1016,8 @@ export async function createCombinedPayloadAtDepth(
9101016
parallelRoutes[parallelRouteKey],
9111017
path,
9121018
parallelRouteKey,
913-
consumesDepth ? urlDepthConsumed + 1 : urlDepthConsumed
1019+
nextUrlDepth,
1020+
currentGroupDepth
9141021
)
9151022
slots[parallelRouteKey] = result.seedData
9161023
if (result.requiresInstantUI) {
@@ -947,7 +1054,6 @@ export async function createCombinedPayloadAtDepth(
9471054
if (layoutOrPageMod !== undefined) {
9481055
instantConfig =
9491056
(layoutOrPageMod as AppSegmentConfig).unstable_instant ?? null
950-
9511057
if (instantConfig && typeof instantConfig === 'object') {
9521058
const rawFactory: unknown = (layoutOrPageMod as any)
9531059
.__debugCreateInstantConfigStack
@@ -1042,7 +1148,8 @@ export async function createCombinedPayloadAtDepth(
10421148
initialLoaderTree,
10431149
null /* parentPath */,
10441150
null /* key */,
1045-
0 /* urlDepthConsumed */
1151+
0 /* urlDepthConsumed */,
1152+
0 /* groupDepthConsumed */
10461153
)
10471154

10481155
if (!requiresInstantUI) {

test/e2e/app-dir/instant-validation/app/suspense-in-root/page.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ export default async function Page() {
9191
<DebugLinks href="/suspense-in-root/static/valid-only-loading-around-dynamic" />
9292
</li>
9393
<li>
94-
<DebugLinks href="/suspense-in-root/static/valid-only-loading-around-dynamic-higher" />
94+
<DebugLinks href="/suspense-in-root/static/invalid-loading-above-route-group" />
9595
</li>
9696
<li>
9797
<DebugLinks href="/suspense-in-root/static/invalid-dynamic-layout-with-loading" />
@@ -185,6 +185,12 @@ export default async function Page() {
185185
<li>
186186
<DebugLinks href="/suspense-in-root/static/route-group-shared-boundary" />
187187
</li>
188+
<li>
189+
<DebugLinks href="/suspense-in-root/static/parallel-group-depths-deep-slot-hole" />
190+
</li>
191+
<li>
192+
<DebugLinks href="/suspense-in-root/static/parallel-group-depths-shallow-slot-hole" />
193+
</li>
188194
<li>
189195
<DebugLinks href="/suspense-in-root/static/route-group-shared-boundary/foo" />
190196
</li>

test/e2e/app-dir/instant-validation/app/suspense-in-root/static/valid-only-loading-around-dynamic-higher/(group)/page.tsx renamed to test/e2e/app-dir/instant-validation/app/suspense-in-root/static/invalid-loading-above-route-group/(group)/page.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,12 @@ export default async function Page() {
1111
<main>
1212
<p>
1313
This page doesn't wrap runtime/dynamic components in suspense, but it
14-
has a loading.tsx above it. a self-navigation with a different search
15-
param value would block, but we accept that.
14+
has a loading.tsx above it. However, the page is inside a route group
15+
and the loading.tsx is on the parent URL segment. Validation considers
16+
the route group as a potential shared boundary where the loading.tsx
17+
Suspense would already be revealed. In a more advanced system we would
18+
analyze siblings of the route group to determine if such a navigation is
19+
actually possible, but for now we conservatively report an error.
1620
</p>
1721
<div>
1822
<Runtime />

test/e2e/app-dir/instant-validation/app/suspense-in-root/static/valid-only-loading-around-dynamic-higher/loading.tsx renamed to test/e2e/app-dir/instant-validation/app/suspense-in-root/static/invalid-loading-above-route-group/loading.tsx

File renamed without changes.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { cookies } from 'next/headers'
2+
import { ReactNode } from 'react'
3+
4+
export default async function B2Layout({ children }: { children: ReactNode }) {
5+
await cookies()
6+
return <div>{children}</div>
7+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export const unstable_instant = { prefetch: 'static' }
2+
3+
export default function Page() {
4+
return (
5+
<main>
6+
<p>Children page inside 2 nested route groups</p>
7+
</main>
8+
)
9+
}

0 commit comments

Comments
 (0)