Skip to content

Commit 5e1067a

Browse files
wyattjohMoroshima
authored andcommitted
Fix prerendering of interception routes with generateStaticParams (vercel#85835)
## What Fixes a bug where interception routes in parallel slots could not be prerendered using `generateStaticParams`, causing 404 responses when these routes were accessed directly. ## Why **The Problem:** Interception routes like `app/@modal/(.)photo/[id]/page.tsx` could not be prerendered even when they exported `generateStaticParams`. This was because the static path generation code only examined "children" segments in the loader tree, completely missing segments from parallel routes (like `@modal`) that actually contribute to the pathname. **Root Cause:** The previous implementation used `childrenRouteParamSegments` which only traversed the `children` branch of the loader tree: ```typescript // OLD: Only looked at children const childrenRouteParamSegments = [...segments from children only...] // This missed parallel routes like @modal that have dynamic segments ``` For a route structure like: ``` app/ [username]/ page.tsx @modal/ (.)[username]/ [id]/ page.tsx // ← This route's segments were MISSED ``` The build system couldn't discover the `[id]` parameter in the parallel route because it never traversed that branch of the tree. ## How **Solution:** Introduces `extractPathnameSegments()` which properly traverses the ENTIRE loader tree (not just children) to find ALL segments that contribute to the pathname: 1. **BFS Traversal**: Explores both `children` AND all parallel route slots (e.g., `@modal`, `@sidebar`) 2. **Depth Tracking**: Correctly tracks URL depth by: - Skipping route groups `(marketing)` - not in URL - Skipping parallel markers `@modal` - not in URL - Including interception markers `(.)photo` - ARE in URL 3. **Prefix Validation**: Ensures static segments match the target pathname before including dynamic segments 4. **Complete Parameter Discovery**: Returns all segments that contribute to pathname construction, regardless of which tree branch they're in **Example:** For `app/@modal/(.)photo/[id]/page.tsx`: - Old: Missed the `[id]` parameter entirely - New: Discovers `[id]` and enables prerendering with `generateStaticParams` ## Changes **New Module**: `extract-pathname-segments.ts` (192 lines) - Core algorithm for traversing loader tree and extracting pathname segments - Handles complex cases: parallel routes, interception routes, route groups - Well-documented with examples and algorithm explanation **Comprehensive Tests**: `extract-pathname-segments.test.ts` (897 lines) - Tests for simple cases, nested structures, parallel routes - Interception route handling in various configurations - Route group behavior and edge cases - Depth tracking validation **Integration**: `static-paths/app.ts` - Replaced `childrenRouteParamSegments` with `extractPathnameSegments()` - Updated pathname construction to use segments from parallel routes - Maintained backward compatibility **E2E Test**: Added test validating prerendering works for intercepted routes ## Test Plan The new E2E test verifies: ```typescript it('should prerender a dynamic intercepted route', async () => { // Verifies build output contains the prerendered interception route expect(next.cliOutput).toContain('/(.)john/1') // Verifies it doesn't generate the non-intercepted path expect(next.cliOutput).not.toContain('/john/1') }) ``` ## Impact **Before**: Interception routes with dynamic segments returned 404 when accessed directly, even with `generateStaticParams` **After**: These routes are properly prerendered at build time and return correct responses
1 parent 2ac197a commit 5e1067a

26 files changed

+3706
-1937
lines changed

packages/next/errors.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -927,5 +927,8 @@
927927
"926": "Optional route parameters are not yet supported (\"[%s]\") in route \"%s\".",
928928
"927": "No debug targets found",
929929
"928": "Unable to get server address",
930-
"929": "No pages or app directory found."
930+
"929": "No pages or app directory found.",
931+
"930": "Expected a dynamic route, but got a static route: %s",
932+
"931": "Unexpected empty path segments match for a route \"%s\" with param \"%s\" of type \"%s\"",
933+
"932": "Could not resolve param value for segment: %s"
931934
}

packages/next/src/build/segment-config/app/app-segments.ts

