diff --git a/packages/next/errors.json b/packages/next/errors.json index aec9186696ca1..9d8d3884e58b5 100644 --- a/packages/next/errors.json +++ b/packages/next/errors.json @@ -927,5 +927,8 @@ "926": "Optional route parameters are not yet supported (\"[%s]\") in route \"%s\".", "927": "No debug targets found", "928": "Unable to get server address", - "929": "No pages or app directory found." + "929": "No pages or app directory found.", + "930": "Expected a dynamic route, but got a static route: %s", + "931": "Unexpected empty path segments match for a route \"%s\" with param \"%s\" of type \"%s\"", + "932": "Could not resolve param value for segment: %s" } diff --git a/packages/next/src/build/segment-config/app/app-segments.ts b/packages/next/src/build/segment-config/app/app-segments.ts index 35c859e9db6f9..2472b7fd7c315 100644 --- a/packages/next/src/build/segment-config/app/app-segments.ts +++ b/packages/next/src/build/segment-config/app/app-segments.ts @@ -17,9 +17,6 @@ import { getLayoutOrPageModule, type LoaderTree, } from '../../../server/lib/app-dir-module' -import { PAGE_SEGMENT_KEY } from '../../../shared/lib/segment' -import type { FallbackRouteParam } from '../../static-paths/types' -import { createFallbackRouteParam } from '../../static-paths/utils' import type { DynamicParamTypes } from '../../../shared/lib/app-router-types' type GenerateStaticParams = (options: { params?: Params }) => Promise @@ -63,14 +60,7 @@ export type AppSegment = { paramType: DynamicParamTypes | undefined filePath: string | undefined config: AppSegmentConfig | undefined - isDynamicSegment: boolean generateStaticParams: GenerateStaticParams | undefined - - /** - * Whether this segment is a parallel route segment or descends from a - * parallel route segment. - */ - isParallelRouteSegment: boolean | undefined } /** @@ -82,35 +72,28 @@ export type AppSegment = { async function collectAppPageSegments(routeModule: AppPageRouteModule) { // We keep track of unique segments, since with parallel routes, it's possible // to see the same segment multiple times. - const uniqueSegments = new Map() + const segments: AppSegment[] = [] - // Queue will store tuples of [loaderTree, currentSegments, isParallelRouteSegment] - type QueueItem = [ - loaderTree: LoaderTree, - currentSegments: AppSegment[], - isParallelRouteSegment: boolean, - ] - const queue: QueueItem[] = [[routeModule.userland.loaderTree, [], false]] + // Queue will store loader trees. + const queue: LoaderTree[] = [routeModule.userland.loaderTree] while (queue.length > 0) { - const [loaderTree, currentSegments, isParallelRouteSegment] = queue.shift()! + const loaderTree = queue.shift()! const [name, parallelRoutes] = loaderTree // Process current node const { mod: userland, filePath } = await getLayoutOrPageModule(loaderTree) const isClientComponent = userland && isClientReference(userland) - const { param: paramName, type: paramType } = getSegmentParam(name) ?? {} + const param = getSegmentParam(name) const segment: AppSegment = { name, - paramName, - paramType, + paramName: param?.paramName, + paramType: param?.paramType, filePath, config: undefined, - isDynamicSegment: !!paramName, generateStaticParams: undefined, - isParallelRouteSegment, } // Only server components can have app segment configurations @@ -118,43 +101,28 @@ async function collectAppPageSegments(routeModule: AppPageRouteModule) { attach(segment, userland, routeModule.definition.pathname) } - // Create a unique key for the segment - const segmentKey = getSegmentKey(segment) - if (!uniqueSegments.has(segmentKey)) { - uniqueSegments.set(segmentKey, segment) - } - - const updatedSegments = [...currentSegments, segment] - - // If this is a page segment, we've reached a leaf node - if (name === PAGE_SEGMENT_KEY) { - // Add all segments in the current path, preferring non-parallel segments - updatedSegments.forEach((seg) => { - const key = getSegmentKey(seg) - if (!uniqueSegments.has(key)) { - uniqueSegments.set(key, seg) - } - }) + // If this segment doesn't already exist, then add it to the segments array. + // The list of segments is short so we just use a list traversal to check + // for duplicates and spare us needing to maintain the string key. + if ( + segments.every( + (s) => + s.name !== segment.name || + s.paramName !== segment.paramName || + s.paramType !== segment.paramType || + s.filePath !== segment.filePath + ) + ) { + segments.push(segment) } // Add all parallel routes to the queue - for (const parallelRouteKey in parallelRoutes) { - const parallelRoute = parallelRoutes[parallelRouteKey] - queue.push([ - parallelRoute, - updatedSegments, - // A parallel route segment is one that descends from a segment that is - // not children or descends from a parallel route segment. - isParallelRouteSegment || parallelRouteKey !== 'children', - ]) + for (const parallelRoute of Object.values(parallelRoutes)) { + queue.push(parallelRoute) } } - return Array.from(uniqueSegments.values()) -} - -function getSegmentKey(segment: AppSegment) { - return `${segment.name}-${segment.filePath ?? ''}-${segment.paramName ?? ''}-${segment.isParallelRouteSegment ? 'pr' : 'np'}` + return segments } /** @@ -174,17 +142,15 @@ function collectAppRouteSegments( // Generate all the segments. const segments: AppSegment[] = parts.map((name) => { - const { param: paramName, type: paramType } = getSegmentParam(name) ?? {} + const param = getSegmentParam(name) return { name, - paramName, - paramType, + paramName: param?.paramName, + paramType: param?.paramType, filePath: undefined, - isDynamicSegment: !!paramName, config: undefined, generateStaticParams: undefined, - isParallelRouteSegment: undefined, } satisfies AppSegment }) @@ -221,55 +187,3 @@ export function collectSegments( 'Expected a route module to be one of app route or page' ) } - -/** - * Collects the fallback route params for a given app page route module. This is - * a variant of the `collectSegments` function that only collects the fallback - * route params without importing anything. - * - * @param routeModule the app page route module - * @returns the fallback route params for the app page route module - */ -export function collectFallbackRouteParams( - routeModule: AppPageRouteModule -): readonly FallbackRouteParam[] { - const uniqueSegments = new Map() - - // Queue will store tuples of [loaderTree, isParallelRouteSegment] - type QueueItem = [loaderTree: LoaderTree, isParallelRouteSegment: boolean] - const queue: QueueItem[] = [[routeModule.userland.loaderTree, false]] - - while (queue.length > 0) { - const [loaderTree, isParallelRouteSegment] = queue.shift()! - const [name, parallelRoutes] = loaderTree - - // Handle this segment (if it's a dynamic segment param). - const segmentParam = getSegmentParam(name) - if (segmentParam) { - const key = `${name}-${segmentParam.param}-${isParallelRouteSegment ? 'pr' : 'np'}` - if (!uniqueSegments.has(key)) { - uniqueSegments.set( - key, - createFallbackRouteParam( - segmentParam.param, - segmentParam.type, - isParallelRouteSegment - ) - ) - } - } - - // Add all of this segment's parallel routes to the queue. - for (const parallelRouteKey in parallelRoutes) { - const parallelRoute = parallelRoutes[parallelRouteKey] - queue.push([ - parallelRoute, - // A parallel route segment is one that descends from a segment that is - // not children or descends from a parallel route segment. - isParallelRouteSegment || parallelRouteKey !== 'children', - ]) - } - } - - return Array.from(uniqueSegments.values()) -} diff --git a/packages/next/src/build/segment-config/app/collect-root-param-keys.ts b/packages/next/src/build/segment-config/app/collect-root-param-keys.ts index e3539ca52182f..2ba80b88c3f84 100644 --- a/packages/next/src/build/segment-config/app/collect-root-param-keys.ts +++ b/packages/next/src/build/segment-config/app/collect-root-param-keys.ts @@ -17,9 +17,9 @@ function collectAppPageRootParamKeys( const [name, parallelRoutes, modules] = current // If this is a dynamic segment, then we collect the param. - const param = getSegmentParam(name)?.param - if (param) { - rootParams.push(param) + const paramName = getSegmentParam(name)?.paramName + if (paramName) { + rootParams.push(paramName) } // If this has a layout module, then we've found the root layout because diff --git a/packages/next/src/build/static-paths/app.test.ts b/packages/next/src/build/static-paths/app.test.ts index 7dcd43782d1fb..0d0c4d43653a9 100644 --- a/packages/next/src/build/static-paths/app.test.ts +++ b/packages/next/src/build/static-paths/app.test.ts @@ -6,12 +6,10 @@ import { calculateFallbackMode, filterUniqueParams, generateRouteStaticParams, - resolveParallelRouteParams, } from './app' -import type { PrerenderedRoute, FallbackRouteParam } from './types' +import type { PrerenderedRoute } from './types' import type { WorkStore } from '../../server/app-render/work-async-storage.external' import type { AppSegment } from '../segment-config/app/app-segments' -import type { DynamicParamTypes } from '../../shared/lib/app-router-types' describe('assignErrorIfEmpty', () => { it('should assign throwOnEmptyStaticShell true for a static route with no children', () => { @@ -42,7 +40,6 @@ describe('assignErrorIfEmpty', () => { { paramName: 'id', paramType: 'dynamic', - isParallelRouteParam: false, }, ], fallbackMode: FallbackMode.NOT_FOUND, @@ -76,12 +73,10 @@ describe('assignErrorIfEmpty', () => { { paramName: 'id', paramType: 'dynamic', - isParallelRouteParam: false, }, { paramName: 'name', paramType: 'dynamic', - isParallelRouteParam: false, }, ], fallbackMode: FallbackMode.NOT_FOUND, @@ -96,7 +91,6 @@ describe('assignErrorIfEmpty', () => { { paramName: 'name', paramType: 'dynamic', - isParallelRouteParam: false, }, ], fallbackMode: FallbackMode.NOT_FOUND, @@ -129,7 +123,6 @@ describe('assignErrorIfEmpty', () => { { paramName: 'name', paramType: 'dynamic', - isParallelRouteParam: false, }, ], fallbackMode: FallbackMode.NOT_FOUND, @@ -160,7 +153,6 @@ describe('assignErrorIfEmpty', () => { { paramName: 'name', paramType: 'dynamic', - isParallelRouteParam: false, }, ], fallbackMode: FallbackMode.NOT_FOUND, @@ -175,12 +167,10 @@ describe('assignErrorIfEmpty', () => { { paramName: 'name', paramType: 'dynamic', - isParallelRouteParam: false, }, { paramName: 'extra', paramType: 'catchall', - isParallelRouteParam: false, }, ], fallbackMode: FallbackMode.NOT_FOUND, @@ -225,7 +215,6 @@ describe('assignErrorIfEmpty', () => { { paramName: 'slug', paramType: 'dynamic', - isParallelRouteParam: false, }, ], fallbackMode: FallbackMode.NOT_FOUND, @@ -269,12 +258,10 @@ describe('assignErrorIfEmpty', () => { { paramName: 'id', paramType: 'dynamic', - isParallelRouteParam: false, }, { paramName: 'slug', paramType: 'catchall', - isParallelRouteParam: false, }, ], fallbackMode: FallbackMode.NOT_FOUND, @@ -289,7 +276,6 @@ describe('assignErrorIfEmpty', () => { { paramName: 'slug', paramType: 'catchall', - isParallelRouteParam: false, }, ], fallbackMode: FallbackMode.NOT_FOUND, @@ -327,17 +313,14 @@ describe('assignErrorIfEmpty', () => { { paramName: 'category', paramType: 'dynamic', - isParallelRouteParam: false, }, { paramName: 'subcategory', paramType: 'dynamic', - isParallelRouteParam: false, }, { paramName: 'item', paramType: 'dynamic', - isParallelRouteParam: false, }, ], fallbackMode: FallbackMode.NOT_FOUND, @@ -352,12 +335,10 @@ describe('assignErrorIfEmpty', () => { { paramName: 'subcategory', paramType: 'dynamic', - isParallelRouteParam: false, }, { paramName: 'item', paramType: 'dynamic', - isParallelRouteParam: false, }, ], fallbackMode: FallbackMode.NOT_FOUND, @@ -372,7 +353,6 @@ describe('assignErrorIfEmpty', () => { { paramName: 'item', paramType: 'dynamic', - isParallelRouteParam: false, }, ], fallbackMode: FallbackMode.NOT_FOUND, @@ -417,7 +397,6 @@ describe('assignErrorIfEmpty', () => { { paramName: 'segments', paramType: 'catchall', - isParallelRouteParam: false, }, ], fallbackMode: FallbackMode.NOT_FOUND, @@ -1307,1272 +1286,3 @@ describe('calculateFallbackMode', () => { expect(result).toBe(FallbackMode.BLOCKING_STATIC_RENDER) }) }) - -describe('resolveParallelRouteParams', () => { - // Helper to create LoaderTree structures for testing - type TestLoaderTree = [ - segment: string, - parallelRoutes: { [key: string]: TestLoaderTree }, - modules: Record, - ] - - function createLoaderTree( - segment: string, - parallelRoutes: { [key: string]: TestLoaderTree } = {}, - children?: TestLoaderTree - ): TestLoaderTree { - const routes = children ? { ...parallelRoutes, children } : parallelRoutes - return [segment, routes, {}] - } - - function createFallbackParam( - paramName: string, - isParallelRouteParam: boolean, - paramType: DynamicParamTypes = 'dynamic' - ): FallbackRouteParam { - return { paramName, paramType, isParallelRouteParam } - } - - describe('direct match case', () => { - it('should skip processing when param already exists in params object', () => { - // Tree: / -> @sidebar/[existingParam] - const loaderTree = createLoaderTree('', { - sidebar: createLoaderTree('[existingParam]'), - }) - const params: Params = { existingParam: 'value' } - const pathname = '/some/path' - const fallbackRouteParams: FallbackRouteParam[] = [] - - resolveParallelRouteParams( - loaderTree, - params, - pathname, - fallbackRouteParams - ) - - expect(params.existingParam).toBe('value') - expect(fallbackRouteParams).toHaveLength(0) - }) - - it('should skip processing for multiple existing params', () => { - // Tree: / -> @sidebar/[param1] + @modal/[...param2] - const loaderTree = createLoaderTree('', { - sidebar: createLoaderTree('[param1]'), - modal: createLoaderTree('[...param2]'), - }) - const params: Params = { param1: 'value1', param2: ['a', 'b'] } - const pathname = '/some/path' - const fallbackRouteParams: FallbackRouteParam[] = [] - - resolveParallelRouteParams( - loaderTree, - params, - pathname, - fallbackRouteParams - ) - - expect(params.param1).toBe('value1') - expect(params.param2).toEqual(['a', 'b']) - expect(fallbackRouteParams).toHaveLength(0) - }) - }) - - describe('dynamic params', () => { - it('should extract dynamic param from pathname when not already in params', () => { - // Tree: / -> @sidebar/[dynamicParam] - // At depth 0, should extract 'some' from pathname '/some/path' - const loaderTree = createLoaderTree('', { - sidebar: createLoaderTree('[dynamicParam]'), - }) - const params: Params = {} - const pathname = '/some/path' - const fallbackRouteParams: FallbackRouteParam[] = [] - - resolveParallelRouteParams( - loaderTree, - params, - pathname, - fallbackRouteParams - ) - - expect(params.dynamicParam).toBe('some') - expect(fallbackRouteParams).toHaveLength(0) - }) - - it('should handle multiple dynamic params in parallel routes at same depth', () => { - // Tree: / -> @modal/[id] + @sidebar/[category] - // Both at depth 0, so both extract 'photo' from pathname '/photo/123' - const loaderTree = createLoaderTree('', { - modal: createLoaderTree('[id]'), - sidebar: createLoaderTree('[category]'), - }) - const params: Params = {} - const pathname = '/photo/123' - const fallbackRouteParams: FallbackRouteParam[] = [] - - resolveParallelRouteParams( - loaderTree, - params, - pathname, - fallbackRouteParams - ) - - // Both should extract the first segment 'photo' - expect(params.id).toBe('photo') - expect(params.category).toBe('photo') - expect(fallbackRouteParams).toHaveLength(0) - }) - - it('should extract dynamic param from pathname at depth 0', () => { - // Tree: / -> @sidebar/[category] - const loaderTree = createLoaderTree('', { - sidebar: createLoaderTree('[category]'), - }) - const params: Params = {} - const pathname = '/tech' - const fallbackRouteParams: FallbackRouteParam[] = [] - - resolveParallelRouteParams( - loaderTree, - params, - pathname, - fallbackRouteParams - ) - - expect(params.category).toBe('tech') - expect(fallbackRouteParams).toHaveLength(0) - }) - - it('should extract dynamic param from pathname at nested depth', () => { - // Tree: /blog -> @sidebar/[category] - const loaderTree = createLoaderTree( - '', - {}, - createLoaderTree('blog', { - sidebar: createLoaderTree('[category]'), - }) - ) - const params: Params = {} - const pathname = '/blog/tech' - const fallbackRouteParams: FallbackRouteParam[] = [] - - resolveParallelRouteParams( - loaderTree, - params, - pathname, - fallbackRouteParams - ) - - expect(params.category).toBe('tech') - expect(fallbackRouteParams).toHaveLength(0) - }) - - it('should extract dynamic param even when other unknown params exist at different depths', () => { - // Tree: / -> @sidebar/[category] - // Even though there's an unknown 'slug' param somewhere else, if the segment - // at this depth is known, we can extract it - const loaderTree = createLoaderTree('', { - sidebar: createLoaderTree('[category]'), - }) - const params: Params = {} - const pathname = '/tech' - const fallbackRouteParams: FallbackRouteParam[] = [ - createFallbackParam('slug', false), // Non-parallel fallback param at different depth - ] - - resolveParallelRouteParams( - loaderTree, - params, - pathname, - fallbackRouteParams - ) - - // Should extract 'tech' because pathSegments[0] is known, regardless of slug - expect(params.category).toBe('tech') - expect(fallbackRouteParams).toHaveLength(1) // Still just slug - }) - - it('should mark dynamic param as fallback when depth exceeds pathname length', () => { - // Tree: /blog/posts -> @sidebar/[category] - const loaderTree = createLoaderTree( - '', - {}, - createLoaderTree( - 'blog', - {}, - createLoaderTree('posts', { - sidebar: createLoaderTree('[category]'), - }) - ) - ) - const params: Params = {} - const pathname = '/blog' // Only 1 segment, but dynamic param is at depth 2 - const fallbackRouteParams: FallbackRouteParam[] = [] - - resolveParallelRouteParams( - loaderTree, - params, - pathname, - fallbackRouteParams - ) - - expect(params.category).toBeUndefined() - expect(fallbackRouteParams).toHaveLength(1) - expect(fallbackRouteParams[0]).toEqual({ - paramName: 'category', - paramType: 'dynamic', - isParallelRouteParam: true, - }) - }) - - it('should resolve embedded params when extracting dynamic param value', () => { - // Tree: /[lang] -> @sidebar/[category] - const loaderTree = createLoaderTree( - '', - {}, - createLoaderTree('[lang]', { - sidebar: createLoaderTree('[category]'), - }) - ) - const params: Params = { lang: 'en' } - const pathname = '/en/tech' - const fallbackRouteParams: FallbackRouteParam[] = [] - - resolveParallelRouteParams( - loaderTree, - params, - pathname, - fallbackRouteParams - ) - - expect(params.category).toBe('tech') - expect(fallbackRouteParams).toHaveLength(0) - }) - - it('should extract dynamic param when unknown params exist at LATER depth', () => { - // Tree: /[lang] -> @sidebar/[filter] (at depth 1) - // /[lang]/products/[category] (category at depth 2 is unknown) - // @sidebar/[filter] is at depth 1, should extract 'products' - // [category] at depth 2 is unknown, but shouldn't affect depth 1 resolution - const loaderTree = createLoaderTree( - '', - {}, - createLoaderTree( - '[lang]', - { - sidebar: createLoaderTree('[filter]'), - }, - createLoaderTree('products', {}, createLoaderTree('[category]')) - ) - ) - const params: Params = { lang: 'en' } - // Pathname with placeholder at depth 2: /en/products/[category] - const pathname = '/en/products/[category]' - const fallbackRouteParams: FallbackRouteParam[] = [ - createFallbackParam('category', false), // category at depth 2 is unknown - ] - - resolveParallelRouteParams( - loaderTree, - params, - pathname, - fallbackRouteParams - ) - - // Should extract 'products' at depth 1, even though category at depth 2 is unknown - expect(params.filter).toBe('products') - expect(fallbackRouteParams).toHaveLength(1) // Still just category - }) - - it('should NOT extract dynamic param when placeholder is at SAME depth', () => { - // Tree: /[lang]/products/[category] -> @sidebar/[filter] - // @sidebar/[filter] is at depth 2 - // [category] at depth 2 is also unknown - same depth! - const loaderTree = createLoaderTree( - '', - {}, - createLoaderTree( - '[lang]', - {}, - createLoaderTree( - 'products', - {}, - createLoaderTree('[category]', { - sidebar: createLoaderTree('[filter]'), - }) - ) - ) - ) - const params: Params = { lang: 'en' } - // Pathname with placeholder at depth 2: /en/products/[category] - const pathname = '/en/products/[category]' - const fallbackRouteParams: FallbackRouteParam[] = [ - createFallbackParam('category', false), // category at depth 2 is unknown - ] - - resolveParallelRouteParams( - loaderTree, - params, - pathname, - fallbackRouteParams - ) - - // Should NOT extract because pathSegments[2] = '[category]' is a placeholder - expect(params.filter).toBeUndefined() - expect(fallbackRouteParams).toHaveLength(2) - expect(fallbackRouteParams[1]).toEqual({ - paramName: 'filter', - paramType: 'dynamic', - isParallelRouteParam: true, - }) - }) - }) - - describe('catchall with non-parallel fallback params', () => { - it('should add to fallbackRouteParams when non-parallel fallback params exist', () => { - // Tree: / -> @sidebar/[...catchallParam] - const loaderTree = createLoaderTree('', { - sidebar: createLoaderTree('[...catchallParam]'), - }) - const params: Params = {} - const pathname = '/some/path/segments' - const fallbackRouteParams: FallbackRouteParam[] = [ - createFallbackParam('regularParam', false), // Non-parallel fallback param - ] - - resolveParallelRouteParams( - loaderTree, - params, - pathname, - fallbackRouteParams - ) - - expect(params.catchallParam).toBeUndefined() - expect(fallbackRouteParams).toHaveLength(2) - expect(fallbackRouteParams[1]).toEqual({ - paramName: 'catchallParam', - paramType: 'catchall', - isParallelRouteParam: true, - }) - }) - }) - - describe('optional-catchall with non-parallel fallback params', () => { - it('should add to fallbackRouteParams when non-parallel fallback params exist', () => { - // Tree: / -> @sidebar/[[...optionalCatchall]] - const loaderTree = createLoaderTree('', { - sidebar: createLoaderTree('[[...optionalCatchall]]'), - }) - const params: Params = {} - const pathname = '/some/path' - const fallbackRouteParams: FallbackRouteParam[] = [ - createFallbackParam('regularParam', false), // Non-parallel fallback param - ] - - resolveParallelRouteParams( - loaderTree, - params, - pathname, - fallbackRouteParams - ) - - expect(params.optionalCatchall).toBeUndefined() - expect(fallbackRouteParams).toHaveLength(2) - expect(fallbackRouteParams[1]).toEqual({ - paramName: 'optionalCatchall', - paramType: 'optional-catchall', - isParallelRouteParam: true, - }) - }) - }) - - describe('catchall deriving from pathname with depth', () => { - it('should use depth to correctly slice pathname segments', () => { - // Tree: /blog -> @sidebar/[...catchallParam] - // At depth 1 (after /blog), should get remaining segments - const loaderTree = createLoaderTree( - '', - {}, - createLoaderTree('blog', { - sidebar: createLoaderTree('[...catchallParam]'), - }) - ) - const params: Params = {} - const pathname = '/blog/2023/posts/my-article' - const fallbackRouteParams: FallbackRouteParam[] = [] - - resolveParallelRouteParams( - loaderTree, - params, - pathname, - fallbackRouteParams - ) - - // Should get segments from depth 1 onwards - expect(params.catchallParam).toEqual(['2023', 'posts', 'my-article']) - expect(fallbackRouteParams).toHaveLength(0) - }) - - it('should handle catchall at depth 0 (root level)', () => { - // Tree: / -> @sidebar/[...catchallParam] - const loaderTree = createLoaderTree('', { - sidebar: createLoaderTree('[...catchallParam]'), - }) - const params: Params = {} - const pathname = '/blog/2023/posts' - const fallbackRouteParams: FallbackRouteParam[] = [] - - resolveParallelRouteParams( - loaderTree, - params, - pathname, - fallbackRouteParams - ) - - // Should get all segments - expect(params.catchallParam).toEqual(['blog', '2023', 'posts']) - expect(fallbackRouteParams).toHaveLength(0) - }) - - it('should handle nested depth correctly', () => { - // Tree: /products/[category] -> @filters/[...filterPath] - const loaderTree = createLoaderTree( - '', - {}, - createLoaderTree( - 'products', - {}, - createLoaderTree('[category]', { - filters: createLoaderTree('[...filterPath]'), - }) - ) - ) - const params: Params = { category: 'electronics' } - const pathname = '/products/electronics/phones/iphone' - const fallbackRouteParams: FallbackRouteParam[] = [] - - resolveParallelRouteParams( - loaderTree, - params, - pathname, - fallbackRouteParams - ) - - // Should get segments from depth 2 onwards (after /products/[category]) - expect(params.filterPath).toEqual(['phones', 'iphone']) - expect(fallbackRouteParams).toHaveLength(0) - }) - - it('should handle single path segment', () => { - // Tree: / -> @sidebar/[...catchallParam] - const loaderTree = createLoaderTree('', { - sidebar: createLoaderTree('[...catchallParam]'), - }) - const params: Params = {} - const pathname = '/single' - const fallbackRouteParams: FallbackRouteParam[] = [] - - resolveParallelRouteParams( - loaderTree, - params, - pathname, - fallbackRouteParams - ) - - expect(params.catchallParam).toEqual(['single']) - expect(fallbackRouteParams).toHaveLength(0) - }) - }) - - describe('route groups', () => { - it('should not increment depth for route groups', () => { - // Tree: /(marketing) -> @sidebar/[...catchallParam] - // Route groups don't contribute to pathname depth - const loaderTree = createLoaderTree( - '', - {}, - createLoaderTree('(marketing)', { - sidebar: createLoaderTree('[...catchallParam]'), - }) - ) - const params: Params = {} - const pathname = '/blog/post' - const fallbackRouteParams: FallbackRouteParam[] = [] - - resolveParallelRouteParams( - loaderTree, - params, - pathname, - fallbackRouteParams - ) - - // Should get all segments since route group doesn't increment depth - expect(params.catchallParam).toEqual(['blog', 'post']) - expect(fallbackRouteParams).toHaveLength(0) - }) - - it('should handle multiple route groups', () => { - // Tree: /(group1)/(group2)/blog -> @sidebar/[...path] - const loaderTree = createLoaderTree( - '', - {}, - createLoaderTree( - '(group1)', - {}, - createLoaderTree( - '(group2)', - {}, - createLoaderTree('blog', { - sidebar: createLoaderTree('[...path]'), - }) - ) - ) - ) - const params: Params = {} - const pathname = '/blog/2023/posts' - const fallbackRouteParams: FallbackRouteParam[] = [] - - resolveParallelRouteParams( - loaderTree, - params, - pathname, - fallbackRouteParams - ) - - // Should get segments from depth 1 (after /blog), route groups don't count - expect(params.path).toEqual(['2023', 'posts']) - expect(fallbackRouteParams).toHaveLength(0) - }) - }) - - describe('optional-catchall with empty pathname', () => { - it('should set params to empty array when pathname has no segments', () => { - // Tree: / -> @sidebar/[[...optionalCatchall]] - const loaderTree = createLoaderTree('', { - sidebar: createLoaderTree('[[...optionalCatchall]]'), - }) - const params: Params = {} - const pathname = '/' - const fallbackRouteParams: FallbackRouteParam[] = [] - - resolveParallelRouteParams( - loaderTree, - params, - pathname, - fallbackRouteParams - ) - - expect(params.optionalCatchall).toEqual([]) - expect(fallbackRouteParams).toHaveLength(0) - }) - - it('should handle optional catchall at nested depth with no remaining segments', () => { - // Tree: /blog -> @sidebar/[[...optionalPath]] - const loaderTree = createLoaderTree( - '', - {}, - createLoaderTree('blog', { - sidebar: createLoaderTree('[[...optionalPath]]'), - }) - ) - const params: Params = {} - const pathname = '/blog' - const fallbackRouteParams: FallbackRouteParam[] = [] - - resolveParallelRouteParams( - loaderTree, - params, - pathname, - fallbackRouteParams - ) - - expect(params.optionalPath).toEqual([]) - expect(fallbackRouteParams).toHaveLength(0) - }) - }) - - describe('optional-catchall with non-empty pathname', () => { - it('should populate params with path segments', () => { - // Tree: / -> @sidebar/[[...optionalCatchall]] - const loaderTree = createLoaderTree('', { - sidebar: createLoaderTree('[[...optionalCatchall]]'), - }) - const params: Params = {} - const pathname = '/api/v1/users' - const fallbackRouteParams: FallbackRouteParam[] = [] - - resolveParallelRouteParams( - loaderTree, - params, - pathname, - fallbackRouteParams - ) - - expect(params.optionalCatchall).toEqual(['api', 'v1', 'users']) - expect(fallbackRouteParams).toHaveLength(0) - }) - }) - - describe('catchall-intercepted params', () => { - it('should handle catchall-intercepted params in parallel routes', () => { - // Tree: / -> @modal/[...path] where [...path] uses catchall-intercepted type - // Note: catchall-intercepted is a param type, not related to interception routes - const loaderTree = createLoaderTree('', { - modal: createLoaderTree('[...path]'), - }) - const params: Params = {} - const pathname = '/photos/album/2023' - const fallbackRouteParams: FallbackRouteParam[] = [] - - resolveParallelRouteParams( - loaderTree, - params, - pathname, - fallbackRouteParams - ) - - // Should get all segments - expect(params.path).toEqual(['photos', 'album', '2023']) - expect(fallbackRouteParams).toHaveLength(0) - }) - }) - - describe('error cases', () => { - it('should throw error for catchall with empty pathname', () => { - // Tree: / -> @sidebar/[...catchallParam] - const loaderTree = createLoaderTree('', { - sidebar: createLoaderTree('[...catchallParam]'), - }) - const params: Params = {} - const pathname = '/' - const fallbackRouteParams: FallbackRouteParam[] = [] - - expect(() => - resolveParallelRouteParams( - loaderTree, - params, - pathname, - fallbackRouteParams - ) - ).toThrow(/Unexpected empty path segments/) - }) - - it('should throw error for catchall when depth exceeds pathname', () => { - // Tree: /blog/posts -> @sidebar/[...catchallParam] - // But pathname is just /blog - const loaderTree = createLoaderTree( - '', - {}, - createLoaderTree( - 'blog', - {}, - createLoaderTree('posts', { - sidebar: createLoaderTree('[...catchallParam]'), - }) - ) - ) - const params: Params = {} - const pathname = '/blog' - const fallbackRouteParams: FallbackRouteParam[] = [] - - expect(() => - resolveParallelRouteParams( - loaderTree, - params, - pathname, - fallbackRouteParams - ) - ).toThrow(/Unexpected empty path segments/) - }) - }) - - describe('complex scenarios', () => { - it('should handle multiple parallel routes at same level', () => { - // Tree: / -> @sidebar/[...sidebarPath] + @modal/[[...modalPath]] - const loaderTree = createLoaderTree('', { - sidebar: createLoaderTree('[...sidebarPath]'), - modal: createLoaderTree('[[...modalPath]]'), - }) - const params: Params = {} - const pathname = '/products/electronics' - const fallbackRouteParams: FallbackRouteParam[] = [] - - resolveParallelRouteParams( - loaderTree, - params, - pathname, - fallbackRouteParams - ) - - expect(params.sidebarPath).toEqual(['products', 'electronics']) - expect(params.modalPath).toEqual(['products', 'electronics']) - expect(fallbackRouteParams).toHaveLength(0) - }) - - it('should handle parallel route with embedded dynamic param from pathname', () => { - // Tree: /[lang] -> @sidebar/[...path] - const loaderTree = createLoaderTree( - '', - {}, - createLoaderTree('[lang]', { - sidebar: createLoaderTree('[...path]'), - }) - ) - const params: Params = { lang: 'en' } - const pathname = '/en/blog/post' - const fallbackRouteParams: FallbackRouteParam[] = [] - - resolveParallelRouteParams( - loaderTree, - params, - pathname, - fallbackRouteParams - ) - - // Should resolve [lang] in path segments to 'en' - expect(params.path).toEqual(['blog', 'post']) - expect(fallbackRouteParams).toHaveLength(0) - }) - - it('should only process parallel routes, not children route', () => { - // Tree: / -> children: /blog, sidebar: /[...path] - const loaderTree = createLoaderTree( - '', - { - sidebar: createLoaderTree('[...path]'), - }, - createLoaderTree('blog') - ) - const params: Params = {} - const pathname = '/blog/post' - const fallbackRouteParams: FallbackRouteParam[] = [] - - resolveParallelRouteParams( - loaderTree, - params, - pathname, - fallbackRouteParams - ) - - // Should only process @sidebar, not children - expect(params.path).toEqual(['blog', 'post']) - expect(fallbackRouteParams).toHaveLength(0) - }) - }) - - describe('interception routes', () => { - it('should increment depth for (.) interception route (same level)', () => { - // Tree: /(.)photo -> @modal/[...segments] - // Interception routes should increment depth unlike route groups - const loaderTree = createLoaderTree( - '', - {}, - createLoaderTree('(.)photo', { - modal: createLoaderTree('[...segments]'), - }) - ) - const params: Params = {} - const pathname = '/photo/123/details' - const fallbackRouteParams: FallbackRouteParam[] = [] - - resolveParallelRouteParams( - loaderTree, - params, - pathname, - fallbackRouteParams - ) - - // Should get segments from depth 1 onwards (after /(.)photo) - expect(params.segments).toEqual(['123', 'details']) - expect(fallbackRouteParams).toHaveLength(0) - }) - - it('should increment depth for (..) interception route (parent level)', () => { - // Tree: /gallery/(..)photo -> @modal/[id] - const loaderTree = createLoaderTree( - '', - {}, - createLoaderTree( - 'gallery', - {}, - createLoaderTree('(..)photo', { - modal: createLoaderTree('[id]'), - }) - ) - ) - const params: Params = {} - const pathname = '/gallery/photo/123' - const fallbackRouteParams: FallbackRouteParam[] = [] - - resolveParallelRouteParams( - loaderTree, - params, - pathname, - fallbackRouteParams - ) - - // (..)photo is at depth 1, @modal/[id] should extract from depth 2 - expect(params.id).toBe('123') - expect(fallbackRouteParams).toHaveLength(0) - }) - - it('should increment depth for (...) interception route (root level)', () => { - // Tree: /app/gallery/(...)photo -> @modal/[...path] - const loaderTree = createLoaderTree( - '', - {}, - createLoaderTree( - 'app', - {}, - createLoaderTree( - 'gallery', - {}, - createLoaderTree('(...)photo', { - modal: createLoaderTree('[...path]'), - }) - ) - ) - ) - const params: Params = {} - const pathname = '/app/gallery/photo/2023/album' - const fallbackRouteParams: FallbackRouteParam[] = [] - - resolveParallelRouteParams( - loaderTree, - params, - pathname, - fallbackRouteParams - ) - - // (...)photo is at depth 2, @modal/[...path] should extract from depth 3 - expect(params.path).toEqual(['2023', 'album']) - expect(fallbackRouteParams).toHaveLength(0) - }) - - it('should increment depth for (..)(..) interception route (grandparent level)', () => { - // Tree: /a/b/(..)(..)photo -> @modal/[category] - const loaderTree = createLoaderTree( - '', - {}, - createLoaderTree( - 'a', - {}, - createLoaderTree( - 'b', - {}, - createLoaderTree('(..)(..)photo', { - modal: createLoaderTree('[category]'), - }) - ) - ) - ) - const params: Params = {} - const pathname = '/a/b/photo/nature' - const fallbackRouteParams: FallbackRouteParam[] = [] - - resolveParallelRouteParams( - loaderTree, - params, - pathname, - fallbackRouteParams - ) - - // (..)(..)photo is at depth 2, @modal/[category] should extract from depth 3 - expect(params.category).toBe('nature') - expect(fallbackRouteParams).toHaveLength(0) - }) - - it('should distinguish interception routes from regular route groups', () => { - // Tree: /(marketing) -> @sidebar/[...path] (route group) - // vs: /(.)photo -> @modal/[...path] (interception route) - const routeGroupTree = createLoaderTree( - '', - {}, - createLoaderTree('(marketing)', { - sidebar: createLoaderTree('[...path]'), - }) - ) - - const interceptionTree = createLoaderTree( - '', - {}, - createLoaderTree('(.)photo', { - modal: createLoaderTree('[...path]'), - }) - ) - - const pathname = '/photo/123' - - // Route group - should NOT increment depth - const routeGroupParams: Params = {} - const routeGroupFallback: FallbackRouteParam[] = [] - resolveParallelRouteParams( - routeGroupTree, - routeGroupParams, - pathname, - routeGroupFallback - ) - // Gets all segments because route group doesn't increment depth - expect(routeGroupParams.path).toEqual(['photo', '123']) - - // Interception route - SHOULD increment depth - const interceptionParams: Params = {} - const interceptionFallback: FallbackRouteParam[] = [] - resolveParallelRouteParams( - interceptionTree, - interceptionParams, - pathname, - interceptionFallback - ) - // Gets segments from depth 1 because (.)photo increments depth - expect(interceptionParams.path).toEqual(['123']) - }) - }) - - describe('empty pathname edge cases', () => { - it('should mark dynamic param as fallback when pathname is empty', () => { - // Tree: / -> @modal/[id] - const loaderTree = createLoaderTree('', { - modal: createLoaderTree('[id]'), - }) - const params: Params = {} - const pathname = '/' - const fallbackRouteParams: FallbackRouteParam[] = [] - - resolveParallelRouteParams( - loaderTree, - params, - pathname, - fallbackRouteParams - ) - - expect(params.id).toBeUndefined() - expect(fallbackRouteParams).toHaveLength(1) - expect(fallbackRouteParams[0]).toEqual({ - paramName: 'id', - paramType: 'dynamic', - isParallelRouteParam: true, - }) - }) - - it('should mark multiple dynamic params as fallback when pathname is empty', () => { - // Tree: / -> @modal/[category] + @sidebar/[filter] - const loaderTree = createLoaderTree('', { - modal: createLoaderTree('[category]'), - sidebar: createLoaderTree('[filter]'), - }) - const params: Params = {} - const pathname = '/' - const fallbackRouteParams: FallbackRouteParam[] = [] - - resolveParallelRouteParams( - loaderTree, - params, - pathname, - fallbackRouteParams - ) - - expect(params.category).toBeUndefined() - expect(params.filter).toBeUndefined() - expect(fallbackRouteParams).toHaveLength(2) - expect(fallbackRouteParams).toContainEqual({ - paramName: 'category', - paramType: 'dynamic', - isParallelRouteParam: true, - }) - expect(fallbackRouteParams).toContainEqual({ - paramName: 'filter', - paramType: 'dynamic', - isParallelRouteParam: true, - }) - }) - - it('should handle nested parallel route with empty pathname at that depth', () => { - // Tree: /blog -> @modal/[id] - const loaderTree = createLoaderTree( - '', - {}, - createLoaderTree('blog', { - modal: createLoaderTree('[id]'), - }) - ) - const params: Params = {} - const pathname = '/blog' - const fallbackRouteParams: FallbackRouteParam[] = [] - - resolveParallelRouteParams( - loaderTree, - params, - pathname, - fallbackRouteParams - ) - - // No segment at depth 1, should mark as fallback - expect(params.id).toBeUndefined() - expect(fallbackRouteParams).toHaveLength(1) - expect(fallbackRouteParams[0]).toEqual({ - paramName: 'id', - paramType: 'dynamic', - isParallelRouteParam: true, - }) - }) - }) - - describe('complex path segments', () => { - it('should handle catch-all with embedded param placeholders in pathname', () => { - // Tree: / -> @sidebar/[...path] - // Pathname contains a placeholder like [category] which is unknown - const loaderTree = createLoaderTree('', { - sidebar: createLoaderTree('[...path]'), - }) - const params: Params = {} - const pathname = '/blog/[category]/tech' - const fallbackRouteParams: FallbackRouteParam[] = [ - createFallbackParam('category', false), // category is unknown - ] - - resolveParallelRouteParams( - loaderTree, - params, - pathname, - fallbackRouteParams - ) - - // Should mark as fallback because there's a non-parallel fallback param - expect(params.path).toBeUndefined() - expect(fallbackRouteParams).toHaveLength(2) - expect(fallbackRouteParams[1]).toEqual({ - paramName: 'path', - paramType: 'catchall', - isParallelRouteParam: true, - }) - }) - - it('should mark catch-all as fallback when pathname has unknown param placeholder', () => { - // Tree: /[lang] -> @sidebar/[...path] - // Pathname has [lang] which is known, but [category] which is not - const loaderTree = createLoaderTree( - '', - {}, - createLoaderTree('[lang]', { - sidebar: createLoaderTree('[...path]'), - }) - ) - const params: Params = { lang: 'en' } - const pathname = '/en/blog/[category]' - const fallbackRouteParams: FallbackRouteParam[] = [] - - resolveParallelRouteParams( - loaderTree, - params, - pathname, - fallbackRouteParams - ) - - // Should mark path as fallback because pathname contains unknown [category] placeholder - expect(params.path).toBeUndefined() - expect(fallbackRouteParams).toHaveLength(1) - expect(fallbackRouteParams[0]).toEqual({ - paramName: 'path', - paramType: 'catchall', - isParallelRouteParam: true, - }) - }) - - it('should handle mixed static and dynamic segments in catch-all resolution', () => { - // Tree: /products/[category] -> @filters/[...filterPath] - const loaderTree = createLoaderTree( - '', - {}, - createLoaderTree( - 'products', - {}, - createLoaderTree('[category]', { - filters: createLoaderTree('[...filterPath]'), - }) - ) - ) - const params: Params = { category: 'electronics' } - const pathname = '/products/electronics/brand/apple/price/high' - const fallbackRouteParams: FallbackRouteParam[] = [] - - resolveParallelRouteParams( - loaderTree, - params, - pathname, - fallbackRouteParams - ) - - // Should get remaining path after resolving category - expect(params.filterPath).toEqual(['brand', 'apple', 'price', 'high']) - expect(fallbackRouteParams).toHaveLength(0) - }) - }) - - describe('integration scenarios', () => { - it('should handle interception route + parallel route together', () => { - // Tree: /gallery/(.)photo -> @modal/[id] + @sidebar/[category] - const loaderTree = createLoaderTree( - '', - {}, - createLoaderTree( - 'gallery', - {}, - createLoaderTree('(.)photo', { - modal: createLoaderTree('[id]'), - sidebar: createLoaderTree('[category]'), - }) - ) - ) - const params: Params = {} - const pathname = '/gallery/photo/123' - const fallbackRouteParams: FallbackRouteParam[] = [] - - resolveParallelRouteParams( - loaderTree, - params, - pathname, - fallbackRouteParams - ) - - // Both should extract from depth 2 (after /gallery/(.)photo) - expect(params.id).toBe('123') - expect(params.category).toBe('123') - expect(fallbackRouteParams).toHaveLength(0) - }) - - it('should handle route group + parallel route + interception route', () => { - // Tree: /(marketing)/gallery/(.)photo -> @modal/[...path] - const loaderTree = createLoaderTree( - '', - {}, - createLoaderTree( - '(marketing)', - {}, - createLoaderTree( - 'gallery', - {}, - createLoaderTree('(.)photo', { - modal: createLoaderTree('[...path]'), - }) - ) - ) - ) - const params: Params = {} - const pathname = '/gallery/photo/2023/album' - const fallbackRouteParams: FallbackRouteParam[] = [] - - resolveParallelRouteParams( - loaderTree, - params, - pathname, - fallbackRouteParams - ) - - // Route group doesn't increment, gallery does, (.)photo does - // So depth is 2, extract from depth 2 onwards - expect(params.path).toEqual(['2023', 'album']) - expect(fallbackRouteParams).toHaveLength(0) - }) - - it('should handle all param types together', () => { - // Tree: /[lang] -> @modal/[category] + @sidebar/[...tags] + @info/[[...extra]] - const loaderTree = createLoaderTree( - '', - {}, - createLoaderTree('[lang]', { - modal: createLoaderTree('[category]'), - sidebar: createLoaderTree('[...tags]'), - info: createLoaderTree('[[...extra]]'), - }) - ) - const params: Params = { lang: 'en' } - const pathname = '/en/tech/react/nextjs' - const fallbackRouteParams: FallbackRouteParam[] = [] - - resolveParallelRouteParams( - loaderTree, - params, - pathname, - fallbackRouteParams - ) - - // All should extract from depth 1 onwards - expect(params.category).toBe('tech') - expect(params.tags).toEqual(['tech', 'react', 'nextjs']) - expect(params.extra).toEqual(['tech', 'react', 'nextjs']) - expect(fallbackRouteParams).toHaveLength(0) - }) - - it('should handle complex nesting with multiple interception routes', () => { - // Tree: /app/(.)modal/(.)photo -> @dialog/[id] - const loaderTree = createLoaderTree( - '', - {}, - createLoaderTree( - 'app', - {}, - createLoaderTree( - '(.)modal', - {}, - createLoaderTree('(.)photo', { - dialog: createLoaderTree('[id]'), - }) - ) - ) - ) - const params: Params = {} - const pathname = '/app/modal/photo/image-123' - const fallbackRouteParams: FallbackRouteParam[] = [] - - resolveParallelRouteParams( - loaderTree, - params, - pathname, - fallbackRouteParams - ) - - // app (depth 1) + (.)modal (depth 2) + (.)photo (depth 3) -> extract at depth 3 - expect(params.id).toBe('image-123') - expect(fallbackRouteParams).toHaveLength(0) - }) - }) -}) - -/** - * Test coverage note for dynamicParams validation in buildAppStaticPaths: - * - * The two-phase validation for segments with `dynamicParams: false` should be tested - * in integration/e2e tests due to the complexity of mocking buildAppStaticPaths dependencies. - * - * Key scenarios to test: - * - * Phase 1 (Children route validation - lines 972-997): - * - ✅ Should throw error when children route segment has dynamicParams: false - * but param is missing from generateStaticParams - * - ✅ Should skip validation for parallel route segments (tested in Phase 2) - * - * Phase 2 (Parallel route validation - lines 1159-1201): - * - ✅ Should throw error when parallel route segment has dynamicParams: false - * but param cannot be resolved from pathname - * - ✅ Should throw error when parallel route segment has dynamicParams: false - * but param is marked as fallback (requires request-time resolution) - * - ✅ Should succeed when parallel route param can be derived from pathname - * - ✅ Should succeed when parallel route param is provided via generateStaticParams - * - * Example test structure for e2e: - * - * app/ - * @modal/[category]/page.tsx // dynamicParams: false, no generateStaticParams - * [slug]/page.tsx // generateStaticParams: [{slug: 'post-1'}] - * - * Expected behavior: - * - If @modal/[category] can derive category from pathname "/post-1": ✅ Success - * - If @modal/[category] cannot derive category: ❌ Phase 2 error with pathname context - * - * app/ - * [slug]/page.tsx // dynamicParams: false, no generateStaticParams - * - * Expected behavior: - * - ❌ Phase 1 error: param missing from generateStaticParams - */ diff --git a/packages/next/src/build/static-paths/app.ts b/packages/next/src/build/static-paths/app.ts index 051e513d11fd4..59595df54c88b 100644 --- a/packages/next/src/build/static-paths/app.ts +++ b/packages/next/src/build/static-paths/app.ts @@ -12,26 +12,22 @@ import { AfterRunner } from '../../server/after/run-with-after' import { createWorkStore } from '../../server/async-storage/work-store' import { FallbackMode } from '../../lib/fallback' import type { IncrementalCache } from '../../server/lib/incremental-cache' -import type { LoaderTree } from '../../server/lib/app-dir-module' import { normalizePathname, encodeParam, - createFallbackRouteParam, + extractPathnameRouteParamSegments, + resolveRouteParamsFromTree, } from './utils' import escapePathDelimiters from '../../shared/lib/router/utils/escape-path-delimiters' import { createIncrementalCache } from '../../export/helpers/create-incremental-cache' import type { NextConfigComplete } from '../../server/config-shared' import type { WorkStore } from '../../server/app-render/work-async-storage.external' import type { DynamicParamTypes } from '../../shared/lib/app-router-types' -import { InvariantError } from '../../shared/lib/invariant-error' -import { - getParamProperties, - getSegmentParam, -} from '../../shared/lib/router/utils/get-segment-param' -import { parseLoaderTree } from '../../shared/lib/router/utils/parse-loader-tree' -import { INTERCEPTION_ROUTE_MARKERS } from '../../shared/lib/router/utils/interception-routes' +import { getParamProperties } from '../../shared/lib/router/utils/get-segment-param' import { throwEmptyGenerateStaticParamsError } from '../../shared/lib/errors/empty-generate-static-params-error' import type { AppRouteModule } from '../../server/route-modules/app-route/module.compiled' +import type { NormalizedAppRoute } from '../../shared/lib/router/routes/app' +import { interceptionPrefixFromParamType } from '../../shared/lib/router/utils/interception-prefix-from-param-type' /** * Filters out duplicate parameters from a list of parameters. @@ -306,7 +302,7 @@ export function calculateFallbackMode( * @param page - The page to validate. * @param regex - The route regex. * @param isRoutePPREnabled - Whether the route has partial prerendering enabled. - * @param childrenRouteParamSegments - The keys of the parameters. + * @param pathnameSegments - The keys of the parameters. * @param rootParamKeys - The keys of the root params. * @param routeParams - The list of parameters to validate. * @returns The list of validated parameters. @@ -314,7 +310,7 @@ export function calculateFallbackMode( function validateParams( page: string, isRoutePPREnabled: boolean, - childrenRouteParamSegments: ReadonlyArray<{ + pathnameSegments: ReadonlyArray<{ readonly paramName: string readonly paramType: DynamicParamTypes }>, @@ -347,7 +343,7 @@ function validateParams( for (const params of routeParams) { const item: Params = {} - for (const { paramName: key, paramType } of childrenRouteParamSegments) { + for (const { paramName: key, paramType } of pathnameSegments) { const { repeat, optional } = getParamProperties(paramType) let paramValue = params[key] @@ -419,11 +415,11 @@ interface TrieNode { * `/blog/[slug]` should not throw because `/blog/first-post` is a more specific concrete route. * * @param prerenderedRoutes - The prerendered routes. - * @param childrenRouteParams - The keys of the route parameters. + * @param pathnameSegments - The keys of the route parameters. */ export function assignErrorIfEmpty( prerenderedRoutes: readonly PrerenderedRoute[], - childrenRouteParams: ReadonlyArray<{ + pathnameSegments: ReadonlyArray<{ readonly paramName: string }> ): void { @@ -446,7 +442,7 @@ export function assignErrorIfEmpty( // for ensuring that routes with the same concrete parameters follow the // same path in the Trie, regardless of the original order of properties // in the `params` object. - for (const { paramName: key } of childrenRouteParams) { + for (const { paramName: key } of pathnameSegments) { // Check if the current route actually has a concrete value for this parameter. // If a dynamic segment is not filled (i.e., it's a fallback), it won't have // this property, and we stop building the path for this route at this point. @@ -552,171 +548,6 @@ export function assignErrorIfEmpty( } } -/** - * Resolves parallel route parameters from the loader tree. This function uses - * tree-based traversal to correctly handle the hierarchical structure of parallel - * routes and accurately determine parameter values based on their depth in the tree. - * - * Unlike interpolateParallelRouteParams (which has a complete URL at runtime), - * this build-time function determines which parallel route params are unknown. - * The pathname may contain placeholders like [slug], making it incomplete. - * - * @param loaderTree - The loader tree structure containing route hierarchy - * @param params - The current route parameters object (will be mutated) - * @param pathname - The current pathname being processed (may contain placeholders) - * @param fallbackRouteParams - Array of fallback route parameters (will be mutated) - */ -export function resolveParallelRouteParams( - loaderTree: LoaderTree, - params: Params, - pathname: string, - fallbackRouteParams: FallbackRouteParam[] -): void { - // Stack-based traversal with depth and parallel route key tracking - const stack: Array<{ - tree: LoaderTree - depth: number - parallelKey: string - }> = [{ tree: loaderTree, depth: 0, parallelKey: 'children' }] - - // Parse pathname into segments for depth-based resolution - const pathSegments = pathname.split('/').filter(Boolean) - - while (stack.length > 0) { - const { tree, depth, parallelKey } = stack.pop()! - const { segment, parallelRoutes } = parseLoaderTree(tree) - - // Only process segments that are in parallel routes (not the main 'children' route) - if (parallelKey !== 'children') { - const segmentParam = getSegmentParam(segment) - - if (segmentParam && !params.hasOwnProperty(segmentParam.param)) { - const { param: paramName, type: paramType } = segmentParam - - switch (paramType) { - case 'catchall': - case 'optional-catchall': - case 'catchall-intercepted-(..)(..)': - case 'catchall-intercepted-(.)': - case 'catchall-intercepted-(..)': - case 'catchall-intercepted-(...)': - // If there are any non-parallel fallback route segments, we can't use the - // pathname to derive the value because it's not complete. We can make - // this assumption because routes are resolved left to right. - if ( - fallbackRouteParams.some((param) => !param.isParallelRouteParam) - ) { - fallbackRouteParams.push( - createFallbackRouteParam(paramName, paramType, true) - ) - break - } - - // For catchall routes in parallel segments, derive from pathname - // using depth to determine which segments to use - const remainingSegments = pathSegments.slice(depth) - - // Process segments to handle any embedded dynamic params - // Track if we encounter any unknown param placeholders - let hasUnknownParam = false - const processedSegments = remainingSegments - .flatMap((pathSegment) => { - const param = getSegmentParam(pathSegment) - if (param) { - // If the segment is a param placeholder, check if we have its value - if (!params.hasOwnProperty(param.param)) { - // Unknown param placeholder in pathname - can't derive full value - hasUnknownParam = true - return undefined - } - // If the segment matches a param, return the param value - // We don't encode values here as that's handled during retrieval. - return params[param.param] - } - // Otherwise it's a static segment - return pathSegment - }) - .filter((s) => s !== undefined) - - // If we encountered any unknown param placeholders, we can't derive - // the full catch-all value from the pathname, so mark as fallback. - if (hasUnknownParam) { - fallbackRouteParams.push( - createFallbackRouteParam(paramName, paramType, true) - ) - break - } - - if (processedSegments.length > 0) { - params[paramName] = processedSegments - } else if (paramType === 'optional-catchall') { - params[paramName] = [] - } else { - // We shouldn't be able to match a catchall segment without any path - // segments if it's not an optional catchall - throw new InvariantError( - `Unexpected empty path segments match for a pathname "${pathname}" with param "${paramName}" of type "${paramType}"` - ) - } - break - - case 'dynamic': - case 'dynamic-intercepted-(..)(..)': - case 'dynamic-intercepted-(.)': - case 'dynamic-intercepted-(..)': - case 'dynamic-intercepted-(...)': - // For regular dynamic parameters, take the segment at this depth - if (depth < pathSegments.length) { - const pathSegment = pathSegments[depth] - const param = getSegmentParam(pathSegment) - - // Check if the segment at this depth is a placeholder for an unknown param - if (param && !params.hasOwnProperty(param.param)) { - // The segment is a placeholder like [category] and we don't have the value - fallbackRouteParams.push( - createFallbackRouteParam(paramName, paramType, true) - ) - break - } - - // If the segment matches a param, use the param value from params object - // Otherwise it's a static segment, just use it directly - // We don't encode values here as that's handled during retrieval - params[paramName] = param ? params[param.param] : pathSegment - } else { - // No segment at this depth, mark as fallback. - fallbackRouteParams.push( - createFallbackRouteParam(paramName, paramType, true) - ) - } - break - - default: - paramType satisfies never - } - } - } - - // Calculate next depth - increment if this is not a route group and not empty - let nextDepth = depth - // Route groups are like (marketing) or (dashboard), NOT interception routes like (.)photo - // Interception routes start with markers like (.), (..), (...), (..)(..)) and should increment depth - const isInterceptionRoute = INTERCEPTION_ROUTE_MARKERS.some((marker) => - segment.startsWith(marker) - ) - const isRouteGroup = - !isInterceptionRoute && segment.startsWith('(') && segment.endsWith(')') - if (!isRouteGroup && segment !== '') { - nextDepth++ - } - - // Add all parallel routes to the stack for processing. - for (const [key, route] of Object.entries(parallelRoutes)) { - stack.push({ tree: route, depth: nextDepth, parallelKey: key }) - } - } -} - /** * Processes app directory segments to build route parameters from generateStaticParams functions. * This function walks through the segments array and calls generateStaticParams for each segment that has it, @@ -807,15 +638,50 @@ export async function generateRouteStaticParams( return currentParams } +function createReplacements( + segment: Pick, + paramValue: string | string[] +) { + // Determine the prefix to use for the interception marker. + let prefix: string + if (segment.paramType) { + prefix = interceptionPrefixFromParamType(segment.paramType) ?? '' + } else { + prefix = '' + } + + return { + pathname: + prefix + + encodeParam(paramValue, (value) => + // Only escape path delimiters if the value is a string, the following + // version will URL encode the value. + escapePathDelimiters(value, true) + ), + encodedPathname: + prefix + + encodeParam( + paramValue, + // URL encode the value. + encodeURIComponent + ), + } +} + /** - * Builds the static paths for an app using `generateStaticParams`. + * Processes app directory segments to build route parameters from generateStaticParams functions. + * This function walks through the segments array and calls generateStaticParams for each segment that has it, + * combining parent parameters with child parameters to build the complete parameter combinations. + * Uses iterative processing instead of recursion for better performance. * - * @param params - The parameters for the build. - * @returns The static paths. + * @param segments - Array of app directory segments to process + * @param store - Work store for tracking fetch cache configuration + * @returns Promise that resolves to an array of all parameter combinations */ export async function buildAppStaticPaths({ dir, page, + route, distDir, cacheComponents, authInterrupts, @@ -835,6 +701,7 @@ export async function buildAppStaticPaths({ }: { dir: string page: string + route: NormalizedAppRoute cacheComponents: boolean authInterrupts: boolean segments: readonly Readonly[] @@ -876,55 +743,15 @@ export async function buildAppStaticPaths({ cacheMaxMemorySize, }) - const childrenRouteParamSegments: Array<{ - readonly name: string - readonly paramName: string - readonly paramType: DynamicParamTypes - }> = [] - - // These are all the parallel fallback route params that will be included when - // we're emitting the route for the base route. - const parallelFallbackRouteParams: FallbackRouteParam[] = [] - - // First pass: collect all non-parallel route param names. - // This allows us to filter out parallel route params that duplicate non-parallel ones. - const nonParallelParamNames = new Set() - for (const segment of segments) { - if (!segment.paramName || !segment.paramType) continue - if (!segment.isParallelRouteSegment) { - nonParallelParamNames.add(segment.paramName) - } - } - - // Second pass: collect segments, ensuring non-parallel route params take precedence. - for (const segment of segments) { - // If this segment doesn't have a param name then it's not param that we - // need to resolve. - if (!segment.paramName || !segment.paramType) continue - - if (segment.isParallelRouteSegment) { - // Skip parallel route params that are already defined as non-parallel route params. - // Non-parallel route params take precedence because they appear in the URL pathname. - if (nonParallelParamNames.has(segment.paramName)) { - continue - } - - // Collect parallel fallback route params for the base route. - // The actual parallel route param resolution is now handled by - // resolveParallelRouteParams using the loader tree. - parallelFallbackRouteParams.push( - createFallbackRouteParam(segment.paramName, segment.paramType, true) - ) - } else { - // Collect all the route param keys that are not parallel route params. - // These are the ones that will be included in the request pathname. - childrenRouteParamSegments.push({ - name: segment.name, - paramName: segment.paramName, - paramType: segment.paramType, - }) - } - } + // Extract segments that contribute to the pathname. + // For AppPageRouteModule: Traverses the loader tree to find all segments (including + // interception routes in parallel slots) that match the pathname + // For AppRouteRouteModule: Filters the segments array to get non-parallel route params + const pathnameRouteParamSegments = extractPathnameRouteParamSegments( + ComponentMod.routeModule, + segments, + route + ) const afterRunner = new AfterRunner() @@ -962,7 +789,7 @@ export async function buildAppStaticPaths({ // dynamicParams set to false. if ( segment.paramName && - segment.isDynamicSegment && + segment.paramType && segment.config?.dynamicParams === false ) { for (const params of routeParams) { @@ -979,7 +806,8 @@ export async function buildAppStaticPaths({ } if ( - segment.isDynamicSegment && + segment.paramName && + segment.paramType && typeof segment.generateStaticParams !== 'function' ) { lastDynamicSegmentHadGenerateStaticParams = false @@ -990,10 +818,10 @@ export async function buildAppStaticPaths({ // Determine if all the segments have had their parameters provided. const hadAllParamsGenerated = - childrenRouteParamSegments.length === 0 || + pathnameRouteParamSegments.length === 0 || (routeParams.length > 0 && routeParams.every((params) => { - for (const { paramName } of childrenRouteParamSegments) { + for (const { paramName } of pathnameRouteParamSegments) { if (paramName in params) continue return false } @@ -1031,19 +859,20 @@ export async function buildAppStaticPaths({ // routes that won't throw on empty static shell for each of them if // they're available. paramsToProcess = generateAllParamCombinations( - childrenRouteParamSegments, + pathnameRouteParamSegments, routeParams, rootParamKeys ) - // The fallback route params for this route is a combination of the - // parallel route params and the non-parallel route params. - const fallbackRouteParams: readonly FallbackRouteParam[] = [ - ...childrenRouteParamSegments.map(({ paramName, paramType: type }) => - createFallbackRouteParam(paramName, type, false) - ), - ...parallelFallbackRouteParams, - ] + // Collect all the fallback route params for the segments. + const fallbackRouteParams: FallbackRouteParam[] = [] + for (const segment of segments) { + if (!segment.paramName || !segment.paramType) continue + fallbackRouteParams.push({ + paramName: segment.paramName, + paramType: segment.paramType, + }) + } // Add the base route, this is the route with all the placeholders as it's // derived from the `page` string. @@ -1063,11 +892,11 @@ export async function buildAppStaticPaths({ } filterUniqueParams( - childrenRouteParamSegments, + pathnameRouteParamSegments, validateParams( page, isRoutePPREnabled, - childrenRouteParamSegments, + pathnameRouteParamSegments, rootParamKeys, paramsToProcess ) @@ -1077,31 +906,25 @@ export async function buildAppStaticPaths({ const fallbackRouteParams: FallbackRouteParam[] = [] - for (const { - paramName: key, - paramType: type, - } of childrenRouteParamSegments) { - const paramValue = params[key] + for (const { name, paramName, paramType } of pathnameRouteParamSegments) { + const paramValue = params[paramName] if (!paramValue) { if (isRoutePPREnabled) { // Mark remaining params as fallback params. - fallbackRouteParams.push(createFallbackRouteParam(key, type, false)) + fallbackRouteParams.push({ paramName, paramType }) for ( let i = - childrenRouteParamSegments.findIndex( - (param) => param.paramName === key + pathnameRouteParamSegments.findIndex( + (param) => param.paramName === paramName ) + 1; - i < childrenRouteParamSegments.length; + i < pathnameRouteParamSegments.length; i++ ) { - fallbackRouteParams.push( - createFallbackRouteParam( - childrenRouteParamSegments[i].paramName, - childrenRouteParamSegments[i].paramType, - false - ) - ) + fallbackRouteParams.push({ + paramName: pathnameRouteParamSegments[i].paramName, + paramType: pathnameRouteParamSegments[i].paramType, + }) } break } else { @@ -1111,45 +934,39 @@ export async function buildAppStaticPaths({ } } - const segment = childrenRouteParamSegments.find( - ({ paramName }) => paramName === key - ) - if (!segment) { - throw new InvariantError( - `Param ${key} not found in childrenRouteParamSegments ${childrenRouteParamSegments.map(({ paramName }) => paramName).join(', ')}` - ) - } + const replacements = createReplacements({ paramType }, paramValue) pathname = pathname.replace( - segment.name, - encodeParam(paramValue, (value) => escapePathDelimiters(value, true)) + name, + // We're replacing the segment name with the replacement pathname + // which will include the interception marker prefix if it exists. + replacements.pathname ) + encodedPathname = encodedPathname.replace( - segment.name, - encodeParam(paramValue, encodeURIComponent) + name, + // We're replacing the segment name with the replacement encoded + // pathname which will include the encoded param value. + replacements.encodedPathname ) } - // Resolve parallel route params from the loader tree if this is from an - // app page. + // Resolve all route params from the loader tree if this is from an + // app page. This processes both regular route params and parallel route params. if ( 'loaderTree' in ComponentMod.routeModule.userland && Array.isArray(ComponentMod.routeModule.userland.loaderTree) ) { - resolveParallelRouteParams( + resolveRouteParamsFromTree( ComponentMod.routeModule.userland.loaderTree, params, - pathname, + route, fallbackRouteParams ) } const fallbackRootParams: string[] = [] - for (const { paramName, isParallelRouteParam } of fallbackRouteParams) { - // Only add the param to the fallback root params if it's not a - // parallel route param. They won't contribute to the request pathname. - if (isParallelRouteParam) continue - + for (const { paramName } of fallbackRouteParams) { // If the param is a root param then we can add it to the fallback // root params. if (rootParamSet.has(paramName)) { @@ -1183,7 +1000,7 @@ export async function buildAppStaticPaths({ // Now we have to set the throwOnEmptyStaticShell for each of the routes. if (prerenderedRoutes && cacheComponents) { - assignErrorIfEmpty(prerenderedRoutes, childrenRouteParamSegments) + assignErrorIfEmpty(prerenderedRoutes, pathnameRouteParamSegments) } return { fallbackMode, prerenderedRoutes } diff --git a/packages/next/src/build/static-paths/app/extract-pathname-route-param-segments-from-loader-tree.test.ts b/packages/next/src/build/static-paths/app/extract-pathname-route-param-segments-from-loader-tree.test.ts new file mode 100644 index 0000000000000..8793b5b78d442 --- /dev/null +++ b/packages/next/src/build/static-paths/app/extract-pathname-route-param-segments-from-loader-tree.test.ts @@ -0,0 +1,1354 @@ +import { parseAppRoute } from '../../../shared/lib/router/routes/app' +import { extractPathnameRouteParamSegmentsFromLoaderTree } from './extract-pathname-route-param-segments-from-loader-tree' + +// Helper to create LoaderTree structures for testing +type TestLoaderTree = [ + segment: string, + parallelRoutes: { [key: string]: TestLoaderTree }, + modules: Record, +] + +function createLoaderTree( + segment: string, + parallelRoutes: { [key: string]: TestLoaderTree } = {}, + children?: TestLoaderTree +): TestLoaderTree { + const routes = children ? { ...parallelRoutes, children } : parallelRoutes + return [segment, routes, {}] +} + +describe('extractPathnameRouteParamSegmentsFromLoaderTree', () => { + describe('Regular Routes (children segments)', () => { + it('should extract single dynamic segment from children route', () => { + // Tree: /[slug] + const loaderTree = createLoaderTree('', {}, createLoaderTree('[slug]')) + const route = parseAppRoute('/[slug]', true) + const { pathnameRouteParamSegments: result } = + extractPathnameRouteParamSegmentsFromLoaderTree(loaderTree, route) + + expect(result).toEqual([ + { name: '[slug]', paramName: 'slug', paramType: 'dynamic' }, + ]) + }) + + it('should extract multiple nested dynamic segments', () => { + // Tree: /[category]/[slug] + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree('[category]', {}, createLoaderTree('[slug]')) + ) + const route = parseAppRoute('/[category]/[slug]', true) + const { pathnameRouteParamSegments: result } = + extractPathnameRouteParamSegmentsFromLoaderTree(loaderTree, route) + + expect(result).toEqual([ + { name: '[category]', paramName: 'category', paramType: 'dynamic' }, + { name: '[slug]', paramName: 'slug', paramType: 'dynamic' }, + ]) + }) + + it('should extract catchall segment', () => { + // Tree: /[...slug] + const loaderTree = createLoaderTree('', {}, createLoaderTree('[...slug]')) + const route = parseAppRoute('/[...slug]', true) + const { pathnameRouteParamSegments: result } = + extractPathnameRouteParamSegmentsFromLoaderTree(loaderTree, route) + + expect(result).toEqual([ + { name: '[...slug]', paramName: 'slug', paramType: 'catchall' }, + ]) + }) + + it('should extract optional catchall segment', () => { + // Tree: /[[...slug]] + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree('[[...slug]]') + ) + const route = parseAppRoute('/[[...slug]]', true) + const { pathnameRouteParamSegments: result } = + extractPathnameRouteParamSegmentsFromLoaderTree(loaderTree, route) + + expect(result).toEqual([ + { + name: '[[...slug]]', + paramName: 'slug', + paramType: 'optional-catchall', + }, + ]) + }) + + it('should extract mixed static and dynamic segments', () => { + // Tree: /blog/[category]/posts/[slug] + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree( + 'blog', + {}, + createLoaderTree( + '[category]', + {}, + createLoaderTree('posts', {}, createLoaderTree('[slug]')) + ) + ) + ) + const route = parseAppRoute('/blog/[category]/posts/[slug]', true) + const { pathnameRouteParamSegments: result } = + extractPathnameRouteParamSegmentsFromLoaderTree(loaderTree, route) + + expect(result).toEqual([ + { name: '[category]', paramName: 'category', paramType: 'dynamic' }, + { name: '[slug]', paramName: 'slug', paramType: 'dynamic' }, + ]) + }) + + it('should handle route with no dynamic segments', () => { + // Tree: /blog/posts + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree('blog', {}, createLoaderTree('posts')) + ) + const route = parseAppRoute('/blog/posts', true) + const { pathnameRouteParamSegments: result } = + extractPathnameRouteParamSegmentsFromLoaderTree(loaderTree, route) + + expect(result).toEqual([]) + }) + + it('should extract only segments matching the target pathname', () => { + // Tree: /blog/[category] but target pathname is /[category] + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree('blog', {}, createLoaderTree('[category]')) + ) + const route = parseAppRoute('/[category]', true) + const { pathnameRouteParamSegments: result } = + extractPathnameRouteParamSegmentsFromLoaderTree(loaderTree, route) + + // Should not match because depths don't align + expect(result).toEqual([]) + }) + }) + + describe('Route Groups', () => { + it('should ignore route groups when extracting segments', () => { + // Tree: /(marketing)/blog/[slug] + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree( + '(marketing)', + {}, + createLoaderTree('blog', {}, createLoaderTree('[slug]')) + ) + ) + const route = parseAppRoute('/blog/[slug]', true) + const { pathnameRouteParamSegments: result } = + extractPathnameRouteParamSegmentsFromLoaderTree(loaderTree, route) + + expect(result).toEqual([ + { name: '[slug]', paramName: 'slug', paramType: 'dynamic' }, + ]) + }) + + it('should ignore nested route groups', () => { + // Tree: /(group1)/(group2)/[id] + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree( + '(group1)', + {}, + createLoaderTree('(group2)', {}, createLoaderTree('[id]')) + ) + ) + const route = parseAppRoute('/[id]', true) + const { pathnameRouteParamSegments: result } = + extractPathnameRouteParamSegmentsFromLoaderTree(loaderTree, route) + + expect(result).toEqual([ + { name: '[id]', paramName: 'id', paramType: 'dynamic' }, + ]) + }) + + it('should handle route groups mixed with static segments', () => { + // Tree: /(app)/dashboard/(users)/[userId] + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree( + '(app)', + {}, + createLoaderTree( + 'dashboard', + {}, + createLoaderTree('(users)', {}, createLoaderTree('[userId]')) + ) + ) + ) + const route = parseAppRoute('/dashboard/[userId]', true) + const { pathnameRouteParamSegments: result } = + extractPathnameRouteParamSegmentsFromLoaderTree(loaderTree, route) + + expect(result).toEqual([ + { name: '[userId]', paramName: 'userId', paramType: 'dynamic' }, + ]) + }) + }) + + describe('Parallel Routes', () => { + it('should extract segment from parallel route matching pathname', () => { + // Tree: / -> @modal/[id] + const loaderTree = createLoaderTree('', { + modal: createLoaderTree('[id]'), + }) + const route = parseAppRoute('/[id]', true) + const { pathnameRouteParamSegments: result } = + extractPathnameRouteParamSegmentsFromLoaderTree(loaderTree, route) + + expect(result).toEqual([ + { name: '[id]', paramName: 'id', paramType: 'dynamic' }, + ]) + }) + + it('should extract segments from multiple parallel routes at same depth', () => { + // Tree: / -> @modal/[id] + @sidebar/[category] + const loaderTree = createLoaderTree('', { + modal: createLoaderTree('[id]'), + sidebar: createLoaderTree('[category]'), + }) + const route = parseAppRoute('/[id]', true) + const { pathnameRouteParamSegments: result } = + extractPathnameRouteParamSegmentsFromLoaderTree(loaderTree, route) + + // Only [id] matches - [category] has different param name + expect(result).toEqual([ + { name: '[id]', paramName: 'id', paramType: 'dynamic' }, + ]) + }) + + it('should extract segments from both children and parallel routes', () => { + // Tree: /[lang] -> children + @modal/[photoId] + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree('[lang]', { + modal: createLoaderTree('[photoId]'), + }) + ) + const route = parseAppRoute('/[lang]/[photoId]', true) + const { pathnameRouteParamSegments: result } = + extractPathnameRouteParamSegmentsFromLoaderTree(loaderTree, route) + + expect(result).toEqual([ + { name: '[lang]', paramName: 'lang', paramType: 'dynamic' }, + { name: '[photoId]', paramName: 'photoId', paramType: 'dynamic' }, + ]) + }) + + it('should extract catchall from parallel route', () => { + // Tree: / -> @sidebar/[...path] + const loaderTree = createLoaderTree('', { + sidebar: createLoaderTree('[...path]'), + }) + const route = parseAppRoute('/[...path]', true) + const { pathnameRouteParamSegments: result } = + extractPathnameRouteParamSegmentsFromLoaderTree(loaderTree, route) + + expect(result).toEqual([ + { name: '[...path]', paramName: 'path', paramType: 'catchall' }, + ]) + }) + + it('should NOT extract parallel route segments that do not match pathname', () => { + // Tree: /[id] -> @modal/[photoId] + @sidebar/[category] + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree('[id]', { + modal: createLoaderTree('[photoId]'), + sidebar: createLoaderTree('[category]'), + }) + ) + const route = parseAppRoute('/[id]', true) + const { pathnameRouteParamSegments: result } = + extractPathnameRouteParamSegmentsFromLoaderTree(loaderTree, route) + + // Only [id] should match, parallel routes are at depth 1 + expect(result).toEqual([ + { name: '[id]', paramName: 'id', paramType: 'dynamic' }, + ]) + }) + }) + + describe('Interception Routes', () => { + it('should extract segment from (.) same-level interception route', () => { + // Tree: /(.)photo/[photoId] + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree('(.)photo', {}, createLoaderTree('[photoId]')) + ) + const route = parseAppRoute('/(.)photo/[photoId]', true) + const { pathnameRouteParamSegments: result } = + extractPathnameRouteParamSegmentsFromLoaderTree(loaderTree, route) + + expect(result).toEqual([ + { + name: '[photoId]', + paramName: 'photoId', + paramType: 'dynamic', + }, + ]) + }) + + it('should extract segment from (..) parent-level interception route', () => { + // Tree: /gallery/(..)photo/[photoId] + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree( + 'gallery', + {}, + createLoaderTree('(..)photo', {}, createLoaderTree('[photoId]')) + ) + ) + const route = parseAppRoute('/gallery/(..)photo/[photoId]', true) + const { pathnameRouteParamSegments: result } = + extractPathnameRouteParamSegmentsFromLoaderTree(loaderTree, route) + + expect(result).toEqual([ + { + name: '[photoId]', + paramName: 'photoId', + paramType: 'dynamic', + }, + ]) + }) + + it('should extract segment from (...) root-level interception route', () => { + // Tree: /app/gallery/(...)photo/[photoId] + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree( + 'app', + {}, + createLoaderTree( + 'gallery', + {}, + createLoaderTree('(...)photo', {}, createLoaderTree('[photoId]')) + ) + ) + ) + const route = parseAppRoute('/app/gallery/(...)photo/[photoId]', true) + const { pathnameRouteParamSegments: result } = + extractPathnameRouteParamSegmentsFromLoaderTree(loaderTree, route) + + expect(result).toEqual([ + { + name: '[photoId]', + paramName: 'photoId', + paramType: 'dynamic', + }, + ]) + }) + + it('should extract segment from (..)(..) grandparent-level interception route', () => { + // Tree: /a/b/(..)(..)photo/[photoId] + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree( + 'a', + {}, + createLoaderTree( + 'b', + {}, + createLoaderTree('(..)(..)photo', {}, createLoaderTree('[photoId]')) + ) + ) + ) + const route = parseAppRoute('/a/b/(..)(..)photo/[photoId]', true) + const { pathnameRouteParamSegments: result } = + extractPathnameRouteParamSegmentsFromLoaderTree(loaderTree, route) + + expect(result).toEqual([ + { + name: '[photoId]', + paramName: 'photoId', + paramType: 'dynamic', + }, + ]) + }) + + it('should distinguish interception routes from route groups', () => { + // Tree: /(marketing)/[slug] vs /(.)photo/[photoId] + const routeGroupTree = createLoaderTree( + '', + {}, + createLoaderTree('(marketing)', {}, createLoaderTree('[slug]')) + ) + const interceptionTree = createLoaderTree( + '', + {}, + createLoaderTree('(.)photo', {}, createLoaderTree('[photoId]')) + ) + + const routeGroupRoute = parseAppRoute('/[slug]', true) + const interceptionRoute = parseAppRoute('/(.)photo/[photoId]', true) + + const { pathnameRouteParamSegments: routeGroupResult } = + extractPathnameRouteParamSegmentsFromLoaderTree( + routeGroupTree, + routeGroupRoute + ) + const { pathnameRouteParamSegments: interceptionResult } = + extractPathnameRouteParamSegmentsFromLoaderTree( + interceptionTree, + interceptionRoute + ) + + // Route group ignored, slug at depth 0 + expect(routeGroupResult).toEqual([ + { name: '[slug]', paramName: 'slug', paramType: 'dynamic' }, + ]) + + // Interception route counts, photoId at depth 1 + expect(interceptionResult).toEqual([ + { + name: '[photoId]', + paramName: 'photoId', + paramType: 'dynamic', + }, + ]) + }) + + it('should handle catchall in interception route', () => { + // Tree: /(.)photo/[...segments] + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree('(.)photo', {}, createLoaderTree('[...segments]')) + ) + const route = parseAppRoute('/(.)photo/[...segments]', true) + const { pathnameRouteParamSegments: result } = + extractPathnameRouteParamSegmentsFromLoaderTree(loaderTree, route) + + expect(result).toEqual([ + { + name: '[...segments]', + paramName: 'segments', + paramType: 'catchall', + }, + ]) + }) + + it('should extract intercepted param when marker is part of the segment itself', () => { + // Tree: /(.)[photoId] - the interception marker is PART OF the dynamic segment + // This is the case where -intercepted- types apply (handled by getSegmentParam) + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree('(.)[photoId]') + ) + const route = parseAppRoute('/[photoId]', true) + const { pathnameRouteParamSegments: result } = + extractPathnameRouteParamSegmentsFromLoaderTree(loaderTree, route) + + expect(result).toEqual([ + { + name: '(.)[photoId]', + paramName: 'photoId', + paramType: 'dynamic-intercepted-(.)', // NOW it has -intercepted- type + }, + ]) + }) + }) + + describe('Interception Routes in Parallel Routes', () => { + it('should extract segment from interception route in parallel slot', () => { + // Tree: @modal/(.)photo/[photoId] + const loaderTree = createLoaderTree('', { + modal: createLoaderTree('(.)photo', {}, createLoaderTree('[photoId]')), + }) + const route = parseAppRoute('/(.)photo/[photoId]', true) + const { pathnameRouteParamSegments: result } = + extractPathnameRouteParamSegmentsFromLoaderTree(loaderTree, route) + + expect(result).toEqual([ + { + name: '[photoId]', + paramName: 'photoId', + paramType: 'dynamic', + }, + ]) + }) + + it('should extract segments from both children and intercepting parallel route', () => { + // Tree: /[id] -> children + @modal/(.)photo/[photoId] + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree('[id]', { + modal: createLoaderTree( + '(.)photo', + {}, + createLoaderTree('[photoId]') + ), + }) + ) + const route = parseAppRoute('/[id]/(.)photo/[photoId]', true) + const { pathnameRouteParamSegments: result } = + extractPathnameRouteParamSegmentsFromLoaderTree(loaderTree, route) + + expect(result).toEqual([ + { name: '[id]', paramName: 'id', paramType: 'dynamic' }, + { + name: '[photoId]', + paramName: 'photoId', + paramType: 'dynamic', + }, + ]) + }) + + it('should extract from multiple parallel routes with interception', () => { + // Tree: /[category] -> @modal/(.)photo/[photoId] + @sidebar/(.)filter/[filterId] + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree('[category]', { + modal: createLoaderTree( + '(.)photo', + {}, + createLoaderTree('[photoId]') + ), + sidebar: createLoaderTree( + '(.)filter', + {}, + createLoaderTree('[filterId]') + ), + }) + ) + const route = parseAppRoute('/[category]/(.)photo/[photoId]', true) + const { pathnameRouteParamSegments: result } = + extractPathnameRouteParamSegmentsFromLoaderTree(loaderTree, route) + + expect(result).toEqual([ + { name: '[category]', paramName: 'category', paramType: 'dynamic' }, + { + name: '[photoId]', + paramName: 'photoId', + paramType: 'dynamic', + }, + ]) + }) + + it('should handle (..) interception in parallel route with nested structure', () => { + // Tree: /gallery/[id] -> @modal/(..)photo/[photoId] + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree( + 'gallery', + {}, + createLoaderTree('[id]', { + modal: createLoaderTree( + '(..)photo', + {}, + createLoaderTree('[photoId]') + ), + }) + ) + ) + const route = parseAppRoute('/gallery/[id]/(..)photo/[photoId]', true) + const { pathnameRouteParamSegments: result } = + extractPathnameRouteParamSegmentsFromLoaderTree(loaderTree, route) + + expect(result).toEqual([ + { name: '[id]', paramName: 'id', paramType: 'dynamic' }, + { + name: '[photoId]', + paramName: 'photoId', + paramType: 'dynamic', + }, + ]) + }) + + it('should handle (...) root-level interception in parallel route', () => { + // Tree: /app/gallery/[id] -> @modal/(...)photo/[photoId] + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree( + 'app', + {}, + createLoaderTree( + 'gallery', + {}, + createLoaderTree('[id]', { + modal: createLoaderTree( + '(...)photo', + {}, + createLoaderTree('[photoId]') + ), + }) + ) + ) + ) + const route = parseAppRoute( + '/app/gallery/[id]/(...)photo/[photoId]', + true + ) + const { pathnameRouteParamSegments: result } = + extractPathnameRouteParamSegmentsFromLoaderTree(loaderTree, route) + + expect(result).toEqual([ + { name: '[id]', paramName: 'id', paramType: 'dynamic' }, + { + name: '[photoId]', + paramName: 'photoId', + paramType: 'dynamic', + }, + ]) + }) + + it('should handle catchall in intercepting parallel route', () => { + // Tree: /[id] -> @modal/(.)details/[...segments] + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree('[id]', { + modal: createLoaderTree( + '(.)details', + {}, + createLoaderTree('[...segments]') + ), + }) + ) + const route = parseAppRoute('/[id]/(.)details/[...segments]', true) + const { pathnameRouteParamSegments: result } = + extractPathnameRouteParamSegmentsFromLoaderTree(loaderTree, route) + + expect(result).toEqual([ + { name: '[id]', paramName: 'id', paramType: 'dynamic' }, + { + name: '[...segments]', + paramName: 'segments', + paramType: 'catchall', + }, + ]) + }) + }) + + describe('Complex Mixed Scenarios', () => { + it('should handle route groups + parallel routes + interception routes', () => { + // Tree: /(marketing)/[lang] -> @modal/(.)photo/[photoId] + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree( + '(marketing)', + {}, + createLoaderTree('[lang]', { + modal: createLoaderTree( + '(.)photo', + {}, + createLoaderTree('[photoId]') + ), + }) + ) + ) + const route = parseAppRoute('/[lang]/(.)photo/[photoId]', true) + const { pathnameRouteParamSegments: result } = + extractPathnameRouteParamSegmentsFromLoaderTree(loaderTree, route) + + expect(result).toEqual([ + { name: '[lang]', paramName: 'lang', paramType: 'dynamic' }, + { + name: '[photoId]', + paramName: 'photoId', + paramType: 'dynamic', + }, + ]) + }) + + it('should handle deeply nested parallel routes with interception', () => { + // Tree: /[lang]/blog/[category] -> @modal/(.)post/[slug] + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree( + '[lang]', + {}, + createLoaderTree( + 'blog', + {}, + createLoaderTree('[category]', { + modal: createLoaderTree( + '(.)post', + {}, + createLoaderTree('[slug]') + ), + }) + ) + ) + ) + const route = parseAppRoute( + '/[lang]/blog/[category]/(.)post/[slug]', + true + ) + const { pathnameRouteParamSegments: result } = + extractPathnameRouteParamSegmentsFromLoaderTree(loaderTree, route) + + expect(result).toEqual([ + { name: '[lang]', paramName: 'lang', paramType: 'dynamic' }, + { name: '[category]', paramName: 'category', paramType: 'dynamic' }, + { + name: '[slug]', + paramName: 'slug', + paramType: 'dynamic', + }, + ]) + }) + + it('should handle multiple interception routes at different levels', () => { + // Tree: /[id] -> @modal1/(.)a/[a] + @modal2/(..)b/[b] + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree('[id]', { + modal1: createLoaderTree('(.)a', {}, createLoaderTree('[a]')), + modal2: createLoaderTree('(..)b', {}, createLoaderTree('[b]')), + }) + ) + const route = parseAppRoute('/[id]/(.)a/[a]', true) + const { pathnameRouteParamSegments: result } = + extractPathnameRouteParamSegmentsFromLoaderTree(loaderTree, route) + + expect(result).toEqual([ + { name: '[id]', paramName: 'id', paramType: 'dynamic' }, + { + name: '[a]', + paramName: 'a', + paramType: 'dynamic', + }, + ]) + }) + + it('should extract from actual Next.js photo gallery pattern', () => { + // Realistic pattern: /photos/[id] with @modal/(.)photo/[photoId] + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree( + 'photos', + {}, + createLoaderTree('[id]', { + modal: createLoaderTree( + '(.)photo', + {}, + createLoaderTree('[photoId]') + ), + }) + ) + ) + const route = parseAppRoute('/photos/[id]/(.)photo/[photoId]', true) + const { pathnameRouteParamSegments: result } = + extractPathnameRouteParamSegmentsFromLoaderTree(loaderTree, route) + + expect(result).toEqual([ + { name: '[id]', paramName: 'id', paramType: 'dynamic' }, + { + name: '[photoId]', + paramName: 'photoId', + paramType: 'dynamic', + }, + ]) + }) + + it('should handle i18n with interception routes', () => { + // Tree: /[locale]/products/[category] -> @modal/(.)product/[productId] + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree( + '[locale]', + {}, + createLoaderTree( + 'products', + {}, + createLoaderTree('[category]', { + modal: createLoaderTree( + '(.)product', + {}, + createLoaderTree('[productId]') + ), + }) + ) + ) + ) + const route = parseAppRoute( + '/[locale]/products/[category]/(.)product/[productId]', + true + ) + const { pathnameRouteParamSegments: result } = + extractPathnameRouteParamSegmentsFromLoaderTree(loaderTree, route) + + expect(result).toEqual([ + { name: '[locale]', paramName: 'locale', paramType: 'dynamic' }, + { name: '[category]', paramName: 'category', paramType: 'dynamic' }, + { + name: '[productId]', + paramName: 'productId', + paramType: 'dynamic', + }, + ]) + }) + }) + + describe('Edge Cases', () => { + it('should return empty array for pathname with no dynamic segments', () => { + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree('blog', {}, createLoaderTree('posts')) + ) + const route = parseAppRoute('/blog/posts', true) + const { pathnameRouteParamSegments: result } = + extractPathnameRouteParamSegmentsFromLoaderTree(loaderTree, route) + + expect(result).toEqual([]) + }) + + it('should return empty array when no segments match pathname', () => { + // Tree has dynamic segments but they don't match the pathname structure + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree('api', {}, createLoaderTree('[version]')) + ) + const route = parseAppRoute('/different/path', true) + const { pathnameRouteParamSegments: result } = + extractPathnameRouteParamSegmentsFromLoaderTree(loaderTree, route) + + expect(result).toEqual([]) + }) + + it('should handle empty segment in tree', () => { + // Tree: '' -> [id] + const loaderTree = createLoaderTree('', {}, createLoaderTree('[id]')) + const route = parseAppRoute('/[id]', true) + const { pathnameRouteParamSegments: result } = + extractPathnameRouteParamSegmentsFromLoaderTree(loaderTree, route) + + expect(result).toEqual([ + { name: '[id]', paramName: 'id', paramType: 'dynamic' }, + ]) + }) + + it('should match segments by depth and param name', () => { + // Tree: /[lang]/blog/[slug] but pathname is /[lang]/[slug] + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree( + '[lang]', + {}, + createLoaderTree('blog', {}, createLoaderTree('[slug]')) + ) + ) + const route = parseAppRoute('/[lang]/[slug]', true) + const { pathnameRouteParamSegments: result } = + extractPathnameRouteParamSegmentsFromLoaderTree(loaderTree, route) + + // Should match [lang] at depth 0 but not [slug] (wrong depth) + expect(result).toEqual([ + { name: '[lang]', paramName: 'lang', paramType: 'dynamic' }, + ]) + }) + + it('should handle optional catchall in parallel route', () => { + // Tree: @sidebar/[[...optional]] + const loaderTree = createLoaderTree('', { + sidebar: createLoaderTree('[[...optional]]'), + }) + const route = parseAppRoute('/[[...optional]]', true) + const { pathnameRouteParamSegments: result } = + extractPathnameRouteParamSegmentsFromLoaderTree(loaderTree, route) + + expect(result).toEqual([ + { + name: '[[...optional]]', + paramName: 'optional', + paramType: 'optional-catchall', + }, + ]) + }) + + it('should handle multiple route groups in sequence', () => { + // Tree: /(a)/(b)/(c)/[id] + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree( + '(a)', + {}, + createLoaderTree( + '(b)', + {}, + createLoaderTree('(c)', {}, createLoaderTree('[id]')) + ) + ) + ) + const route = parseAppRoute('/[id]', true) + const { pathnameRouteParamSegments: result } = + extractPathnameRouteParamSegmentsFromLoaderTree(loaderTree, route) + + expect(result).toEqual([ + { name: '[id]', paramName: 'id', paramType: 'dynamic' }, + ]) + }) + }) + + describe('Static Segment Matching', () => { + it('should not extract segments when static segments do not match', () => { + // Tree: /blog/[slug] but pathname is /news/[slug] + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree('blog', {}, createLoaderTree('[slug]')) + ) + const route = parseAppRoute('/news/[slug]', true) + const { pathnameRouteParamSegments: result } = + extractPathnameRouteParamSegmentsFromLoaderTree(loaderTree, route) + + expect(result).toEqual([]) + }) + + it('should match when static segments align correctly', () => { + // Tree: /api/v1/[endpoint] -> /api/v1/[endpoint] + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree( + 'api', + {}, + createLoaderTree('v1', {}, createLoaderTree('[endpoint]')) + ) + ) + const route = parseAppRoute('/api/v1/[endpoint]', true) + const { pathnameRouteParamSegments: result } = + extractPathnameRouteParamSegmentsFromLoaderTree(loaderTree, route) + + expect(result).toEqual([ + { name: '[endpoint]', paramName: 'endpoint', paramType: 'dynamic' }, + ]) + }) + + it('should handle segments with values already present in the page', () => { + // Tree: /blog/[slug] but pathname is /blog/my-slug + const loaderTree = createLoaderTree( + '', + { + sidebar: createLoaderTree('[[...catchAll]]'), + }, + createLoaderTree('blog', {}, createLoaderTree('[slug]')) + ) + const route = parseAppRoute('/blog/my-slug', true) + const { pathnameRouteParamSegments: result } = + extractPathnameRouteParamSegmentsFromLoaderTree(loaderTree, route) + + expect(result).toEqual([]) + }) + }) + + describe('Prefix Validation with Type Mismatch', () => { + it('should NOT extract param when prefix has type mismatch (static vs dynamic)', () => { + // Tree: /(.)photo -> @modal/[id] + // Route: /[category]/[id] + // + // When checking @modal/[id] at depth 1: + // currentPath = [(.)photo] (STATIC segment) + // route.segments[0] = [category] (DYNAMIC segment) + // route.segments[1] = [id] (DYNAMIC segment) + // + // The [id] param matches at depth 1, BUT the prefix validation should fail + // because (.)photo (static) doesn't match [category] (dynamic) at depth 0 + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree('(.)photo', { + modal: createLoaderTree('[id]'), + }) + ) + const route = parseAppRoute('/[category]/[id]', true) + const { pathnameRouteParamSegments: result } = + extractPathnameRouteParamSegmentsFromLoaderTree(loaderTree, route) + + // Should return empty array - [id] should NOT be extracted + // Without the type check, validatePrefixMatch would incorrectly return true + // because neither the static nor dynamic comparison would trigger + expect(result).toEqual([]) + }) + + it('should NOT extract param when prefix has type mismatch (dynamic vs static)', () => { + // Tree: /[lang] -> @modal/[id] + // Route: /photo/[id] + // + // When checking @modal/[id] at depth 1: + // currentPath = [lang] (DYNAMIC segment) + // route.segments[0] = photo (STATIC segment) + // route.segments[1] = [id] (DYNAMIC segment) + // + // The [id] param matches at depth 1, BUT the prefix validation should fail + // because [lang] (dynamic) doesn't match photo (static) at depth 0 + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree('[lang]', { + modal: createLoaderTree('[id]'), + }) + ) + const route = parseAppRoute('/photo/[id]', true) + const { pathnameRouteParamSegments: result } = + extractPathnameRouteParamSegmentsFromLoaderTree(loaderTree, route) + + // Should return empty array - [id] should NOT be extracted + // Without the type check, validatePrefixMatch would incorrectly return true + expect(result).toEqual([]) + }) + + it('should extract param when prefix types match correctly', () => { + // Tree: /blog -> @modal/(.)photo/[id] + // Route: /blog/(.)photo/[id] + // + // When checking @modal/(.)photo/[id]: + // currentPath at depth 1 = (.)photo (STATIC segment) + // route.segments at depth 1 = (.)photo (STATIC segment) + // + // Types match AND names match, so [id] should be extracted + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree('blog', { + modal: createLoaderTree('(.)photo', {}, createLoaderTree('[id]')), + }) + ) + const route = parseAppRoute('/blog/(.)photo/[id]', true) + const { pathnameRouteParamSegments: result } = + extractPathnameRouteParamSegmentsFromLoaderTree(loaderTree, route) + + // Should extract [id] because prefix validation succeeds + expect(result).toEqual([ + { + name: '[id]', + paramName: 'id', + paramType: 'dynamic', + }, + ]) + }) + }) + + describe('Params Resolution', () => { + it('should resolve single static value for dynamic segment', () => { + // Tree: /[id] + // Route: /123 (static value) + const loaderTree = createLoaderTree('', {}, createLoaderTree('[id]')) + const route = parseAppRoute('/123', true) + const { pathnameRouteParamSegments, params } = + extractPathnameRouteParamSegmentsFromLoaderTree(loaderTree, route) + + expect(pathnameRouteParamSegments).toEqual([]) + expect(params).toEqual({ id: '123' }) + }) + + it('should resolve multiple static values for dynamic segments', () => { + // Tree: /[category]/[id] + // Route: /electronics/123 + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree('[category]', {}, createLoaderTree('[id]')) + ) + const route = parseAppRoute('/electronics/123', true) + const { pathnameRouteParamSegments, params } = + extractPathnameRouteParamSegmentsFromLoaderTree(loaderTree, route) + + expect(pathnameRouteParamSegments).toEqual([]) + expect(params).toEqual({ + category: 'electronics', + id: '123', + }) + }) + + it('should resolve static value in interception route', () => { + // Tree: /blog -> @modal/(.)photo/[id] + // Route: /blog/(.)photo/123 + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree('blog', { + modal: createLoaderTree('(.)photo', {}, createLoaderTree('[id]')), + }) + ) + const route = parseAppRoute('/blog/(.)photo/123', true) + const { pathnameRouteParamSegments, params } = + extractPathnameRouteParamSegmentsFromLoaderTree(loaderTree, route) + + expect(pathnameRouteParamSegments).toEqual([]) + expect(params).toEqual({ id: '123' }) + }) + + it('should resolve catchall with static segments', () => { + // Tree: /docs/[...slug] + // Route: /docs/getting-started/installation + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree('docs', {}, createLoaderTree('[...slug]')) + ) + const route = parseAppRoute('/docs/getting-started/installation', true) + const { pathnameRouteParamSegments, params } = + extractPathnameRouteParamSegmentsFromLoaderTree(loaderTree, route) + + expect(pathnameRouteParamSegments).toEqual([]) + expect(params).toEqual({ + slug: ['getting-started', 'installation'], + }) + }) + + it('should resolve optional catchall with static segments', () => { + // Tree: /docs/[[...slug]] + // Route: /docs/api/reference + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree('docs', {}, createLoaderTree('[[...slug]]')) + ) + const route = parseAppRoute('/docs/api/reference', true) + const { pathnameRouteParamSegments, params } = + extractPathnameRouteParamSegmentsFromLoaderTree(loaderTree, route) + + expect(pathnameRouteParamSegments).toEqual([]) + expect(params).toEqual({ + slug: ['api', 'reference'], + }) + }) + + it('should resolve optional catchall with empty value', () => { + // Tree: /docs/[[...slug]] + // Route: /docs + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree('docs', {}, createLoaderTree('[[...slug]]')) + ) + const route = parseAppRoute('/docs', true) + const { pathnameRouteParamSegments, params } = + extractPathnameRouteParamSegmentsFromLoaderTree(loaderTree, route) + + expect(pathnameRouteParamSegments).toEqual([]) + expect(params).toEqual({}) + }) + + it('should handle mixed static and dynamic segments', () => { + // Tree: /blog/[lang]/[slug] + // Route: /blog/en/[slug] (lang is static, slug is dynamic) + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree( + 'blog', + {}, + createLoaderTree('[lang]', {}, createLoaderTree('[slug]')) + ) + ) + const route = parseAppRoute('/blog/en/[slug]', true) + const { pathnameRouteParamSegments, params } = + extractPathnameRouteParamSegmentsFromLoaderTree(loaderTree, route) + + // [slug] is not in pathnameRouteParamSegments because the prefix has a type + // mismatch ([lang] dynamic vs 'en' static), so validation fails + expect(pathnameRouteParamSegments).toEqual([]) + // But lang is still resolved from the static value + expect(params).toEqual({ + lang: 'en', + }) + }) + + it('should not resolve params when segment is dynamic placeholder', () => { + // Tree: /[category]/[id] + // Route: /[category]/[id] (both are placeholders) + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree('[category]', {}, createLoaderTree('[id]')) + ) + const route = parseAppRoute('/[category]/[id]', true) + const { pathnameRouteParamSegments, params } = + extractPathnameRouteParamSegmentsFromLoaderTree(loaderTree, route) + + expect(pathnameRouteParamSegments).toEqual([ + { name: '[category]', paramName: 'category', paramType: 'dynamic' }, + { name: '[id]', paramName: 'id', paramType: 'dynamic' }, + ]) + expect(params).toEqual({}) + }) + + it('should resolve params with route groups', () => { + // Tree: /(shop)/[category]/[id] + // Route: /electronics/123 + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree( + '(shop)', + {}, + createLoaderTree('[category]', {}, createLoaderTree('[id]')) + ) + ) + const route = parseAppRoute('/electronics/123', true) + const { pathnameRouteParamSegments, params } = + extractPathnameRouteParamSegmentsFromLoaderTree(loaderTree, route) + + expect(pathnameRouteParamSegments).toEqual([]) + expect(params).toEqual({ + category: 'electronics', + id: '123', + }) + }) + + it('should resolve params in parallel routes', () => { + // Tree: /blog -> @modal/[id] + // Route: /blog/123 (via parallel route) + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree('blog', { + modal: createLoaderTree('[id]'), + }) + ) + const route = parseAppRoute('/blog/123', true) + const { pathnameRouteParamSegments, params } = + extractPathnameRouteParamSegmentsFromLoaderTree(loaderTree, route) + + expect(pathnameRouteParamSegments).toEqual([]) + expect(params).toEqual({ id: '123' }) + }) + + it('should resolve params with interception markers in segment', () => { + // Tree: /(.)[id] + // Route: /(.)123 + const loaderTree = createLoaderTree('', {}, createLoaderTree('(.)[id]')) + const route = parseAppRoute('/(.)123', true) + const { pathnameRouteParamSegments, params } = + extractPathnameRouteParamSegmentsFromLoaderTree(loaderTree, route) + + expect(pathnameRouteParamSegments).toEqual([]) + // The interception marker is part of the segment name + expect(params).toEqual({ id: '123' }) + }) + + it('should handle catchall with mixed static and dynamic in pathname', () => { + // Tree: /[...slug] + // Route: /api/[version]/users (version is dynamic, api and users are static) + const loaderTree = createLoaderTree('', {}, createLoaderTree('[...slug]')) + const route = parseAppRoute('/api/[version]/users', true) + const { pathnameRouteParamSegments, params } = + extractPathnameRouteParamSegmentsFromLoaderTree(loaderTree, route) + + expect(pathnameRouteParamSegments).toEqual([]) + // Should not resolve because pathname contains unknown [version] + expect(params).toEqual({}) + }) + + it('should resolve complex interception route in photo gallery pattern', () => { + // Tree: / -> @modal/(.)photo/[id] + // Route: /(.)photo/abc123 + const loaderTree = createLoaderTree('', { + modal: createLoaderTree('(.)photo', {}, createLoaderTree('[id]')), + }) + const route = parseAppRoute('/(.)photo/abc123', true) + const { pathnameRouteParamSegments, params } = + extractPathnameRouteParamSegmentsFromLoaderTree(loaderTree, route) + + expect(pathnameRouteParamSegments).toEqual([]) + expect(params).toEqual({ id: 'abc123' }) + }) + + it('should resolve params with (..) parent-level interception', () => { + // Tree: /blog -> @modal/(..)[id] + // Route: /blog/(..)456 + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree('blog', { + modal: createLoaderTree('(..)[id]'), + }) + ) + const route = parseAppRoute('/blog/(..)456', true) + const { pathnameRouteParamSegments, params } = + extractPathnameRouteParamSegmentsFromLoaderTree(loaderTree, route) + + expect(pathnameRouteParamSegments).toEqual([]) + expect(params).toEqual({ id: '456' }) + }) + + it('should resolve catch-all params with (..) parent-level interception', () => { + // Tree: /blog -> @modal/(..)[...catchAll] + // Route: /blog/(..)some/path/here + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree('blog', { + modal: createLoaderTree('(..)[...catchAll]'), + }) + ) + const route = parseAppRoute('/blog/(..)some/path/here', true) + const { pathnameRouteParamSegments, params } = + extractPathnameRouteParamSegmentsFromLoaderTree(loaderTree, route) + + expect(pathnameRouteParamSegments).toEqual([]) + expect(params).toEqual({ catchAll: ['some', 'path', 'here'] }) + }) + + it('should handle deeply nested static values', () => { + // Tree: /[lang]/[region]/shop/[category]/[id] + // Route: /en/us/shop/electronics/laptop-123 + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree( + '[lang]', + {}, + createLoaderTree( + '[region]', + {}, + createLoaderTree( + 'shop', + {}, + createLoaderTree('[category]', {}, createLoaderTree('[id]')) + ) + ) + ) + ) + const route = parseAppRoute('/en/us/shop/electronics/laptop-123', true) + const { pathnameRouteParamSegments, params } = + extractPathnameRouteParamSegmentsFromLoaderTree(loaderTree, route) + + expect(pathnameRouteParamSegments).toEqual([]) + expect(params).toEqual({ + lang: 'en', + region: 'us', + category: 'electronics', + id: 'laptop-123', + }) + }) + }) +}) diff --git a/packages/next/src/build/static-paths/app/extract-pathname-route-param-segments-from-loader-tree.ts b/packages/next/src/build/static-paths/app/extract-pathname-route-param-segments-from-loader-tree.ts new file mode 100644 index 0000000000000..2551096155934 --- /dev/null +++ b/packages/next/src/build/static-paths/app/extract-pathname-route-param-segments-from-loader-tree.ts @@ -0,0 +1,192 @@ +import type { LoaderTree } from '../../../server/lib/app-dir-module' +import type { Params } from '../../../server/request/params' +import type { DynamicParamTypes } from '../../../shared/lib/app-router-types' +import { + parseAppRouteSegment, + type NormalizedAppRoute, + type NormalizedAppRouteSegment, +} from '../../../shared/lib/router/routes/app' +import { parseLoaderTree } from '../../../shared/lib/router/utils/parse-loader-tree' +import { resolveParamValue } from '../../../shared/lib/router/utils/resolve-param-value' + +/** + * Validates that the static segments in currentPath match the corresponding + * segments in targetSegments. This ensures we only extract dynamic parameters + * that are part of the target pathname structure. + * + * Segments are compared literally - interception markers like "(.)photo" are + * part of the pathname and must match exactly. + * + * @example + * // Matching paths + * currentPath: ['blog', '(.)photo'] + * targetSegments: ['blog', '(.)photo', '[id]'] + * → Returns true (both static segments match exactly) + * + * @example + * // Non-matching paths + * currentPath: ['blog', '(.)photo'] + * targetSegments: ['blog', 'photo', '[id]'] + * → Returns false (segments don't match - marker is part of pathname) + * + * @param currentPath - The accumulated path segments from the loader tree + * @param targetSegments - The target pathname split into segments + * @returns true if all static segments match, false otherwise + */ +function validatePrefixMatch( + currentPath: NormalizedAppRouteSegment[], + route: NormalizedAppRoute +): boolean { + for (let i = 0; i < currentPath.length; i++) { + const pathSegment = currentPath[i] + const targetPathSegment = route.segments[i] + + // Type mismatch - one is static, one is dynamic + if (pathSegment.type !== targetPathSegment.type) { + return false + } + + // One has an interception marker, the other doesn't. + if ( + pathSegment.interceptionMarker !== targetPathSegment.interceptionMarker + ) { + return false + } + + // Both are static but names don't match + if ( + pathSegment.type === 'static' && + targetPathSegment.type === 'static' && + pathSegment.name !== targetPathSegment.name + ) { + return false + } + // Both are dynamic but param names don't match + else if ( + pathSegment.type === 'dynamic' && + targetPathSegment.type === 'dynamic' && + pathSegment.param.paramType !== targetPathSegment.param.paramType && + pathSegment.param.paramName !== targetPathSegment.param.paramName + ) { + return false + } + } + + return true +} + +/** + * Extracts pathname route param segments from a loader tree and resolves + * parameter values from static segments in the route. + * + * @param loaderTree - The loader tree structure containing route hierarchy + * @param route - The target route to match against + * @returns Object containing pathname route param segments and resolved params + */ +export function extractPathnameRouteParamSegmentsFromLoaderTree( + loaderTree: LoaderTree, + route: NormalizedAppRoute +): { + pathnameRouteParamSegments: Array<{ + readonly name: string + readonly paramName: string + readonly paramType: DynamicParamTypes + }> + params: Params +} { + const pathnameRouteParamSegments: Array<{ + readonly name: string + readonly paramName: string + readonly paramType: DynamicParamTypes + }> = [] + const params: Params = {} + + // BFS traversal with depth and path tracking + const queue: Array<{ + tree: LoaderTree + depth: number + currentPath: NormalizedAppRouteSegment[] + }> = [{ tree: loaderTree, depth: 0, currentPath: [] }] + + while (queue.length > 0) { + const { tree, depth, currentPath } = queue.shift()! + const { segment, parallelRoutes } = parseLoaderTree(tree) + + // Build the path for the current node + let updatedPath = currentPath + let nextDepth = depth + + const appSegment = parseAppRouteSegment(segment) + + // Only add to path if it's a real segment that appears in the URL + // Route groups and parallel markers don't contribute to URL pathname + if ( + appSegment && + appSegment.type !== 'route-group' && + appSegment.type !== 'parallel-route' + ) { + updatedPath = [...currentPath, appSegment] + nextDepth = depth + 1 + } + + // Check if this segment has a param and matches the target pathname at this depth + if (appSegment?.type === 'dynamic') { + const { paramName, paramType } = appSegment.param + + // Check if this segment is at the correct depth in the target pathname + // A segment matches if: + // 1. There's a dynamic segment at this depth in the pathname + // 2. The parameter names match (e.g., [id] matches [id], not [category]) + // 3. The static segments leading up to this point match (prefix check) + if (depth < route.segments.length) { + const targetSegment = route.segments[depth] + + // Match if the target pathname has a dynamic segment at this depth + if (targetSegment.type === 'dynamic') { + // Check that parameter names match exactly + // This prevents [category] from matching against /[id] + if (paramName !== targetSegment.param.paramName) { + continue // Different param names, skip this segment + } + + // Validate that the path leading up to this dynamic segment matches + // the target pathname. This prevents false matches like extracting + // [slug] from "/news/[slug]" when the tree has "/blog/[slug]" + if (validatePrefixMatch(currentPath, route)) { + pathnameRouteParamSegments.push({ + name: segment, + paramName, + paramType, + }) + } + } + } + + // Resolve parameter value if it's not already known. + if (!params.hasOwnProperty(paramName)) { + const paramValue = resolveParamValue( + paramName, + paramType, + depth, + route, + params + ) + + if (paramValue !== undefined) { + params[paramName] = paramValue + } + } + } + + // Continue traversing all parallel routes to find matching segments + for (const parallelRoute of Object.values(parallelRoutes)) { + queue.push({ + tree: parallelRoute, + depth: nextDepth, + currentPath: updatedPath, + }) + } + } + + return { pathnameRouteParamSegments, params } +} diff --git a/packages/next/src/build/static-paths/types.ts b/packages/next/src/build/static-paths/types.ts index 0b9a8f63ee84a..0afe7a6b29a7a 100644 --- a/packages/next/src/build/static-paths/types.ts +++ b/packages/next/src/build/static-paths/types.ts @@ -27,12 +27,6 @@ export type FallbackRouteParam = { * The type of the param. */ readonly paramType: DynamicParamTypes - - /** - * Whether this is a parallel route param or descends from a parallel route - * param. - */ - readonly isParallelRouteParam: boolean } type FallbackPrerenderedRoute = { @@ -41,8 +35,9 @@ type FallbackPrerenderedRoute = { readonly encodedPathname: string /** - * The fallback route params for the route. This includes both the parallel - * route params and the non-parallel route params. + * The fallback route params for the route. This includes all route parameters + * that are unknown at build time, from both the main children route and any + * parallel routes. */ readonly fallbackRouteParams: readonly FallbackRouteParam[] readonly fallbackMode: FallbackMode | undefined diff --git a/packages/next/src/build/static-paths/utils.test.ts b/packages/next/src/build/static-paths/utils.test.ts new file mode 100644 index 0000000000000..8733020221c49 --- /dev/null +++ b/packages/next/src/build/static-paths/utils.test.ts @@ -0,0 +1,972 @@ +import type { Params } from '../../server/request/params' +import { parseAppRoute } from '../../shared/lib/router/routes/app' +import type { FallbackRouteParam } from './types' +import { resolveRouteParamsFromTree } from './utils' + +// Helper to create LoaderTree structures for testing +type TestLoaderTree = [ + segment: string, + parallelRoutes: { [key: string]: TestLoaderTree }, + modules: Record, +] + +function createLoaderTree( + segment: string, + parallelRoutes: { [key: string]: TestLoaderTree } = {}, + children?: TestLoaderTree +): TestLoaderTree { + const routes = children ? { ...parallelRoutes, children } : parallelRoutes + return [segment, routes, {}] +} + +describe('resolveRouteParamsFromTree', () => { + describe('direct match case', () => { + it('should skip processing when param already exists in params object', () => { + // Tree: / -> @sidebar/[existingParam] + const loaderTree = createLoaderTree('', { + sidebar: createLoaderTree('[existingParam]'), + }) + const params: Params = { existingParam: 'value' } + const route = parseAppRoute('/some/path', true) + const fallbackRouteParams: FallbackRouteParam[] = [] + + resolveRouteParamsFromTree(loaderTree, params, route, fallbackRouteParams) + + expect(params.existingParam).toBe('value') + expect(fallbackRouteParams).toHaveLength(0) + }) + + it('should skip processing for multiple existing params', () => { + // Tree: / -> @sidebar/[param1] + @modal/[...param2] + const loaderTree = createLoaderTree('', { + sidebar: createLoaderTree('[param1]'), + modal: createLoaderTree('[...param2]'), + }) + const params: Params = { param1: 'value1', param2: ['a', 'b'] } + const route = parseAppRoute('/some/path', true) + const fallbackRouteParams: FallbackRouteParam[] = [] + + resolveRouteParamsFromTree(loaderTree, params, route, fallbackRouteParams) + + expect(params.param1).toBe('value1') + expect(params.param2).toEqual(['a', 'b']) + expect(fallbackRouteParams).toHaveLength(0) + }) + }) + + describe('dynamic params', () => { + it('should extract dynamic param from pathname when not already in params', () => { + // Tree: / -> @sidebar/[dynamicParam] + // At depth 0, should extract 'some' from pathname '/some/path' + const loaderTree = createLoaderTree('', { + sidebar: createLoaderTree('[dynamicParam]'), + }) + const params: Params = {} + const route = parseAppRoute('/some/path', true) + const fallbackRouteParams: FallbackRouteParam[] = [] + + resolveRouteParamsFromTree(loaderTree, params, route, fallbackRouteParams) + + expect(params.dynamicParam).toBe('some') + expect(fallbackRouteParams).toHaveLength(0) + }) + + it('should handle multiple dynamic params in parallel routes at same depth', () => { + // Tree: / -> @modal/[id] + @sidebar/[category] + // Both at depth 0, so both extract 'photo' from pathname '/photo/123' + const loaderTree = createLoaderTree('', { + modal: createLoaderTree('[id]'), + sidebar: createLoaderTree('[category]'), + }) + const params: Params = {} + const route = parseAppRoute('/photo/123', true) + const fallbackRouteParams: FallbackRouteParam[] = [] + + resolveRouteParamsFromTree(loaderTree, params, route, fallbackRouteParams) + + // Both should extract the first segment 'photo' + expect(params.id).toBe('photo') + expect(params.category).toBe('photo') + expect(fallbackRouteParams).toHaveLength(0) + }) + + it('should extract dynamic param from pathname at depth 0', () => { + // Tree: / -> @sidebar/[category] + const loaderTree = createLoaderTree('', { + sidebar: createLoaderTree('[category]'), + }) + const params: Params = {} + const route = parseAppRoute('/tech', true) + const fallbackRouteParams: FallbackRouteParam[] = [] + + resolveRouteParamsFromTree(loaderTree, params, route, fallbackRouteParams) + + expect(params.category).toBe('tech') + expect(fallbackRouteParams).toHaveLength(0) + }) + + it('should extract dynamic param from pathname at nested depth', () => { + // Tree: /blog -> @sidebar/[category] + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree('blog', { + sidebar: createLoaderTree('[category]'), + }) + ) + const params: Params = {} + const route = parseAppRoute('/blog/tech', true) + const fallbackRouteParams: FallbackRouteParam[] = [] + + resolveRouteParamsFromTree(loaderTree, params, route, fallbackRouteParams) + + expect(params.category).toBe('tech') + expect(fallbackRouteParams).toHaveLength(0) + }) + + it('should extract dynamic param even when other unknown params exist at different depths', () => { + // Tree: / -> @sidebar/[category] + // Even though there's an unknown 'slug' param somewhere else, if the segment + // at this depth is known, we can extract it + const loaderTree = createLoaderTree('', { + sidebar: createLoaderTree('[category]'), + }) + const params: Params = {} + const route = parseAppRoute('/tech', true) + const fallbackRouteParams: FallbackRouteParam[] = [ + { paramName: 'slug', paramType: 'dynamic' }, + ] + + resolveRouteParamsFromTree(loaderTree, params, route, fallbackRouteParams) + + // Should extract 'tech' because pathSegments[0] is known, regardless of slug + expect(params.category).toBe('tech') + expect(fallbackRouteParams).toHaveLength(1) // Still just slug + }) + + it('should mark dynamic param as fallback when depth exceeds pathname length', () => { + // Tree: /blog/posts -> @sidebar/[category] + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree( + 'blog', + {}, + createLoaderTree('posts', { + sidebar: createLoaderTree('[category]'), + }) + ) + ) + const params: Params = {} + const route = parseAppRoute('/blog', true) + const fallbackRouteParams: FallbackRouteParam[] = [] + + resolveRouteParamsFromTree(loaderTree, params, route, fallbackRouteParams) + + expect(params.category).toBeUndefined() + expect(fallbackRouteParams).toHaveLength(1) + expect(fallbackRouteParams[0]).toEqual({ + paramName: 'category', + paramType: 'dynamic', + }) + }) + + it('should resolve embedded params when extracting dynamic param value', () => { + // Tree: /[lang] -> @sidebar/[category] + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree('[lang]', { + sidebar: createLoaderTree('[category]'), + }) + ) + const params: Params = { lang: 'en' } + const route = parseAppRoute('/en/tech', true) + const fallbackRouteParams: FallbackRouteParam[] = [] + + resolveRouteParamsFromTree(loaderTree, params, route, fallbackRouteParams) + + expect(params.category).toBe('tech') + expect(fallbackRouteParams).toHaveLength(0) + }) + + it('should extract dynamic param when unknown params exist at LATER depth', () => { + // Tree: /[lang] -> @sidebar/[filter] (at depth 1) + // /[lang]/products/[category] (category at depth 2 is unknown) + // @sidebar/[filter] is at depth 1, should extract 'products' + // [category] at depth 2 is unknown, but shouldn't affect depth 1 resolution + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree( + '[lang]', + { + sidebar: createLoaderTree('[filter]'), + }, + createLoaderTree('products', {}, createLoaderTree('[category]')) + ) + ) + const params: Params = { lang: 'en' } + const route = parseAppRoute('/en/products/[category]', true) + const fallbackRouteParams: FallbackRouteParam[] = [ + { paramName: 'category', paramType: 'dynamic' }, + ] + + resolveRouteParamsFromTree(loaderTree, params, route, fallbackRouteParams) + + // Should extract 'products' at depth 1, even though category at depth 2 is unknown + expect(params.filter).toBe('products') + expect(fallbackRouteParams).toHaveLength(1) // Still just category + }) + + it('should NOT extract dynamic param when placeholder is at SAME depth', () => { + // Tree: /[lang]/products/[category] -> @sidebar/[filter] + // @sidebar/[filter] is at depth 2 + // [category] at depth 2 is also unknown - same depth! + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree( + '[lang]', + {}, + createLoaderTree( + 'products', + {}, + createLoaderTree('[category]', { + sidebar: createLoaderTree('[filter]'), + }) + ) + ) + ) + const params: Params = { lang: 'en' } + const route = parseAppRoute('/en/products/[category]', true) + const fallbackRouteParams: FallbackRouteParam[] = [ + { paramName: 'category', paramType: 'dynamic' }, + ] + + resolveRouteParamsFromTree(loaderTree, params, route, fallbackRouteParams) + + // Should NOT extract because pathSegments[2] = '[category]' is a placeholder + expect(params.filter).toBeUndefined() + expect(fallbackRouteParams).toHaveLength(2) + expect(fallbackRouteParams[1]).toEqual({ + paramName: 'filter', + paramType: 'dynamic', + }) + }) + }) + + describe('catchall deriving from pathname with depth', () => { + it('should use depth to correctly slice pathname segments', () => { + // Tree: /blog -> @sidebar/[...catchallParam] + // At depth 1 (after /blog), should get remaining segments + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree('blog', { + sidebar: createLoaderTree('[...catchallParam]'), + }) + ) + const params: Params = {} + const route = parseAppRoute('/blog/2023/posts/my-article', true) + const fallbackRouteParams: FallbackRouteParam[] = [] + + resolveRouteParamsFromTree(loaderTree, params, route, fallbackRouteParams) + + // Should get segments from depth 1 onwards + expect(params.catchallParam).toEqual(['2023', 'posts', 'my-article']) + expect(fallbackRouteParams).toHaveLength(0) + }) + + it('should handle catchall at depth 0 (root level)', () => { + // Tree: / -> @sidebar/[...catchallParam] + const loaderTree = createLoaderTree('', { + sidebar: createLoaderTree('[...catchallParam]'), + }) + const params: Params = {} + const route = parseAppRoute('/blog/2023/posts', true) + const fallbackRouteParams: FallbackRouteParam[] = [] + + resolveRouteParamsFromTree(loaderTree, params, route, fallbackRouteParams) + + // Should get all segments + expect(params.catchallParam).toEqual(['blog', '2023', 'posts']) + expect(fallbackRouteParams).toHaveLength(0) + }) + + it('should handle nested depth correctly', () => { + // Tree: /products/[category] -> @filters/[...filterPath] + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree( + 'products', + {}, + createLoaderTree('[category]', { + filters: createLoaderTree('[...filterPath]'), + }) + ) + ) + const params: Params = { category: 'electronics' } + const route = parseAppRoute('/products/electronics/phones/iphone', true) + const fallbackRouteParams: FallbackRouteParam[] = [] + + resolveRouteParamsFromTree(loaderTree, params, route, fallbackRouteParams) + + // Should get segments from depth 2 onwards (after /products/[category]) + expect(params.filterPath).toEqual(['phones', 'iphone']) + expect(fallbackRouteParams).toHaveLength(0) + }) + + it('should handle single path segment', () => { + // Tree: / -> @sidebar/[...catchallParam] + const loaderTree = createLoaderTree('', { + sidebar: createLoaderTree('[...catchallParam]'), + }) + const params: Params = {} + const route = parseAppRoute('/single', true) + const fallbackRouteParams: FallbackRouteParam[] = [] + + resolveRouteParamsFromTree(loaderTree, params, route, fallbackRouteParams) + + expect(params.catchallParam).toEqual(['single']) + expect(fallbackRouteParams).toHaveLength(0) + }) + }) + + describe('route groups', () => { + it('should not increment depth for route groups', () => { + // Tree: /(marketing) -> @sidebar/[...catchallParam] + // Route groups don't contribute to pathname depth + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree('(marketing)', { + sidebar: createLoaderTree('[...catchallParam]'), + }) + ) + const params: Params = {} + const route = parseAppRoute('/blog/post', true) + const fallbackRouteParams: FallbackRouteParam[] = [] + + resolveRouteParamsFromTree(loaderTree, params, route, fallbackRouteParams) + + // Should get all segments since route group doesn't increment depth + expect(params.catchallParam).toEqual(['blog', 'post']) + expect(fallbackRouteParams).toHaveLength(0) + }) + + it('should handle multiple route groups', () => { + // Tree: /(group1)/(group2)/blog -> @sidebar/[...path] + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree( + '(group1)', + {}, + createLoaderTree( + '(group2)', + {}, + createLoaderTree('blog', { + sidebar: createLoaderTree('[...path]'), + }) + ) + ) + ) + const params: Params = {} + const route = parseAppRoute('/blog/2023/posts', true) + const fallbackRouteParams: FallbackRouteParam[] = [] + + resolveRouteParamsFromTree(loaderTree, params, route, fallbackRouteParams) + + // Should get segments from depth 1 (after /blog), route groups don't count + expect(params.path).toEqual(['2023', 'posts']) + expect(fallbackRouteParams).toHaveLength(0) + }) + }) + + describe('optional-catchall with empty pathname', () => { + it('should set params to empty array when pathname has no segments', () => { + // Tree: / -> @sidebar/[[...optionalCatchall]] + const loaderTree = createLoaderTree('', { + sidebar: createLoaderTree('[[...optionalCatchall]]'), + }) + const params: Params = {} + const route = parseAppRoute('/', true) + const fallbackRouteParams: FallbackRouteParam[] = [] + + resolveRouteParamsFromTree(loaderTree, params, route, fallbackRouteParams) + + expect(params.optionalCatchall).toBeUndefined() + expect(fallbackRouteParams).toHaveLength(0) + }) + + it('should handle optional catchall at nested depth with no remaining segments', () => { + // Tree: /blog -> @sidebar/[[...optionalPath]] + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree('blog', { + sidebar: createLoaderTree('[[...optionalPath]]'), + }) + ) + const params: Params = {} + const route = parseAppRoute('/blog', true) + const fallbackRouteParams: FallbackRouteParam[] = [] + + resolveRouteParamsFromTree(loaderTree, params, route, fallbackRouteParams) + + expect(params.optionalPath).toBeUndefined() + expect(fallbackRouteParams).toHaveLength(0) + }) + }) + + describe('optional-catchall with non-empty pathname', () => { + it('should populate params with path segments', () => { + // Tree: / -> @sidebar/[[...optionalCatchall]] + const loaderTree = createLoaderTree('', { + sidebar: createLoaderTree('[[...optionalCatchall]]'), + }) + const params: Params = {} + const route = parseAppRoute('/api/v1/users', true) + const fallbackRouteParams: FallbackRouteParam[] = [] + + resolveRouteParamsFromTree(loaderTree, params, route, fallbackRouteParams) + + expect(params.optionalCatchall).toEqual(['api', 'v1', 'users']) + expect(fallbackRouteParams).toHaveLength(0) + }) + }) + + describe('catchall-intercepted params', () => { + it('should handle catchall-intercepted params in parallel routes', () => { + // Tree: / -> @modal/[...path] where [...path] uses catchall-intercepted type + // Note: catchall-intercepted is a param type, not related to interception routes + const loaderTree = createLoaderTree('', { + modal: createLoaderTree('[...path]'), + }) + const params: Params = {} + const route = parseAppRoute('/photos/album/2023', true) + const fallbackRouteParams: FallbackRouteParam[] = [] + + resolveRouteParamsFromTree(loaderTree, params, route, fallbackRouteParams) + + // Should get all segments + expect(params.path).toEqual(['photos', 'album', '2023']) + expect(fallbackRouteParams).toHaveLength(0) + }) + }) + + describe('error cases', () => { + it('should throw error for catchall with empty pathname', () => { + // Tree: / -> @sidebar/[...catchallParam] + const loaderTree = createLoaderTree('', { + sidebar: createLoaderTree('[...catchallParam]'), + }) + const params: Params = {} + const route = parseAppRoute('/', true) + const fallbackRouteParams: FallbackRouteParam[] = [] + + expect(() => + resolveRouteParamsFromTree( + loaderTree, + params, + route, + fallbackRouteParams + ) + ).toThrow(/Unexpected empty path segments/) + }) + + it('should throw error for catchall when depth exceeds pathname', () => { + // Tree: /blog/posts -> @sidebar/[...catchallParam] + // But pathname is just /blog + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree( + 'blog', + {}, + createLoaderTree('posts', { + sidebar: createLoaderTree('[...catchallParam]'), + }) + ) + ) + const params: Params = {} + const route = parseAppRoute('/blog', true) + const fallbackRouteParams: FallbackRouteParam[] = [] + + expect(() => + resolveRouteParamsFromTree( + loaderTree, + params, + route, + fallbackRouteParams + ) + ).toThrow(/Unexpected empty path segments/) + }) + }) + + describe('complex scenarios', () => { + it('should handle multiple parallel routes at same level', () => { + // Tree: / -> @sidebar/[...sidebarPath] + @modal/[[...modalPath]] + const loaderTree = createLoaderTree('', { + sidebar: createLoaderTree('[...sidebarPath]'), + modal: createLoaderTree('[[...modalPath]]'), + }) + const params: Params = {} + const route = parseAppRoute('/products/electronics', true) + const fallbackRouteParams: FallbackRouteParam[] = [] + + resolveRouteParamsFromTree(loaderTree, params, route, fallbackRouteParams) + + expect(params.sidebarPath).toEqual(['products', 'electronics']) + expect(params.modalPath).toEqual(['products', 'electronics']) + expect(fallbackRouteParams).toHaveLength(0) + }) + + it('should handle parallel route with embedded dynamic param from pathname', () => { + // Tree: /[lang] -> @sidebar/[...path] + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree('[lang]', { + sidebar: createLoaderTree('[...path]'), + }) + ) + const params: Params = { lang: 'en' } + const route = parseAppRoute('/en/blog/post', true) + const fallbackRouteParams: FallbackRouteParam[] = [] + + resolveRouteParamsFromTree(loaderTree, params, route, fallbackRouteParams) + + // Should resolve [lang] in path segments to 'en' + expect(params.path).toEqual(['blog', 'post']) + expect(fallbackRouteParams).toHaveLength(0) + }) + + it('should only process parallel routes, not children route', () => { + // Tree: / -> children: /blog, sidebar: /[...path] + const loaderTree = createLoaderTree( + '', + { + sidebar: createLoaderTree('[...path]'), + }, + createLoaderTree('blog') + ) + const params: Params = {} + const route = parseAppRoute('/blog/post', true) + const fallbackRouteParams: FallbackRouteParam[] = [] + + resolveRouteParamsFromTree(loaderTree, params, route, fallbackRouteParams) + + // Should only process @sidebar, not children + expect(params.path).toEqual(['blog', 'post']) + expect(fallbackRouteParams).toHaveLength(0) + }) + }) + + describe('interception routes', () => { + it('should increment depth for (.) interception route (same level)', () => { + // Tree: /(.)photo -> @modal/[...segments] + // Interception routes should increment depth unlike route groups + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree('(.)photo', { + modal: createLoaderTree('[...segments]'), + }) + ) + const params: Params = {} + const route = parseAppRoute('/photo/123/details', true) + const fallbackRouteParams: FallbackRouteParam[] = [] + + resolveRouteParamsFromTree(loaderTree, params, route, fallbackRouteParams) + + // Should get segments from depth 1 onwards (after /(.)photo) + expect(params.segments).toEqual(['123', 'details']) + expect(fallbackRouteParams).toHaveLength(0) + }) + + it('should increment depth for (..) interception route (parent level)', () => { + // Tree: /gallery/(..)photo -> @modal/[id] + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree( + 'gallery', + {}, + createLoaderTree('(..)photo', { + modal: createLoaderTree('[id]'), + }) + ) + ) + const params: Params = {} + const route = parseAppRoute('/gallery/photo/123', true) + const fallbackRouteParams: FallbackRouteParam[] = [] + + resolveRouteParamsFromTree(loaderTree, params, route, fallbackRouteParams) + + // (..)photo is at depth 1, @modal/[id] should extract from depth 2 + expect(params.id).toBe('123') + expect(fallbackRouteParams).toHaveLength(0) + }) + + it('should increment depth for (...) interception route (root level)', () => { + // Tree: /app/gallery/(...)photo -> @modal/[...path] + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree( + 'app', + {}, + createLoaderTree( + 'gallery', + {}, + createLoaderTree('(...)photo', { + modal: createLoaderTree('[...path]'), + }) + ) + ) + ) + const params: Params = {} + const route = parseAppRoute('/app/gallery/photo/2023/album', true) + const fallbackRouteParams: FallbackRouteParam[] = [] + + resolveRouteParamsFromTree(loaderTree, params, route, fallbackRouteParams) + + // (...)photo is at depth 2, @modal/[...path] should extract from depth 3 + expect(params.path).toEqual(['2023', 'album']) + expect(fallbackRouteParams).toHaveLength(0) + }) + + it('should increment depth for (..)(..) interception route (grandparent level)', () => { + // Tree: /a/b/(..)(..)photo -> @modal/[category] + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree( + 'a', + {}, + createLoaderTree( + 'b', + {}, + createLoaderTree('(..)(..)photo', { + modal: createLoaderTree('[category]'), + }) + ) + ) + ) + const params: Params = {} + const route = parseAppRoute('/a/b/photo/nature', true) + const fallbackRouteParams: FallbackRouteParam[] = [] + + resolveRouteParamsFromTree(loaderTree, params, route, fallbackRouteParams) + + // (..)(..)photo is at depth 2, @modal/[category] should extract from depth 3 + expect(params.category).toBe('nature') + expect(fallbackRouteParams).toHaveLength(0) + }) + + it('should distinguish interception routes from regular route groups', () => { + // Tree: /(marketing) -> @sidebar/[...path] (route group) + // vs: /(.)photo -> @modal/[...path] (interception route) + const routeGroupTree = createLoaderTree( + '', + {}, + createLoaderTree('(marketing)', { + sidebar: createLoaderTree('[...path]'), + }) + ) + + const interceptionTree = createLoaderTree( + '', + {}, + createLoaderTree('(.)photo', { + modal: createLoaderTree('[...path]'), + }) + ) + + const route = parseAppRoute('/photo/123', true) + + // Route group - should NOT increment depth + const routeGroupParams: Params = {} + const routeGroupFallback: FallbackRouteParam[] = [] + resolveRouteParamsFromTree( + routeGroupTree, + routeGroupParams, + route, + routeGroupFallback + ) + // Gets all segments because route group doesn't increment depth + expect(routeGroupParams.path).toEqual(['photo', '123']) + + // Interception route - SHOULD increment depth + const interceptionParams: Params = {} + const interceptionFallback: FallbackRouteParam[] = [] + resolveRouteParamsFromTree( + interceptionTree, + interceptionParams, + route, + interceptionFallback + ) + // Gets segments from depth 1 because (.)photo increments depth + expect(interceptionParams.path).toEqual(['123']) + }) + }) + + describe('empty pathname edge cases', () => { + it('should mark dynamic param as fallback when pathname is empty', () => { + // Tree: / -> @modal/[id] + const loaderTree = createLoaderTree('', { + modal: createLoaderTree('[id]'), + }) + const params: Params = {} + const route = parseAppRoute('/', true) + const fallbackRouteParams: FallbackRouteParam[] = [] + + resolveRouteParamsFromTree(loaderTree, params, route, fallbackRouteParams) + + expect(params.id).toBeUndefined() + expect(fallbackRouteParams).toHaveLength(1) + expect(fallbackRouteParams[0]).toEqual({ + paramName: 'id', + paramType: 'dynamic', + }) + }) + + it('should mark multiple dynamic params as fallback when pathname is empty', () => { + // Tree: / -> @modal/[category] + @sidebar/[filter] + const loaderTree = createLoaderTree('', { + modal: createLoaderTree('[category]'), + sidebar: createLoaderTree('[filter]'), + }) + const params: Params = {} + const route = parseAppRoute('/', true) + const fallbackRouteParams: FallbackRouteParam[] = [] + + resolveRouteParamsFromTree(loaderTree, params, route, fallbackRouteParams) + + expect(params.category).toBeUndefined() + expect(params.filter).toBeUndefined() + expect(fallbackRouteParams).toHaveLength(2) + expect(fallbackRouteParams).toContainEqual({ + paramName: 'category', + paramType: 'dynamic', + }) + expect(fallbackRouteParams).toContainEqual({ + paramName: 'filter', + paramType: 'dynamic', + }) + }) + + it('should handle nested parallel route with empty pathname at that depth', () => { + // Tree: /blog -> @modal/[id] + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree('blog', { + modal: createLoaderTree('[id]'), + }) + ) + const params: Params = {} + const route = parseAppRoute('/blog', true) + const fallbackRouteParams: FallbackRouteParam[] = [] + + resolveRouteParamsFromTree(loaderTree, params, route, fallbackRouteParams) + + // No segment at depth 1, should mark as fallback + expect(params.id).toBeUndefined() + expect(fallbackRouteParams).toHaveLength(1) + expect(fallbackRouteParams[0]).toEqual({ + paramName: 'id', + paramType: 'dynamic', + }) + }) + }) + + describe('complex path segments', () => { + it('should handle catch-all with embedded param placeholders in pathname', () => { + // Tree: / -> @sidebar/[...path] + // Pathname contains a placeholder like [category] which is unknown + const loaderTree = createLoaderTree('', { + sidebar: createLoaderTree('[...path]'), + }) + const params: Params = {} + const route = parseAppRoute('/blog/[category]/tech', true) + const fallbackRouteParams: FallbackRouteParam[] = [ + { paramName: 'category', paramType: 'dynamic' }, + ] + + resolveRouteParamsFromTree(loaderTree, params, route, fallbackRouteParams) + + // Should mark as fallback because there's a non-parallel fallback param + expect(params.path).toBeUndefined() + expect(fallbackRouteParams).toHaveLength(2) + expect(fallbackRouteParams[1]).toEqual({ + paramName: 'path', + paramType: 'catchall', + }) + }) + + it('should mark catch-all as fallback when pathname has unknown param placeholder', () => { + // Tree: /[lang] -> @sidebar/[...path] + // Pathname has [lang] which is known, but [category] which is not + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree('[lang]', { + sidebar: createLoaderTree('[...path]'), + }) + ) + const params: Params = { lang: 'en' } + const route = parseAppRoute('/en/blog/[category]', true) + const fallbackRouteParams: FallbackRouteParam[] = [] + + resolveRouteParamsFromTree(loaderTree, params, route, fallbackRouteParams) + + // Should mark path as fallback because pathname contains unknown [category] placeholder + expect(params.path).toBeUndefined() + expect(fallbackRouteParams).toHaveLength(1) + expect(fallbackRouteParams[0]).toEqual({ + paramName: 'path', + paramType: 'catchall', + }) + }) + + it('should handle mixed static and dynamic segments in catch-all resolution', () => { + // Tree: /products/[category] -> @filters/[...filterPath] + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree( + 'products', + {}, + createLoaderTree('[category]', { + filters: createLoaderTree('[...filterPath]'), + }) + ) + ) + const params: Params = { category: 'electronics' } + const route = parseAppRoute( + '/products/electronics/brand/apple/price/high', + true + ) + const fallbackRouteParams: FallbackRouteParam[] = [] + + resolveRouteParamsFromTree(loaderTree, params, route, fallbackRouteParams) + + // Should get remaining path after resolving category + expect(params.filterPath).toEqual(['brand', 'apple', 'price', 'high']) + expect(fallbackRouteParams).toHaveLength(0) + }) + }) + + describe('integration scenarios', () => { + it('should handle interception route + parallel route together', () => { + // Tree: /gallery/(.)photo -> @modal/[id] + @sidebar/[category] + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree( + 'gallery', + {}, + createLoaderTree('(.)photo', { + modal: createLoaderTree('[id]'), + sidebar: createLoaderTree('[category]'), + }) + ) + ) + const params: Params = {} + const route = parseAppRoute('/gallery/photo/123', true) + const fallbackRouteParams: FallbackRouteParam[] = [] + + resolveRouteParamsFromTree(loaderTree, params, route, fallbackRouteParams) + + // Both should extract from depth 2 (after /gallery/(.)photo) + expect(params.id).toBe('123') + expect(params.category).toBe('123') + expect(fallbackRouteParams).toHaveLength(0) + }) + + it('should handle route group + parallel route + interception route', () => { + // Tree: /(marketing)/gallery/(.)photo -> @modal/[...path] + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree( + '(marketing)', + {}, + createLoaderTree( + 'gallery', + {}, + createLoaderTree('(.)photo', { + modal: createLoaderTree('[...path]'), + }) + ) + ) + ) + const params: Params = {} + const route = parseAppRoute('/gallery/photo/2023/album', true) + const fallbackRouteParams: FallbackRouteParam[] = [] + + resolveRouteParamsFromTree(loaderTree, params, route, fallbackRouteParams) + + // Route group doesn't increment, gallery does, (.)photo does + // So depth is 2, extract from depth 2 onwards + expect(params.path).toEqual(['2023', 'album']) + expect(fallbackRouteParams).toHaveLength(0) + }) + + it('should handle all param types together', () => { + // Tree: /[lang] -> @modal/[category] + @sidebar/[...tags] + @info/[[...extra]] + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree('[lang]', { + modal: createLoaderTree('[category]'), + sidebar: createLoaderTree('[...tags]'), + info: createLoaderTree('[[...extra]]'), + }) + ) + const params: Params = { lang: 'en' } + const route = parseAppRoute('/en/tech/react/nextjs', true) + const fallbackRouteParams: FallbackRouteParam[] = [] + + resolveRouteParamsFromTree(loaderTree, params, route, fallbackRouteParams) + + // All should extract from depth 1 onwards + expect(params.category).toBe('tech') + expect(params.tags).toEqual(['tech', 'react', 'nextjs']) + expect(params.extra).toEqual(['tech', 'react', 'nextjs']) + expect(fallbackRouteParams).toHaveLength(0) + }) + + it('should handle complex nesting with multiple interception routes', () => { + // Tree: /app/(.)modal/(.)photo -> @dialog/[id] + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree( + 'app', + {}, + createLoaderTree( + '(.)modal', + {}, + createLoaderTree('(.)photo', { + dialog: createLoaderTree('[id]'), + }) + ) + ) + ) + const params: Params = {} + const route = parseAppRoute('/app/modal/photo/image-123', true) + const fallbackRouteParams: FallbackRouteParam[] = [] + + resolveRouteParamsFromTree(loaderTree, params, route, fallbackRouteParams) + + // app (depth 1) + (.)modal (depth 2) + (.)photo (depth 3) -> extract at depth 3 + expect(params.id).toBe('image-123') + expect(fallbackRouteParams).toHaveLength(0) + }) + }) +}) diff --git a/packages/next/src/build/static-paths/utils.ts b/packages/next/src/build/static-paths/utils.ts index efb26ecd285d6..c52b65c69a761 100644 --- a/packages/next/src/build/static-paths/utils.ts +++ b/packages/next/src/build/static-paths/utils.ts @@ -1,4 +1,17 @@ +import type { LoaderTree } from '../../server/lib/app-dir-module' +import type { Params } from '../../server/request/params' +import type { AppPageRouteModule } from '../../server/route-modules/app-page/module.compiled' +import type { AppRouteRouteModule } from '../../server/route-modules/app-route/module.compiled' +import { isAppPageRouteModule } from '../../server/route-modules/checks' import type { DynamicParamTypes } from '../../shared/lib/app-router-types' +import { + parseAppRouteSegment, + type NormalizedAppRoute, +} from '../../shared/lib/router/routes/app' +import { parseLoaderTree } from '../../shared/lib/router/utils/parse-loader-tree' +import type { AppSegment } from '../segment-config/app/app-segments' +import { extractPathnameRouteParamSegmentsFromLoaderTree } from './app/extract-pathname-route-param-segments-from-loader-tree' +import { resolveParamValue } from '../../shared/lib/router/utils/resolve-param-value' import type { FallbackRouteParam } from './types' /** @@ -33,17 +46,144 @@ export function normalizePathname(pathname: string) { } /** - * Creates a fallback route param. + * Extracts segments that contribute to the pathname by traversing the loader tree + * based on the route module type. * - * @param paramName - The name of the param. - * @param isParallelRouteParam - Whether this is a parallel route param or - * descends from a parallel route param. - * @returns The fallback route param. + * @param routeModule - The app route module (page or route handler) + * @param segments - Array of AppSegment objects collected from the route + * @param page - The target pathname to match against, INCLUDING interception + * markers (e.g., "/blog/[slug]", "/(.)photo/[id]") + * @returns Array of segments with param info that contribute to the pathname */ -export function createFallbackRouteParam( - paramName: string, - paramType: DynamicParamTypes, - isParallelRouteParam: boolean -): FallbackRouteParam { - return { paramName, paramType, isParallelRouteParam } +export function extractPathnameRouteParamSegments( + routeModule: AppRouteRouteModule | AppPageRouteModule, + segments: readonly Readonly[], + route: NormalizedAppRoute +): Array<{ + readonly name: string + readonly paramName: string + readonly paramType: DynamicParamTypes +}> { + // For AppPageRouteModule, use the loaderTree traversal approach + if (isAppPageRouteModule(routeModule)) { + const { pathnameRouteParamSegments } = + extractPathnameRouteParamSegmentsFromLoaderTree( + routeModule.userland.loaderTree, + route + ) + return pathnameRouteParamSegments + } + + return extractPathnameRouteParamSegmentsFromSegments(segments) +} + +export function extractPathnameRouteParamSegmentsFromSegments( + segments: readonly Readonly[] +): Array<{ + readonly name: string + readonly paramName: string + readonly paramType: DynamicParamTypes +}> { + // TODO: should we consider what values are already present in the page? + + // For AppRouteRouteModule, filter the segments array to get the route params + // that contribute to the pathname. + const result: Array<{ + readonly name: string + readonly paramName: string + readonly paramType: DynamicParamTypes + }> = [] + + for (const segment of segments) { + // Skip segments without param info. + if (!segment.paramName || !segment.paramType) continue + + // Collect all the route param keys that contribute to the pathname. + result.push({ + name: segment.name, + paramName: segment.paramName, + paramType: segment.paramType, + }) + } + + return result +} + +/** + * Resolves all route parameters from the loader tree. This function uses + * tree-based traversal to correctly handle the hierarchical structure of routes + * and accurately determine parameter values based on their depth in the tree. + * + * This processes both regular route parameters (from the main children route) and + * parallel route parameters (from slots like @modal, @sidebar). + * + * Unlike interpolateParallelRouteParams (which has a complete URL at runtime), + * this build-time function determines which route params are unknown. + * The pathname may contain placeholders like [slug], making it incomplete. + * + * @param loaderTree - The loader tree structure containing route hierarchy + * @param params - The current route parameters object (will be mutated) + * @param route - The current route being processed + * @param fallbackRouteParams - Array of fallback route parameters (will be mutated) + */ +export function resolveRouteParamsFromTree( + loaderTree: LoaderTree, + params: Params, + route: NormalizedAppRoute, + fallbackRouteParams: FallbackRouteParam[] +): void { + // Stack-based traversal with depth tracking + const stack: Array<{ + tree: LoaderTree + depth: number + }> = [{ tree: loaderTree, depth: 0 }] + + while (stack.length > 0) { + const { tree, depth } = stack.pop()! + const { segment, parallelRoutes } = parseLoaderTree(tree) + + const appSegment = parseAppRouteSegment(segment) + + // If this segment is a route parameter, then we should process it if it's + // not already known and is not already marked as a fallback route param. + if ( + appSegment?.type === 'dynamic' && + !params.hasOwnProperty(appSegment.param.paramName) && + !fallbackRouteParams.some( + (param) => param.paramName === appSegment.param.paramName + ) + ) { + const { paramName, paramType } = appSegment.param + + const paramValue = resolveParamValue( + paramName, + paramType, + depth, + route, + params + ) + + if (paramValue !== undefined) { + params[paramName] = paramValue + } else if (paramType !== 'optional-catchall') { + // If we couldn't resolve the param, mark it as a fallback + fallbackRouteParams.push({ paramName, paramType }) + } + } + + // Calculate next depth - increment if this is not a route group and not empty + let nextDepth = depth + if ( + appSegment && + appSegment.type !== 'route-group' && + appSegment.type !== 'parallel-route' + ) { + nextDepth++ + } + + // Add all parallel routes to the stack for processing. + for (const parallelRoute of Object.values(parallelRoutes)) { + stack.push({ tree: parallelRoute, depth: nextDepth }) + } + } } diff --git a/packages/next/src/build/utils.ts b/packages/next/src/build/utils.ts index b02db3628939c..a889669b34f7a 100644 --- a/packages/next/src/build/utils.ts +++ b/packages/next/src/build/utils.ts @@ -78,6 +78,7 @@ import { formatIssue, isRelevantWarning } from '../shared/lib/turbopack/utils' import type { TurbopackResult } from './swc/types' import type { FunctionsConfigManifest, ManifestRoute } from './index' import { getNamedRouteRegex } from '../shared/lib/router/utils/route-regex' +import { parseAppRoute } from '../shared/lib/router/routes/app' export type ROUTER_TYPE = 'pages' | 'app' @@ -870,14 +871,17 @@ export async function isPageStatic({ appConfig.revalidate = 0 } + const route = parseAppRoute(page, true) + // If the page is dynamic and we're not in edge runtime, then we need to // build the static paths. The edge runtime doesn't support static // paths. - if (isDynamicRoute(page) && !pathIsEdgeRuntime) { + if (route.dynamicSegments.length > 0 && !pathIsEdgeRuntime) { ;({ prerenderedRoutes, fallbackMode: prerenderFallbackMode } = await buildAppStaticPaths({ dir, page, + route, cacheComponents, authInterrupts, segments, diff --git a/packages/next/src/build/validate-app-paths.ts b/packages/next/src/build/validate-app-paths.ts index 701b4ce82b157..17e0e371f487d 100644 --- a/packages/next/src/build/validate-app-paths.ts +++ b/packages/next/src/build/validate-app-paths.ts @@ -15,39 +15,39 @@ import { */ function validateSegmentParam(param: SegmentParam, pathname: string): void { // Check for empty parameter names - if (param.param.length === 0) { + if (param.paramName.length === 0) { throw new Error(`Parameter names cannot be empty in route "${pathname}".`) } // Check for three-dot character (…) instead of ... - if (param.param.includes('…')) { + if (param.paramName.includes('…')) { throw new Error( - `Detected a three-dot character ('…') in parameter "${param.param}" in route "${pathname}". Did you mean ('...')?` + `Detected a three-dot character ('…') in parameter "${param.paramName}" in route "${pathname}". Did you mean ('...')?` ) } // Check for optional non-catch-all segments (not yet supported) if ( - param.type !== 'optional-catchall' && - param.param.startsWith('[') && - param.param.endsWith(']') + param.paramType !== 'optional-catchall' && + param.paramName.startsWith('[') && + param.paramName.endsWith(']') ) { throw new Error( - `Optional route parameters are not yet supported ("[${param.param}]") in route "${pathname}".` + `Optional route parameters are not yet supported ("[${param.paramName}]") in route "${pathname}".` ) } // Check for extra brackets - if (param.param.startsWith('[') || param.param.endsWith(']')) { + if (param.paramName.startsWith('[') || param.paramName.endsWith(']')) { throw new Error( - `Segment names may not start or end with extra brackets ('${param.param}') in route "${pathname}".` + `Segment names may not start or end with extra brackets ('${param.paramName}') in route "${pathname}".` ) } // Check for erroneous periods - if (param.param.startsWith('.')) { + if (param.paramName.startsWith('.')) { throw new Error( - `Segment names may not start with erroneous periods ('${param.param}') in route "${pathname}".` + `Segment names may not start with erroneous periods ('${param.paramName}') in route "${pathname}".` ) } } @@ -82,7 +82,7 @@ function validateAppRoute(route: NormalizedAppRoute): void { // First, validate syntax validateSegmentParam(segment.param, route.pathname) - const properties = getParamProperties(segment.param.type) + const properties = getParamProperties(segment.param.paramType) if (properties.repeat) { if (properties.optional) { @@ -95,25 +95,25 @@ function validateAppRoute(route: NormalizedAppRoute): void { } // Check to see if the parameter name is already in use. - if (slugNames.has(segment.param.param)) { + if (slugNames.has(segment.param.paramName)) { throw new Error( - `You cannot have the same slug name "${segment.param.param}" repeat within a single dynamic path in route "${route.pathname}".` + `You cannot have the same slug name "${segment.param.paramName}" repeat within a single dynamic path in route "${route.pathname}".` ) } // Normalize parameter name for comparison by removing all non-word // characters. - const normalizedSegment = segment.param.param.replace(/\W/g, '') + const normalizedSegment = segment.param.paramName.replace(/\W/g, '') if (normalizedSegments.has(normalizedSegment)) { const existing = Array.from(slugNames).find((s) => { return s.replace(/\W/g, '') === normalizedSegment }) throw new Error( - `You cannot have the slug names "${existing}" and "${segment.param.param}" differ only by non-word symbols within a single dynamic path in route "${route.pathname}".` + `You cannot have the slug names "${existing}" and "${segment.param.paramName}" differ only by non-word symbols within a single dynamic path in route "${route.pathname}".` ) } - slugNames.add(segment.param.param) + slugNames.add(segment.param.paramName) normalizedSegments.add(normalizedSegment) } @@ -179,7 +179,7 @@ function normalizeSegments( // Dynamic segment - normalize the parameter name by replacing the // parameter name with a wildcard. The interception marker is already // included in the segment name, so no special handling is needed. - return segment.name.replace(segment.param.param, '*') + return segment.name.replace(segment.param.paramName, '*') }) .join('/') ) @@ -217,7 +217,7 @@ export function validateAppPaths( const lastSegment = route.segments[route.segments.length - 1] if ( lastSegment?.type === 'dynamic' && - lastSegment.param.type === 'optional-catchall' + lastSegment.param.paramType === 'optional-catchall' ) { const prefixSegments = route.segments.slice(0, -1) const normalizedPrefix = normalizeSegments(prefixSegments) @@ -229,14 +229,14 @@ export function validateAppPaths( // /[[...slug]] has prefix '' which should match '/' if (prefixSegments.length === 0 && appPath === '/') { throw new Error( - `You cannot define a route with the same specificity as an optional catch-all route ("${appPath}" and "/[[...${lastSegment.param.param}]]").` + `You cannot define a route with the same specificity as an optional catch-all route ("${appPath}" and "/[[...${lastSegment.param.paramName}]]").` ) } // General case: compare normalized structures if (normalizedAppPath === normalizedPrefix) { throw new Error( - `You cannot define a route with the same specificity as an optional catch-all route ("${appPath}" and "${normalizedPrefix}/[[...${lastSegment.param.param}]]").` + `You cannot define a route with the same specificity as an optional catch-all route ("${appPath}" and "${normalizedPrefix}/[[...${lastSegment.param.paramName}]]").` ) } } diff --git a/packages/next/src/build/webpack/loaders/next-root-params-loader.ts b/packages/next/src/build/webpack/loaders/next-root-params-loader.ts index c5aa71bd31f6e..cf89abe1077de 100644 --- a/packages/next/src/build/webpack/loaders/next-root-params-loader.ts +++ b/packages/next/src/build/webpack/loaders/next-root-params-loader.ts @@ -159,7 +159,7 @@ function getParamsFromLayoutFilePath({ for (const segment of segments) { const param = getSegmentParam(segment) if (param !== null) { - paramNames.push(param.param) + paramNames.push(param.paramName) } } return paramNames diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index cb05115ced45e..d466eaa827c2d 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -408,8 +408,8 @@ function makeGetDynamicParamFromSegment( if (!segmentParam) { return null } - const segmentKey = segmentParam.param - const dynamicParamType = dynamicParamTypes[segmentParam.type] + const segmentKey = segmentParam.paramName + const dynamicParamType = dynamicParamTypes[segmentParam.paramType] return getDynamicParam( interpolatedParams, segmentKey, diff --git a/packages/next/src/server/app-render/walk-tree-with-flight-router-state.tsx b/packages/next/src/server/app-render/walk-tree-with-flight-router-state.tsx index 336ab006874fa..b4edfb6b2d6ae 100644 --- a/packages/next/src/server/app-render/walk-tree-with-flight-router-state.tsx +++ b/packages/next/src/server/app-render/walk-tree-with-flight-router-state.tsx @@ -307,5 +307,5 @@ const canSegmentBeOverridden = ( return false } - return getSegmentParam(existingSegment)?.param === segment[0] + return getSegmentParam(existingSegment)?.paramName === segment[0] } diff --git a/packages/next/src/server/dev/static-paths-worker.ts b/packages/next/src/server/dev/static-paths-worker.ts index 08300f56e6fda..f38663f675b9f 100644 --- a/packages/next/src/server/dev/static-paths-worker.ts +++ b/packages/next/src/server/dev/static-paths-worker.ts @@ -20,6 +20,7 @@ import { buildPagesStaticPaths } from '../../build/static-paths/pages' import { createIncrementalCache } from '../../export/helpers/create-incremental-cache' import type { AppPageRouteModule } from '../route-modules/app-page/module' import type { AppRouteRouteModule } from '../route-modules/app-route/module' +import { parseAppRoute } from '../../shared/lib/router/routes/app' type RuntimeConfig = { pprConfig: ExperimentalPPRConfig | undefined @@ -111,6 +112,13 @@ export async function loadStaticPaths({ routeModule as AppPageRouteModule | AppRouteRouteModule ) + const route = parseAppRoute(pathname, true) + if (route.dynamicSegments.length === 0) { + throw new InvariantError( + `Expected a dynamic route, but got a static route: ${pathname}` + ) + } + const isRoutePPREnabled = isAppPageRouteModule(routeModule) && checkIsRoutePPREnabled(config.pprConfig) @@ -120,6 +128,7 @@ export async function loadStaticPaths({ return buildAppStaticPaths({ dir, page: pathname, + route, cacheComponents: config.cacheComponents, segments, distDir, diff --git a/packages/next/src/server/request/fallback-params.test.ts b/packages/next/src/server/request/fallback-params.test.ts index 5844ae79423f0..c16eb0f18ea2e 100644 --- a/packages/next/src/server/request/fallback-params.test.ts +++ b/packages/next/src/server/request/fallback-params.test.ts @@ -1,11 +1,43 @@ -import { createOpaqueFallbackRouteParams } from './fallback-params' +import { + createOpaqueFallbackRouteParams, + getFallbackRouteParams, +} from './fallback-params' import type { FallbackRouteParam } from '../../build/static-paths/types' +import type AppPageRouteModule from '../route-modules/app-page/module' +import type { LoaderTree } from '../lib/app-dir-module' + +// Helper to create LoaderTree structures for testing +type TestLoaderTree = [ + segment: string, + parallelRoutes: { [key: string]: TestLoaderTree }, + modules: Record, +] + +function createLoaderTree( + segment: string, + parallelRoutes: { [key: string]: TestLoaderTree } = {}, + children?: TestLoaderTree +): TestLoaderTree { + const routes = children ? { ...parallelRoutes, children } : parallelRoutes + return [segment, routes, {}] +} + +/** + * Creates a mock AppPageRouteModule for testing. + */ +function createMockRouteModule(loaderTree: LoaderTree): AppPageRouteModule { + return { + userland: { + loaderTree, + }, + } as AppPageRouteModule +} describe('createOpaqueFallbackRouteParams', () => { describe('opaque object interface', () => { const fallbackParams: readonly FallbackRouteParam[] = [ - { paramName: 'slug', paramType: 'dynamic', isParallelRouteParam: false }, - { paramName: 'modal', paramType: 'dynamic', isParallelRouteParam: true }, + { paramName: 'slug', paramType: 'dynamic' }, + { paramName: 'modal', paramType: 'dynamic' }, ] it('has method works correctly', () => { @@ -38,3 +70,525 @@ describe('createOpaqueFallbackRouteParams', () => { }) }) }) + +describe('getFallbackRouteParams', () => { + describe('Regular Routes (children segments)', () => { + it('should extract single dynamic segment from children route', () => { + // Tree: /[slug] + const loaderTree = createLoaderTree('', {}, createLoaderTree('[slug]')) + const routeModule = createMockRouteModule(loaderTree) + const result = getFallbackRouteParams('/[slug]', routeModule) + + expect(result).not.toBeNull() + expect(result!.has('slug')).toBe(true) + expect(result!.get('slug')?.[1]).toBe('d') // 'd' = dynamic (short type) + }) + + it('should extract multiple nested dynamic segments', () => { + // Tree: /[category]/[slug] + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree('[category]', {}, createLoaderTree('[slug]')) + ) + const routeModule = createMockRouteModule(loaderTree) + const result = getFallbackRouteParams('/[category]/[slug]', routeModule) + + expect(result).not.toBeNull() + expect(result!.size).toBe(2) + expect(result!.has('category')).toBe(true) + expect(result!.has('slug')).toBe(true) + expect(result!.get('category')?.[1]).toBe('d') + expect(result!.get('slug')?.[1]).toBe('d') + }) + + it('should extract catchall segment', () => { + // Tree: /[...slug] + const loaderTree = createLoaderTree('', {}, createLoaderTree('[...slug]')) + const routeModule = createMockRouteModule(loaderTree) + const result = getFallbackRouteParams('/[...slug]', routeModule) + + expect(result).not.toBeNull() + expect(result!.size).toBe(1) + expect(result!.has('slug')).toBe(true) + expect(result!.get('slug')?.[1]).toBe('c') // 'c' = catchall + }) + + it('should extract optional catchall segment', () => { + // Tree: /[[...slug]] + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree('[[...slug]]') + ) + const routeModule = createMockRouteModule(loaderTree) + const result = getFallbackRouteParams('/[[...slug]]', routeModule) + + expect(result).not.toBeNull() + expect(result!.size).toBe(1) + expect(result!.has('slug')).toBe(true) + expect(result!.get('slug')?.[1]).toBe('oc') // 'oc' = optional-catchall + }) + + it('should extract mixed static and dynamic segments', () => { + // Tree: /blog/[category]/posts/[slug] + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree( + 'blog', + {}, + createLoaderTree( + '[category]', + {}, + createLoaderTree('posts', {}, createLoaderTree('[slug]')) + ) + ) + ) + const routeModule = createMockRouteModule(loaderTree) + const result = getFallbackRouteParams( + '/blog/[category]/posts/[slug]', + routeModule + ) + + expect(result).not.toBeNull() + expect(result!.size).toBe(2) + expect(result!.has('category')).toBe(true) + expect(result!.has('slug')).toBe(true) + }) + + it('should handle route with no dynamic segments', () => { + // Tree: /blog/posts + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree('blog', {}, createLoaderTree('posts')) + ) + const routeModule = createMockRouteModule(loaderTree) + const result = getFallbackRouteParams('/blog/posts', routeModule) + + // Should return null for no fallback params + expect(result).toBeNull() + }) + + it('should handle partially static routes', () => { + // Tree: /[teamSlug]/[projectSlug] but page is /vercel/[projectSlug] + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree('[teamSlug]', {}, createLoaderTree('[projectSlug]')) + ) + const routeModule = createMockRouteModule(loaderTree) + const result = getFallbackRouteParams( + '/vercel/[projectSlug]', + routeModule + ) + + expect(result).not.toBeNull() + // Only projectSlug should be a fallback param, vercel is static + expect(result!.has('projectSlug')).toBe(true) + expect(result!.has('teamSlug')).toBe(false) + }) + }) + + describe('Route Groups', () => { + it('should ignore route groups when extracting segments', () => { + // Tree: /(marketing)/blog/[slug] + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree( + '(marketing)', + {}, + createLoaderTree('blog', {}, createLoaderTree('[slug]')) + ) + ) + const routeModule = createMockRouteModule(loaderTree) + const result = getFallbackRouteParams('/blog/[slug]', routeModule) + + expect(result).not.toBeNull() + expect(result!.size).toBe(1) + expect(result!.has('slug')).toBe(true) + }) + + it('should handle route groups mixed with static segments', () => { + // Tree: /(app)/dashboard/(users)/[userId] + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree( + '(app)', + {}, + createLoaderTree( + 'dashboard', + {}, + createLoaderTree('(users)', {}, createLoaderTree('[userId]')) + ) + ) + ) + const routeModule = createMockRouteModule(loaderTree) + const result = getFallbackRouteParams('/dashboard/[userId]', routeModule) + + expect(result).not.toBeNull() + expect(result!.size).toBe(1) + expect(result!.has('userId')).toBe(true) + }) + }) + + describe('Parallel Routes', () => { + it('should extract segment from parallel route matching pathname', () => { + // Tree: / -> @modal/[id] + const loaderTree = createLoaderTree('', { + modal: createLoaderTree('[id]'), + }) + const routeModule = createMockRouteModule(loaderTree) + const result = getFallbackRouteParams('/[id]', routeModule) + + expect(result).not.toBeNull() + expect(result!.size).toBe(1) + expect(result!.has('id')).toBe(true) + }) + + it('should extract segments from both children and parallel routes', () => { + // Tree: /[lang] -> children + @modal/[photoId] + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree('[lang]', { + modal: createLoaderTree('[photoId]'), + }) + ) + const routeModule = createMockRouteModule(loaderTree) + const result = getFallbackRouteParams('/[lang]/[photoId]', routeModule) + + expect(result).not.toBeNull() + expect(result!.size).toBe(2) + expect(result!.has('lang')).toBe(true) + expect(result!.has('photoId')).toBe(true) + }) + + it('should handle parallel route params that are not in pathname', () => { + // Tree: /[id] -> @modal/[photoId] (photoId is not in pathname /[id]) + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree('[id]', { + modal: createLoaderTree('[photoId]'), + }) + ) + const routeModule = createMockRouteModule(loaderTree) + const result = getFallbackRouteParams('/[id]', routeModule) + + expect(result).not.toBeNull() + expect(result!.size).toBe(2) + expect(result!.has('id')).toBe(true) + // photoId should also be included as it's a parallel route param + expect(result!.has('photoId')).toBe(true) + }) + }) + + describe('Interception Routes', () => { + it('should extract segment from (.) same-level interception route', () => { + // Tree: /(.)photo/[photoId] + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree('(.)photo', {}, createLoaderTree('[photoId]')) + ) + const routeModule = createMockRouteModule(loaderTree) + const result = getFallbackRouteParams('/(.)photo/[photoId]', routeModule) + + expect(result).not.toBeNull() + expect(result!.size).toBe(1) + expect(result!.has('photoId')).toBe(true) + }) + + it('should extract segment from (..) parent-level interception route', () => { + // Tree: /gallery/(..)photo/[photoId] + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree( + 'gallery', + {}, + createLoaderTree('(..)photo', {}, createLoaderTree('[photoId]')) + ) + ) + const routeModule = createMockRouteModule(loaderTree) + const result = getFallbackRouteParams( + '/gallery/(..)photo/[photoId]', + routeModule + ) + + expect(result).not.toBeNull() + expect(result!.size).toBe(1) + expect(result!.has('photoId')).toBe(true) + }) + + it('should extract intercepted param when marker is part of the segment itself', () => { + // Tree: /(.)[photoId] - the interception marker is PART OF the dynamic segment + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree('(.)[photoId]') + ) + const routeModule = createMockRouteModule(loaderTree) + const result = getFallbackRouteParams('/[photoId]', routeModule) + + expect(result).not.toBeNull() + expect(result!.size).toBe(1) + expect(result!.has('photoId')).toBe(true) + // Should have intercepted type + expect(result!.get('photoId')?.[1]).toBe('di(.)') // 'di(.)' = dynamic-intercepted-(.)' + }) + }) + + describe('Interception Routes in Parallel Routes', () => { + it('should extract segment from interception route in parallel slot', () => { + // Tree: @modal/(.)photo/[photoId] + const loaderTree = createLoaderTree('', { + modal: createLoaderTree('(.)photo', {}, createLoaderTree('[photoId]')), + }) + const routeModule = createMockRouteModule(loaderTree) + const result = getFallbackRouteParams('/(.)photo/[photoId]', routeModule) + + expect(result).not.toBeNull() + expect(result!.size).toBe(1) + expect(result!.has('photoId')).toBe(true) + }) + + it('should extract segments from both children and intercepting parallel route', () => { + // Tree: /[id] -> children + @modal/(.)photo/[photoId] + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree('[id]', { + modal: createLoaderTree( + '(.)photo', + {}, + createLoaderTree('[photoId]') + ), + }) + ) + const routeModule = createMockRouteModule(loaderTree) + const result = getFallbackRouteParams( + '/[id]/(.)photo/[photoId]', + routeModule + ) + + expect(result).not.toBeNull() + expect(result!.size).toBe(2) + expect(result!.has('id')).toBe(true) + expect(result!.has('photoId')).toBe(true) + }) + + it('should handle realistic photo gallery pattern with interception', () => { + // Realistic pattern: /photos/[id] with @modal/(.)photo/[photoId] + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree( + 'photos', + {}, + createLoaderTree('[id]', { + modal: createLoaderTree( + '(.)photo', + {}, + createLoaderTree('[photoId]') + ), + }) + ) + ) + const routeModule = createMockRouteModule(loaderTree) + const result = getFallbackRouteParams( + '/photos/[id]/(.)photo/[photoId]', + routeModule + ) + + expect(result).not.toBeNull() + expect(result!.size).toBe(2) + expect(result!.has('id')).toBe(true) + expect(result!.has('photoId')).toBe(true) + }) + }) + + describe('Complex Mixed Scenarios', () => { + it('should handle route groups + parallel routes + interception routes', () => { + // Tree: /(marketing)/[lang] -> @modal/(.)photo/[photoId] + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree( + '(marketing)', + {}, + createLoaderTree('[lang]', { + modal: createLoaderTree( + '(.)photo', + {}, + createLoaderTree('[photoId]') + ), + }) + ) + ) + const routeModule = createMockRouteModule(loaderTree) + const result = getFallbackRouteParams( + '/[lang]/(.)photo/[photoId]', + routeModule + ) + + expect(result).not.toBeNull() + expect(result!.size).toBe(2) + expect(result!.has('lang')).toBe(true) + expect(result!.has('photoId')).toBe(true) + }) + + it('should handle i18n with interception routes', () => { + // Tree: /[locale]/products/[category] -> @modal/(.)product/[productId] + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree( + '[locale]', + {}, + createLoaderTree( + 'products', + {}, + createLoaderTree('[category]', { + modal: createLoaderTree( + '(.)product', + {}, + createLoaderTree('[productId]') + ), + }) + ) + ) + ) + const routeModule = createMockRouteModule(loaderTree) + const result = getFallbackRouteParams( + '/[locale]/products/[category]/(.)product/[productId]', + routeModule + ) + + expect(result).not.toBeNull() + expect(result!.size).toBe(3) + expect(result!.has('locale')).toBe(true) + expect(result!.has('category')).toBe(true) + expect(result!.has('productId')).toBe(true) + }) + + it('should handle partially static i18n route', () => { + // Tree: /[locale]/products/[category] but page is /en/products/[category] + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree( + '[locale]', + {}, + createLoaderTree('products', {}, createLoaderTree('[category]')) + ) + ) + const routeModule = createMockRouteModule(loaderTree) + const result = getFallbackRouteParams( + '/en/products/[category]', + routeModule + ) + + expect(result).not.toBeNull() + expect(result!.size).toBe(1) + expect(result!.has('category')).toBe(true) + // locale should not be a fallback param because 'en' is static + expect(result!.has('locale')).toBe(false) + }) + + it('should handle a partially static intercepting route', () => { + // Tree: /[locale]/(.)photo/[photoId] but page is /en/(.)photo/[photoId] + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree('[locale]', { + modal: createLoaderTree( + '(.)photo', + {}, + createLoaderTree('[photoId]') + ), + }) + ) + const routeModule = createMockRouteModule(loaderTree) + const result = getFallbackRouteParams( + '/en/(.)photo/[photoId]', + routeModule + ) + + expect(result).not.toBeNull() + expect(result!.size).toBe(1) + expect(result!.has('photoId')).toBe(true) + // locale should not be a fallback param because 'en' is static + expect(result!.has('locale')).toBe(false) + }) + }) + + describe('Edge Cases', () => { + it('should return null for pathname with no dynamic segments', () => { + const loaderTree = createLoaderTree( + '', + {}, + createLoaderTree('blog', {}, createLoaderTree('posts')) + ) + const routeModule = createMockRouteModule(loaderTree) + const result = getFallbackRouteParams('/blog/posts', routeModule) + + expect(result).toBeNull() + }) + + it('should handle empty segment in tree', () => { + // Tree: '' -> [id] + const loaderTree = createLoaderTree('', {}, createLoaderTree('[id]')) + const routeModule = createMockRouteModule(loaderTree) + const result = getFallbackRouteParams('/[id]', routeModule) + + expect(result).not.toBeNull() + expect(result!.size).toBe(1) + expect(result!.has('id')).toBe(true) + }) + + it('should handle root dynamic route', () => { + // Tree: /[slug] + const loaderTree = createLoaderTree('', {}, createLoaderTree('[slug]')) + const routeModule = createMockRouteModule(loaderTree) + const result = getFallbackRouteParams('/[slug]', routeModule) + + expect(result).not.toBeNull() + expect(result!.size).toBe(1) + expect(result!.has('slug')).toBe(true) + }) + + it('should handle catchall at root', () => { + // Tree: /[...slug] + const loaderTree = createLoaderTree('', {}, createLoaderTree('[...slug]')) + const routeModule = createMockRouteModule(loaderTree) + const result = getFallbackRouteParams('/[...slug]', routeModule) + + expect(result).not.toBeNull() + expect(result!.size).toBe(1) + expect(result!.has('slug')).toBe(true) + expect(result!.get('slug')?.[1]).toBe('c') // catchall + }) + + it('should handle optional catchall in parallel route', () => { + // Tree: @sidebar/[[...optional]] + const loaderTree = createLoaderTree('', { + sidebar: createLoaderTree('[[...optional]]'), + }) + const routeModule = createMockRouteModule(loaderTree) + + let result = getFallbackRouteParams('/[[...optional]]', routeModule) + expect(result).not.toBeNull() + expect(result!.size).toBe(1) + expect(result!.has('optional')).toBe(true) + expect(result!.get('optional')?.[1]).toBe('oc') // optional-catchall + + result = getFallbackRouteParams('/sidebar/is/real', routeModule) + expect(result).toBeNull() + }) + }) +}) diff --git a/packages/next/src/server/request/fallback-params.ts b/packages/next/src/server/request/fallback-params.ts index 7f584dd8150b8..cadde0b603d19 100644 --- a/packages/next/src/server/request/fallback-params.ts +++ b/packages/next/src/server/request/fallback-params.ts @@ -1,19 +1,10 @@ -import { collectFallbackRouteParams } from '../../build/segment-config/app/app-segments' +import { resolveRouteParamsFromTree } from '../../build/static-paths/utils' import type { FallbackRouteParam } from '../../build/static-paths/types' import type { DynamicParamTypesShort } from '../../shared/lib/app-router-types' -import { InvariantError } from '../../shared/lib/invariant-error' -import { getRouteMatcher } from '../../shared/lib/router/utils/route-matcher' -import { getRouteRegex } from '../../shared/lib/router/utils/route-regex' import { dynamicParamTypes } from '../app-render/get-short-dynamic-param-type' import type AppPageRouteModule from '../route-modules/app-page/module' - -function getParamKeys(page: string) { - const pattern = getRouteRegex(page) - const matcher = getRouteMatcher(pattern) - - // Get the default list of allowed params. - return Object.keys(matcher(page)) -} +import { parseAppRoute } from '../../shared/lib/router/routes/app' +import { extractPathnameRouteParamSegmentsFromLoaderTree } from '../../build/static-paths/app/extract-pathname-route-param-segments-from-loader-tree' export type OpaqueFallbackRouteParamValue = [ /** @@ -96,78 +87,38 @@ export function getFallbackRouteParams( page: string, routeModule: AppPageRouteModule ) { - // First, get the fallback route params based on the provided page. - const unknownParamKeys = new Set(getParamKeys(page)) - - // Needed when processing fallback route params for catchall routes in - // parallel segments, derive from pathname. This is similar to - // getDynamicParam's pagePath parsing logic. - const pathSegments = page.split('/').filter(Boolean) - - const collected = collectFallbackRouteParams(routeModule) - - // Then, we have to get the fallback route params from the segments that are - // associated with parallel route segments. - const fallbackRouteParams: FallbackRouteParam[] = [] - for (const fallbackRouteParam of collected) { - if (fallbackRouteParam.isParallelRouteParam) { - // Try to see if we can resolve this parameter from the page that was - // passed in. - if (unknownParamKeys.has(fallbackRouteParam.paramName)) { - // The parameter is known, we can skip adding it to the fallback route - // params. - continue - } - - if ( - fallbackRouteParam.paramType === 'optional-catchall' || - fallbackRouteParam.paramType === 'catchall' - ) { - // If there are any fallback route segments then we can't use the - // pathname to derive the value because it's not complete. We can - // make this assumption because the routes are always resolved left - // to right and the catchall is always the last segment, so any - // route parameters that are unknown will always contribute to the - // pathname and therefore the catchall param too. - if ( - collected.some( - (param) => - !param.isParallelRouteParam && - unknownParamKeys.has(param.paramName) - ) - ) { - fallbackRouteParams.push(fallbackRouteParam) - continue - } - - if ( - pathSegments.length === 0 && - fallbackRouteParam.paramType !== 'optional-catchall' - ) { - // We shouldn't be able to match a catchall segment without any path - // segments if it's not an optional catchall. - throw new InvariantError( - `Unexpected empty path segments match for a pathname "${page}" with param "${fallbackRouteParam.paramName}" of type "${fallbackRouteParam.paramType}"` - ) - } - - // The path segments are not empty, and the segments didn't contain any - // unknown params, so we know that this particular fallback route param - // route param is not actually unknown, and is known. We can skip adding - // it to the fallback route params. - } else { - // This is some other type of route param that shouldn't get resolved - // statically. - throw new InvariantError( - `Unexpected match for a pathname "${page}" with a param "${fallbackRouteParam.paramName}" of type "${fallbackRouteParam.paramType}"` - ) - } - } else if (unknownParamKeys.has(fallbackRouteParam.paramName)) { - // As this is a non-parallel route segment, and it exists in the unknown - // param keys, we know it's a fallback route param. - fallbackRouteParams.push(fallbackRouteParam) - } - } - + const route = parseAppRoute(page, true) + + // Extract the pathname-contributing segments from the loader tree. This + // mirrors the logic in buildAppStaticPaths where we determine which segments + // actually contribute to the pathname. + const { pathnameRouteParamSegments, params } = + extractPathnameRouteParamSegmentsFromLoaderTree( + routeModule.userland.loaderTree, + route + ) + + // Create fallback route params for the pathname segments. + const fallbackRouteParams: FallbackRouteParam[] = + pathnameRouteParamSegments.map(({ paramName, paramType }) => ({ + paramName, + paramType, + })) + + // Resolve route params from the loader tree. This mutates the + // fallbackRouteParams array to add any route params that are + // unknown at request time. + // + // The page parameter contains placeholders like [slug], which helps + // resolveRouteParamsFromTree determine which params are unknown. + resolveRouteParamsFromTree( + routeModule.userland.loaderTree, + params, // Static params extracted from the page + route, // The page pattern with placeholders + fallbackRouteParams // Will be mutated to add route params + ) + + // Convert the fallback route params to an opaque format that can be safely + // used in the postponed state without exposing implementation details. return createOpaqueFallbackRouteParams(fallbackRouteParams) } diff --git a/packages/next/src/shared/lib/router/routes/app.ts b/packages/next/src/shared/lib/router/routes/app.ts index b1d4df1831de4..0112713fa0fed 100644 --- a/packages/next/src/shared/lib/router/routes/app.ts +++ b/packages/next/src/shared/lib/router/routes/app.ts @@ -115,7 +115,10 @@ export type NormalizedAppRoute = Omit & { } export function isNormalizedAppRoute( - route: AppRoute + route: InterceptionAppRoute +): route is NormalizedInterceptionAppRoute +export function isNormalizedAppRoute( + route: AppRoute | InterceptionAppRoute ): route is NormalizedAppRoute { return route.normalized } diff --git a/packages/next/src/shared/lib/router/utils/get-dynamic-param.test.ts b/packages/next/src/shared/lib/router/utils/get-dynamic-param.test.ts index 90cb985ea93b4..900d588062273 100644 --- a/packages/next/src/shared/lib/router/utils/get-dynamic-param.test.ts +++ b/packages/next/src/shared/lib/router/utils/get-dynamic-param.test.ts @@ -7,7 +7,6 @@ import { import type { Params } from '../../../../server/request/params' import { InvariantError } from '../../invariant-error' import { createMockOpaqueFallbackRouteParams } from '../../../../server/app-render/postponed-state.test' -import type { LoaderTree } from '../../../../server/lib/app-dir-module' describe('getDynamicParam', () => { describe('basic dynamic parameters (d, di)', () => { @@ -403,60 +402,43 @@ describe('parseMatchedParameter', () => { }) }) +// Helper to create LoaderTree structures for testing +type TestLoaderTree = [ + segment: string, + parallelRoutes: { [key: string]: TestLoaderTree }, + modules: Record, +] + +function createLoaderTree( + segment: string, + parallelRoutes: { [key: string]: TestLoaderTree } = {}, + children?: TestLoaderTree +): TestLoaderTree { + const routes = children ? { ...parallelRoutes, children } : parallelRoutes + return [segment, routes, {}] +} + describe('interpolateParallelRouteParams', () => { it('should interpolate parallel route params', () => { - const loaderTree = [ + const loaderTree = createLoaderTree( '', - { - children: [ - 'optional-catch-all', - { - children: [ - '[[...path]]', - { - children: [ - '__PAGE__', - {}, - { - page: [ - null, - '/private/var/folders/xy/84vxj27s21x2brb851sdl_5c0000gn/T/next-install-1265b780415069863d37bb613af21623e2ce3eecc0c3a770cbbc66e0a4cf18aa/app/optional-catch-all/[[...path]]/page.tsx', - ], - }, - ], - }, - { - layout: [ - null, - '/private/var/folders/xy/84vxj27s21x2brb851sdl_5c0000gn/T/next-install-1265b780415069863d37bb613af21623e2ce3eecc0c3a770cbbc66e0a4cf18aa/app/optional-catch-all/[[...path]]/layout.tsx', - ], - }, - ], - }, - {}, - ], - }, - { - 'global-error': [ - null, - 'next/dist/client/components/builtin/global-error.js', - ], - 'not-found': [null, 'next/dist/client/components/builtin/not-found.js'], - forbidden: [null, 'next/dist/client/components/builtin/forbidden.js'], - unauthorized: [ - null, - 'next/dist/client/components/builtin/unauthorized.js', - ], - }, - ] as unknown as LoaderTree + {}, + createLoaderTree( + 'optional-catch-all', + { + modal: createLoaderTree('[[...catchAll]]'), + }, + createLoaderTree('[[...path]]') + ) + ) expect( interpolateParallelRouteParams( loaderTree, - {}, + { path: ['foo', 'bar'] }, '/optional-catch-all/[[...path]]', null ) - ).toEqual({}) + ).toEqual({ path: ['foo', 'bar'], catchAll: ['foo', 'bar'] }) }) }) diff --git a/packages/next/src/shared/lib/router/utils/get-dynamic-param.ts b/packages/next/src/shared/lib/router/utils/get-dynamic-param.ts index eb024b9d6c5af..8521bac670bf6 100644 --- a/packages/next/src/shared/lib/router/utils/get-dynamic-param.ts +++ b/packages/next/src/shared/lib/router/utils/get-dynamic-param.ts @@ -5,7 +5,8 @@ import type { Params } from '../../../../server/request/params' import type { DynamicParamTypesShort } from '../../app-router-types' import { InvariantError } from '../../invariant-error' import { parseLoaderTree } from './parse-loader-tree' -import { getSegmentParam } from './get-segment-param' +import { parseAppRoute, parseAppRouteSegment } from '../routes/app' +import { resolveParamValue } from './resolve-param-value' /** * Gets the value of a param from the params object. This correctly handles the @@ -43,7 +44,7 @@ export function interpolateParallelRouteParams( params: Params, pagePath: string, fallbackRouteParams: OpaqueFallbackRouteParams | null -) { +): Params { const interpolated = structuredClone(params) // Stack-based traversal with depth tracking @@ -51,79 +52,54 @@ export function interpolateParallelRouteParams( { tree: loaderTree, depth: 0 }, ] - // Derive value from pagePath based on depth and parameter type - const pathSegments = pagePath.split('/').slice(1) // Remove first empty string + // Parse the route from the provided page path. + const route = parseAppRoute(pagePath, true) while (stack.length > 0) { const { tree, depth } = stack.pop()! const { segment, parallelRoutes } = parseLoaderTree(tree) - // Check if current segment contains a parameter - const segmentParam = getSegmentParam(segment) + const appSegment = parseAppRouteSegment(segment) + if ( - segmentParam && - !interpolated.hasOwnProperty(segmentParam.param) && + appSegment?.type === 'dynamic' && + !interpolated.hasOwnProperty(appSegment.param.paramName) && // If the param is in the fallback route params, we don't need to // interpolate it because it's already marked as being unknown. - !fallbackRouteParams?.has(segmentParam.param) + !fallbackRouteParams?.has(appSegment.param.paramName) ) { - switch (segmentParam.type) { - case 'catchall': - case 'optional-catchall': - case 'catchall-intercepted-(..)(..)': - case 'catchall-intercepted-(.)': - case 'catchall-intercepted-(..)': - case 'catchall-intercepted-(...)': - // For catchall parameters, take all remaining segments from this depth - const remainingSegments = pathSegments.slice(depth) - - // Process each segment to handle any dynamic params - const processedSegments = remainingSegments - .flatMap((pathSegment) => { - const param = getSegmentParam(pathSegment) - // If the segment matches a param, return the param value otherwise, - // it's a static segment, so just return that. We don't use the - // `getParamValue` function here because we don't want the values to - // be encoded, that's handled on get by the `getDynamicParam` - // function. - return param ? interpolated[param.param] : pathSegment - }) - .filter((s) => s !== undefined) - - if (processedSegments.length > 0) { - interpolated[segmentParam.param] = processedSegments - } - break - case 'dynamic': - case 'dynamic-intercepted-(..)(..)': - case 'dynamic-intercepted-(.)': - case 'dynamic-intercepted-(..)': - case 'dynamic-intercepted-(...)': - // For regular dynamic parameters, take the segment at this depth - if (depth < pathSegments.length) { - const pathSegment = pathSegments[depth] - const param = getSegmentParam(pathSegment) - - interpolated[segmentParam.param] = param - ? interpolated[param.param] - : pathSegment - } - break - default: - segmentParam.type satisfies never + const { paramName, paramType } = appSegment.param + + const paramValue = resolveParamValue( + paramName, + paramType, + depth, + route, + interpolated + ) + + if (paramValue !== undefined) { + interpolated[paramName] = paramValue + } else if (paramType !== 'optional-catchall') { + throw new InvariantError( + `Could not resolve param value for segment: ${paramName}` + ) } } // Calculate next depth - increment if this is not a route group and not empty let nextDepth = depth - const isRouteGroup = segment.startsWith('(') && segment.endsWith(')') - if (!isRouteGroup && segment !== '') { + if ( + appSegment && + appSegment.type !== 'route-group' && + appSegment.type !== 'parallel-route' + ) { nextDepth++ } // Add all parallel routes to the stack for processing - for (const route of Object.values(parallelRoutes)) { - stack.push({ tree: route, depth: nextDepth }) + for (const parallelRoute of Object.values(parallelRoutes)) { + stack.push({ tree: parallelRoute, depth: nextDepth }) } } diff --git a/packages/next/src/shared/lib/router/utils/get-segment-param.tsx b/packages/next/src/shared/lib/router/utils/get-segment-param.tsx index 1777583d49519..a76ab0dcdaf0e 100644 --- a/packages/next/src/shared/lib/router/utils/get-segment-param.tsx +++ b/packages/next/src/shared/lib/router/utils/get-segment-param.tsx @@ -2,8 +2,8 @@ import { INTERCEPTION_ROUTE_MARKERS } from './interception-routes' import type { DynamicParamTypes } from '../../app-router-types' export type SegmentParam = { - param: string - type: DynamicParamTypes + paramName: string + paramType: DynamicParamTypes } /** @@ -24,26 +24,26 @@ export function getSegmentParam(segment: string): SegmentParam | null { return { // TODO-APP: Optional catchall does not currently work with parallel routes, // so for now aren't handling a potential interception marker. - type: 'optional-catchall', - param: segment.slice(5, -2), + paramType: 'optional-catchall', + paramName: segment.slice(5, -2), } } if (segment.startsWith('[...') && segment.endsWith(']')) { return { - type: interceptionMarker + paramType: interceptionMarker ? `catchall-intercepted-${interceptionMarker}` : 'catchall', - param: segment.slice(4, -1), + paramName: segment.slice(4, -1), } } if (segment.startsWith('[') && segment.endsWith(']')) { return { - type: interceptionMarker + paramType: interceptionMarker ? `dynamic-intercepted-${interceptionMarker}` : 'dynamic', - param: segment.slice(1, -1), + paramName: segment.slice(1, -1), } } diff --git a/packages/next/src/shared/lib/router/utils/interception-prefix-from-param-type.ts b/packages/next/src/shared/lib/router/utils/interception-prefix-from-param-type.ts new file mode 100644 index 0000000000000..d9af52b64e976 --- /dev/null +++ b/packages/next/src/shared/lib/router/utils/interception-prefix-from-param-type.ts @@ -0,0 +1,25 @@ +import type { DynamicParamTypes } from '../../app-router-types' + +export function interceptionPrefixFromParamType( + paramType: DynamicParamTypes +): string | null { + switch (paramType) { + case 'catchall-intercepted-(..)(..)': + case 'dynamic-intercepted-(..)(..)': + return '(..)(..)' + case 'catchall-intercepted-(.)': + case 'dynamic-intercepted-(.)': + return '(.)' + case 'catchall-intercepted-(..)': + case 'dynamic-intercepted-(..)': + return '(..)' + case 'catchall-intercepted-(...)': + case 'dynamic-intercepted-(...)': + return '(...)' + case 'catchall': + case 'dynamic': + case 'optional-catchall': + default: + return null + } +} diff --git a/packages/next/src/shared/lib/router/utils/resolve-param-value.ts b/packages/next/src/shared/lib/router/utils/resolve-param-value.ts new file mode 100644 index 0000000000000..47901f9cd3860 --- /dev/null +++ b/packages/next/src/shared/lib/router/utils/resolve-param-value.ts @@ -0,0 +1,154 @@ +import type { Params } from '../../../../server/request/params' +import type { DynamicParamTypes } from '../../app-router-types' +import { InvariantError } from '../../invariant-error' +import type { + NormalizedAppRoute, + NormalizedAppRouteSegment, +} from '../routes/app' +import { interceptionPrefixFromParamType } from './interception-prefix-from-param-type' + +/** + * Extracts the param value from a path segment, handling interception markers + * based on the expected param type. + * + * @param pathSegment - The path segment to extract the value from + * @param params - The current params object for resolving dynamic param references + * @param paramType - The expected param type which may include interception marker info + * @returns The extracted param value + */ +function getParamValueFromSegment( + pathSegment: NormalizedAppRouteSegment, + params: Params, + paramType: DynamicParamTypes +): string { + // If the segment is dynamic, resolve it from the params object + if (pathSegment.type === 'dynamic') { + return params[pathSegment.param.paramName] as string + } + + // If the paramType indicates this is an intercepted param, strip the marker + // that matches the interception marker in the param type + const interceptionPrefix = interceptionPrefixFromParamType(paramType) + if (interceptionPrefix === pathSegment.interceptionMarker) { + return pathSegment.name.replace(pathSegment.interceptionMarker, '') + } + + // For static segments, use the name + return pathSegment.name +} + +/** + * Resolves a route parameter value from the route segments at the given depth. + * This shared logic is used by both extractPathnameRouteParamSegmentsFromLoaderTree + * and resolveRouteParamsFromTree. + * + * @param paramName - The parameter name to resolve + * @param paramType - The parameter type (dynamic, catchall, etc.) + * @param depth - The current depth in the route tree + * @param route - The normalized route containing segments + * @param params - The current params object (used to resolve embedded param references) + * @param options - Configuration options + * @returns The resolved parameter value, or undefined if it cannot be resolved + */ +export function resolveParamValue( + paramName: string, + paramType: DynamicParamTypes, + depth: number, + route: NormalizedAppRoute, + params: Params +): string | string[] | undefined { + switch (paramType) { + case 'catchall': + case 'optional-catchall': + case 'catchall-intercepted-(..)(..)': + case 'catchall-intercepted-(.)': + case 'catchall-intercepted-(..)': + case 'catchall-intercepted-(...)': + // For catchall routes, derive from pathname using depth to determine + // which segments to use + const processedSegments: string[] = [] + + // Process segments to handle any embedded dynamic params + for (let index = depth; index < route.segments.length; index++) { + const pathSegment = route.segments[index] + + if (pathSegment.type === 'static') { + let value = pathSegment.name + + // For intercepted catch-all params, strip the marker from the first segment + const interceptionPrefix = interceptionPrefixFromParamType(paramType) + if ( + interceptionPrefix && + index === depth && + interceptionPrefix === pathSegment.interceptionMarker + ) { + // Strip the interception marker from the value + value = value.replace(pathSegment.interceptionMarker, '') + } + + processedSegments.push(value) + } else { + // If the segment is a param placeholder, check if we have its value + if (!params.hasOwnProperty(pathSegment.param.paramName)) { + // If the segment is an optional catchall, we can break out of the + // loop because it's optional! + if (pathSegment.param.paramType === 'optional-catchall') { + break + } + + // Unknown param placeholder in pathname - can't derive full value + return undefined + } + + // If the segment matches a param, use the param value + // We don't encode values here as that's handled during retrieval. + const paramValue = params[pathSegment.param.paramName] + if (Array.isArray(paramValue)) { + processedSegments.push(...paramValue) + } else { + processedSegments.push(paramValue as string) + } + } + } + + if (processedSegments.length > 0) { + return processedSegments + } else if (paramType === 'optional-catchall') { + return undefined + } else { + // We shouldn't be able to match a catchall segment without any path + // segments if it's not an optional catchall + throw new InvariantError( + `Unexpected empty path segments match for a route "${route.pathname}" with param "${paramName}" of type "${paramType}"` + ) + } + case 'dynamic': + case 'dynamic-intercepted-(..)(..)': + case 'dynamic-intercepted-(.)': + case 'dynamic-intercepted-(..)': + case 'dynamic-intercepted-(...)': + // For regular dynamic parameters, take the segment at this depth + if (depth < route.segments.length) { + const pathSegment = route.segments[depth] + + // Check if the segment at this depth is a placeholder for an unknown param + if ( + pathSegment.type === 'dynamic' && + !params.hasOwnProperty(pathSegment.param.paramName) + ) { + // The segment is a placeholder like [category] and we don't have the value + return undefined + } + + // If the segment matches a param, use the param value from params object + // Otherwise it's a static segment, just use it directly + // We don't encode values here as that's handled during retrieval + return getParamValueFromSegment(pathSegment, params, paramType) + } + + return undefined + + default: + paramType satisfies never + } +} diff --git a/test/e2e/app-dir/interception-dynamic-segment/app/@modal/(.)[username]/[id]/page.tsx b/test/e2e/app-dir/interception-dynamic-segment/app/@modal/(.)[username]/[id]/page.tsx index 7c48f6584136b..1621e9d34051a 100644 --- a/test/e2e/app-dir/interception-dynamic-segment/app/@modal/(.)[username]/[id]/page.tsx +++ b/test/e2e/app-dir/interception-dynamic-segment/app/@modal/(.)[username]/[id]/page.tsx @@ -1,3 +1,7 @@ export default function Page() { return 'intercepted' } + +export async function generateStaticParams() { + return [{ username: 'john', id: '1' }] +} diff --git a/test/e2e/app-dir/interception-dynamic-segment/interception-dynamic-segment.test.ts b/test/e2e/app-dir/interception-dynamic-segment/interception-dynamic-segment.test.ts index 2620329ca8a14..465af127abfbd 100644 --- a/test/e2e/app-dir/interception-dynamic-segment/interception-dynamic-segment.test.ts +++ b/test/e2e/app-dir/interception-dynamic-segment/interception-dynamic-segment.test.ts @@ -146,6 +146,16 @@ describe('interception-dynamic-segment', () => { expect(res.status).toBe(200) expect(res.headers.get('x-nextjs-cache')).toBe('HIT') }) + + it('should prerender a dynamic intercepted route', async () => { + if (process.env.__NEXT_CACHE_COMPONENTS === 'true') { + expect(next.cliOutput).toContain('/(.)[username]/[id]') + expect(next.cliOutput).toContain('/(.)john/[id]') + } + + expect(next.cliOutput).toContain('/(.)john/1') + expect(next.cliOutput).not.toContain('/john/1') + }) } if (!isNextDev) {