Lines changed: 26 additions & 112 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,6 @@ import {
1717
getLayoutOrPageModule,
1818
type LoaderTree,
1919
} from '../../../server/lib/app-dir-module'
20-
import { PAGE_SEGMENT_KEY } from '../../../shared/lib/segment'
21-
import type { FallbackRouteParam } from '../../static-paths/types'
22-
import { createFallbackRouteParam } from '../../static-paths/utils'
2320
import type { DynamicParamTypes } from '../../../shared/lib/app-router-types'
2421

2522
type GenerateStaticParams = (options: { params?: Params }) => Promise<Params[]>
@@ -63,14 +60,7 @@ export type AppSegment = {
6360
paramType: DynamicParamTypes | undefined
6461
filePath: string | undefined
6562
config: AppSegmentConfig | undefined
66-
isDynamicSegment: boolean
6763
generateStaticParams: GenerateStaticParams | undefined
68-
69-
/**
70-
* Whether this segment is a parallel route segment or descends from a
71-
* parallel route segment.
72-
*/
73-
isParallelRouteSegment: boolean | undefined
7464
}
7565

7666
/**
@@ -82,79 +72,57 @@ export type AppSegment = {
8272
async function collectAppPageSegments(routeModule: AppPageRouteModule) {
8373
// We keep track of unique segments, since with parallel routes, it's possible
8474
// to see the same segment multiple times.
85-
const uniqueSegments = new Map<string, AppSegment>()
75+
const segments: AppSegment[] = []
8676

87-
// Queue will store tuples of [loaderTree, currentSegments, isParallelRouteSegment]
88-
type QueueItem = [
89-
loaderTree: LoaderTree,
90-
currentSegments: AppSegment[],
91-
isParallelRouteSegment: boolean,
92-
]
93-
const queue: QueueItem[] = [[routeModule.userland.loaderTree, [], false]]
77+
// Queue will store loader trees.
78+
const queue: LoaderTree[] = [routeModule.userland.loaderTree]
9479

9580
while (queue.length > 0) {
96-
const [loaderTree, currentSegments, isParallelRouteSegment] = queue.shift()!
81+
const loaderTree = queue.shift()!
9782
const [name, parallelRoutes] = loaderTree
9883

9984
// Process current node
10085
const { mod: userland, filePath } = await getLayoutOrPageModule(loaderTree)
10186
const isClientComponent = userland && isClientReference(userland)
10287

103-
const { param: paramName, type: paramType } = getSegmentParam(name) ?? {}
88+
const param = getSegmentParam(name)
10489

10590
const segment: AppSegment = {
10691
name,
107-
paramName,
108-
paramType,
92+
paramName: param?.paramName,
93+
paramType: param?.paramType,
10994
filePath,
11095
config: undefined,
111-
isDynamicSegment: !!paramName,
11296
generateStaticParams: undefined,
113-
isParallelRouteSegment,
11497
}
11598

11699
// Only server components can have app segment configurations
117100
if (!isClientComponent) {
118101
attach(segment, userland, routeModule.definition.pathname)
119102
}
120103

121-
// Create a unique key for the segment
122-
const segmentKey = getSegmentKey(segment)
123-
if (!uniqueSegments.has(segmentKey)) {
124-
uniqueSegments.set(segmentKey, segment)
125-
}
126-
127-
const updatedSegments = [...currentSegments, segment]
128-
129-
// If this is a page segment, we've reached a leaf node
130-
if (name === PAGE_SEGMENT_KEY) {
131-
// Add all segments in the current path, preferring non-parallel segments
132-
updatedSegments.forEach((seg) => {
133-
const key = getSegmentKey(seg)
134-
if (!uniqueSegments.has(key)) {
135-
uniqueSegments.set(key, seg)
136-
}
137-
})
104+
// If this segment doesn't already exist, then add it to the segments array.
105+
// The list of segments is short so we just use a list traversal to check
106+
// for duplicates and spare us needing to maintain the string key.
107+
if (
108+
segments.every(
109+
(s) =>
110+
s.name !== segment.name ||
111+
s.paramName !== segment.paramName ||
112+
s.paramType !== segment.paramType ||
113+
s.filePath !== segment.filePath
114+
)
115+
) {
116+
segments.push(segment)
138117
}
139118

140119
// Add all parallel routes to the queue
141-
for (const parallelRouteKey in parallelRoutes) {
142-
const parallelRoute = parallelRoutes[parallelRouteKey]
143-
queue.push([
144-
parallelRoute,
145-
updatedSegments,
146-
// A parallel route segment is one that descends from a segment that is
147-
// not children or descends from a parallel route segment.
148-
isParallelRouteSegment || parallelRouteKey !== 'children',
149-
])
120+
for (const parallelRoute of Object.values(parallelRoutes)) {
121+
queue.push(parallelRoute)
150122
}
151123
}
152124

153-
return Array.from(uniqueSegments.values())
154-
}
155-
156-
function getSegmentKey(segment: AppSegment) {
157-
return `${segment.name}-${segment.filePath ?? ''}-${segment.paramName ?? ''}-${segment.isParallelRouteSegment ? 'pr' : 'np'}`
125+
return segments
158126
}
159127

160128
/**
@@ -174,17 +142,15 @@ function collectAppRouteSegments(
174142

175143
// Generate all the segments.
176144
const segments: AppSegment[] = parts.map((name) => {
177-
const { param: paramName, type: paramType } = getSegmentParam(name) ?? {}
145+
const param = getSegmentParam(name)
178146

179147
return {
180148
name,
181-
paramName,
182-
paramType,
149+
paramName: param?.paramName,
150+
paramType: param?.paramType,
183151
filePath: undefined,
184-
isDynamicSegment: !!paramName,
185152
config: undefined,
186153
generateStaticParams: undefined,
187-
isParallelRouteSegment: undefined,
188154
} satisfies AppSegment
189155
})
190156

@@ -221,55 +187,3 @@ export function collectSegments(
221187
'Expected a route module to be one of app route or page'
222188
)
223189
}
224-
225-
/**
226-
* Collects the fallback route params for a given app page route module. This is
227-
* a variant of the `collectSegments` function that only collects the fallback
228-
* route params without importing anything.
229-
*
230-
* @param routeModule the app page route module
231-
* @returns the fallback route params for the app page route module
232-
*/
233-
export function collectFallbackRouteParams(
234-
routeModule: AppPageRouteModule
235-
): readonly FallbackRouteParam[] {
236-
const uniqueSegments = new Map<string, FallbackRouteParam>()
237-
238-
// Queue will store tuples of [loaderTree, isParallelRouteSegment]
239-
type QueueItem = [loaderTree: LoaderTree, isParallelRouteSegment: boolean]
240-
const queue: QueueItem[] = [[routeModule.userland.loaderTree, false]]
241-
242-
while (queue.length > 0) {
243-
const [loaderTree, isParallelRouteSegment] = queue.shift()!
244-
const [name, parallelRoutes] = loaderTree
245-
246-
// Handle this segment (if it's a dynamic segment param).
247-
const segmentParam = getSegmentParam(name)
248-
if (segmentParam) {
249-
const key = `${name}-${segmentParam.param}-${isParallelRouteSegment ? 'pr' : 'np'}`
250-
if (!uniqueSegments.has(key)) {
251-
uniqueSegments.set(
252-
key,
253-
createFallbackRouteParam(
254-
segmentParam.param,
255-
segmentParam.type,
256-
isParallelRouteSegment
257-
)
258-
)
259-
}
260-
}
261-
262-
// Add all of this segment's parallel routes to the queue.
263-
for (const parallelRouteKey in parallelRoutes) {
264-
const parallelRoute = parallelRoutes[parallelRouteKey]
265-
queue.push([
266-
parallelRoute,
267-
// A parallel route segment is one that descends from a segment that is
268-
// not children or descends from a parallel route segment.
269-
isParallelRouteSegment || parallelRouteKey !== 'children',
270-
])
271-
}
272-
}
273-
274-
return Array.from(uniqueSegments.values())
275-
}

packages/next/src/build/segment-config/app/collect-root-param-keys.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,9 @@ function collectAppPageRootParamKeys(
1717
const [name, parallelRoutes, modules] = current
1818

1919
// If this is a dynamic segment, then we collect the param.
20-
const param = getSegmentParam(name)?.param
21-
if (param) {
22-
rootParams.push(param)
20+
const paramName = getSegmentParam(name)?.paramName
21+
if (paramName) {
22+
rootParams.push(paramName)
2323
}
2424

2525
// If this has a layout module, then we've found the root layout because

0 commit comments

Comments
 (0)