From 7a287cc7c1ae2fd89ac239a8616fd340a2fa3f9c Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 28 Oct 2025 17:46:10 -0600 Subject: [PATCH] fix: preserve interception markers in param type --- packages/next/src/build/static-paths/app.ts | 10 ++++- .../has-interception-route-in-current-tree.ts | 12 +++++- packages/next/src/client/route-params.ts | 41 +++++++++++++++++-- .../get-short-dynamic-param-type.tsx | 10 ++++- packages/next/src/server/app-render/types.ts | 14 ++++++- .../src/server/dev/on-demand-entry-handler.ts | 10 ++++- .../next/src/shared/lib/app-router-types.ts | 25 +++++++++-- .../router/utils/get-dynamic-param.test.ts | 12 +++--- .../lib/router/utils/get-dynamic-param.ts | 10 ++++- .../lib/router/utils/get-segment-param.tsx | 31 +++++++++++--- 10 files changed, 145 insertions(+), 30 deletions(-) diff --git a/packages/next/src/build/static-paths/app.ts b/packages/next/src/build/static-paths/app.ts index d115c1c5e7ad51..051e513d11fd48 100644 --- a/packages/next/src/build/static-paths/app.ts +++ b/packages/next/src/build/static-paths/app.ts @@ -596,7 +596,10 @@ export function resolveParallelRouteParams( switch (paramType) { case 'catchall': case 'optional-catchall': - case 'catchall-intercepted': + 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. @@ -658,7 +661,10 @@ export function resolveParallelRouteParams( break case 'dynamic': - case 'dynamic-intercepted': + 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] diff --git a/packages/next/src/client/components/router-reducer/reducers/has-interception-route-in-current-tree.ts b/packages/next/src/client/components/router-reducer/reducers/has-interception-route-in-current-tree.ts index c3023e919cd5a2..b52dd11a053af7 100644 --- a/packages/next/src/client/components/router-reducer/reducers/has-interception-route-in-current-tree.ts +++ b/packages/next/src/client/components/router-reducer/reducers/has-interception-route-in-current-tree.ts @@ -6,7 +6,17 @@ export function hasInterceptionRouteInCurrentTree([ parallelRoutes, ]: FlightRouterState): boolean { // If we have a dynamic segment, it's marked as an interception route by the presence of the `i` suffix. - if (Array.isArray(segment) && (segment[2] === 'di' || segment[2] === 'ci')) { + if ( + Array.isArray(segment) && + (segment[2] === 'di(..)(..)' || + segment[2] === 'ci(..)(..)' || + segment[2] === 'di(.)' || + segment[2] === 'ci(.)' || + segment[2] === 'di(..)' || + segment[2] === 'ci(..)' || + segment[2] === 'di(...)' || + segment[2] === 'ci(...)') + ) { return true } diff --git a/packages/next/src/client/route-params.ts b/packages/next/src/client/route-params.ts index dbd392d5d5f031..dc49ce882f5b16 100644 --- a/packages/next/src/client/route-params.ts +++ b/packages/next/src/client/route-params.ts @@ -60,14 +60,29 @@ export function parseDynamicParamFromURLPart( // This needs to match the behavior in get-dynamic-param.ts. switch (paramType) { // Catchalls - case 'c': - case 'ci': { + case 'c': { // Catchalls receive all the remaining URL parts. If there are no // remaining pathname parts, return an empty array. return partIndex < pathnameParts.length ? pathnameParts.slice(partIndex).map((s) => encodeURIComponent(s)) : [] } + // Catchall intercepted + case 'ci(..)(..)': + case 'ci(.)': + case 'ci(..)': + case 'ci(...)': { + const prefix = paramType.length - 2 + return partIndex < pathnameParts.length + ? pathnameParts.slice(partIndex).map((s, i) => { + if (i === 0) { + return encodeURIComponent(s.slice(prefix)) + } + + return encodeURIComponent(s) + }) + : [] + } // Optional catchalls case 'oc': { // Optional catchalls receive all the remaining URL parts, unless this is @@ -77,8 +92,7 @@ export function parseDynamicParamFromURLPart( : null } // Dynamic - case 'd': - case 'di': { + case 'd': { if (partIndex >= pathnameParts.length) { // The route tree expected there to be more parts in the URL than there // actually are. This could happen if the x-nextjs-rewritten-path header @@ -91,6 +105,25 @@ export function parseDynamicParamFromURLPart( } return encodeURIComponent(pathnameParts[partIndex]) } + // Dynamic intercepted + case 'di(..)(..)': + case 'di(.)': + case 'di(..)': + case 'di(...)': { + const prefix = paramType.length - 2 + if (partIndex >= pathnameParts.length) { + // The route tree expected there to be more parts in the URL than there + // actually are. This could happen if the x-nextjs-rewritten-path header + // is incorrectly set, or potentially due to bug in Next.js. TODO: + // Should this be a hard error? During a prefetch, we can just abort. + // During a client navigation, we could trigger a hard refresh. But if + // it happens during initial render, we don't really have any + // recovery options. + return '' + } + + return encodeURIComponent(pathnameParts[partIndex].slice(prefix)) + } default: paramType satisfies never return '' diff --git a/packages/next/src/server/app-render/get-short-dynamic-param-type.tsx b/packages/next/src/server/app-render/get-short-dynamic-param-type.tsx index 0c484211515eec..fc4097dac32e9f 100644 --- a/packages/next/src/server/app-render/get-short-dynamic-param-type.tsx +++ b/packages/next/src/server/app-render/get-short-dynamic-param-type.tsx @@ -8,8 +8,14 @@ export const dynamicParamTypes: Record< DynamicParamTypesShort > = { catchall: 'c', - 'catchall-intercepted': 'ci', + 'catchall-intercepted-(..)(..)': 'ci(..)(..)', + 'catchall-intercepted-(.)': 'ci(.)', + 'catchall-intercepted-(..)': 'ci(..)', + 'catchall-intercepted-(...)': 'ci(...)', 'optional-catchall': 'oc', dynamic: 'd', - 'dynamic-intercepted': 'di', + 'dynamic-intercepted-(..)(..)': 'di(..)(..)', + 'dynamic-intercepted-(.)': 'di(.)', + 'dynamic-intercepted-(..)': 'di(..)', + 'dynamic-intercepted-(...)': 'di(...)', } diff --git a/packages/next/src/server/app-render/types.ts b/packages/next/src/server/app-render/types.ts index 060c1094ae29d2..90659b73869054 100644 --- a/packages/next/src/server/app-render/types.ts +++ b/packages/next/src/server/app-render/types.ts @@ -21,7 +21,19 @@ import type { IncomingMessage } from 'http' import type { RenderResumeDataCache } from '../resume-data-cache/resume-data-cache' import type { ServerCacheStatus } from '../../next-devtools/dev-overlay/cache-indicator' -const dynamicParamTypesSchema = s.enums(['c', 'ci', 'oc', 'd', 'di']) +const dynamicParamTypesSchema = s.enums([ + 'c', + 'ci(..)(..)', + 'ci(.)', + 'ci(..)', + 'ci(...)', + 'oc', + 'd', + 'di(..)(..)', + 'di(.)', + 'di(..)', + 'di(...)', +]) const segmentSchema = s.union([ s.string(), diff --git a/packages/next/src/server/dev/on-demand-entry-handler.ts b/packages/next/src/server/dev/on-demand-entry-handler.ts index 02076da5ca5522..d51f33cfe85c3a 100644 --- a/packages/next/src/server/dev/on-demand-entry-handler.ts +++ b/packages/next/src/server/dev/on-demand-entry-handler.ts @@ -87,12 +87,18 @@ function convertDynamicParamTypeToSyntax( ) { switch (dynamicParamTypeShort) { case 'c': - case 'ci': + case 'ci(..)(..)': + case 'ci(.)': + case 'ci(..)': + case 'ci(...)': return `[...${param}]` case 'oc': return `[[...${param}]]` case 'd': - case 'di': + case 'di(..)(..)': + case 'di(.)': + case 'di(..)': + case 'di(...)': return `[${param}]` default: throw new Error('Unknown dynamic param type') diff --git a/packages/next/src/shared/lib/app-router-types.ts b/packages/next/src/shared/lib/app-router-types.ts index aa050987ab6938..176efd7a96734b 100644 --- a/packages/next/src/shared/lib/app-router-types.ts +++ b/packages/next/src/shared/lib/app-router-types.ts @@ -114,12 +114,29 @@ export type ReadyCacheNode = { export type DynamicParamTypes = | 'catchall' - | 'catchall-intercepted' + | 'catchall-intercepted-(..)(..)' + | 'catchall-intercepted-(.)' + | 'catchall-intercepted-(..)' + | 'catchall-intercepted-(...)' | 'optional-catchall' | 'dynamic' - | 'dynamic-intercepted' - -export type DynamicParamTypesShort = 'c' | 'ci' | 'oc' | 'd' | 'di' + | 'dynamic-intercepted-(..)(..)' + | 'dynamic-intercepted-(.)' + | 'dynamic-intercepted-(..)' + | 'dynamic-intercepted-(...)' + +export type DynamicParamTypesShort = + | 'c' + | 'ci(..)(..)' + | 'ci(.)' + | 'ci(..)' + | 'ci(...)' + | 'oc' + | 'd' + | 'di(..)(..)' + | 'di(.)' + | 'di(..)' + | 'di(...)' export type Segment = | string 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 139c497052861a..90cb985ea93b49 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 @@ -64,12 +64,12 @@ describe('getDynamicParam', () => { const params: Params = {} expect(() => { - getDynamicParam(params, 'slug', 'di', null) + getDynamicParam(params, 'slug', 'di(..)(..)', null) }).toThrow(InvariantError) expect(() => { - getDynamicParam(params, 'slug', 'di', null) + getDynamicParam(params, 'slug', 'di(..)(..)', null) }).toThrow( - 'Invariant: Missing value for segment key: "slug" with dynamic param type: di. This is a bug in Next.js.' + 'Invariant: Missing value for segment key: "slug" with dynamic param type: di(..)(..). This is a bug in Next.js.' ) }) }) @@ -113,13 +113,13 @@ describe('getDynamicParam', () => { it('should handle catchall intercepted (ci) with array values', () => { const params: Params = { path: ['photo', '123'] } - const result = getDynamicParam(params, 'path', 'ci', null) + const result = getDynamicParam(params, 'path', 'ci(..)(..)', null) expect(result).toEqual({ param: 'path', value: ['photo', '123'], - type: 'ci', - treeSegment: ['path', 'photo/123', 'ci'], + type: 'ci(..)(..)', + treeSegment: ['path', 'photo/123', 'ci(..)(..)'], }) }) 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 093c4a97d0377a..eb024b9d6c5af0 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 @@ -70,7 +70,10 @@ export function interpolateParallelRouteParams( switch (segmentParam.type) { case 'catchall': case 'optional-catchall': - case 'catchall-intercepted': + 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) @@ -92,7 +95,10 @@ export function interpolateParallelRouteParams( } break case 'dynamic': - case 'dynamic-intercepted': + 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] 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 af7ce9d19aaf85..0f3fa5fbb185d5 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 @@ -29,14 +29,18 @@ export function getSegmentParam(segment: string): { if (segment.startsWith('[...') && segment.endsWith(']')) { return { - type: interceptionMarker ? 'catchall-intercepted' : 'catchall', + type: interceptionMarker + ? `catchall-intercepted-${interceptionMarker}` + : 'catchall', param: segment.slice(4, -1), } } if (segment.startsWith('[') && segment.endsWith(']')) { return { - type: interceptionMarker ? 'dynamic-intercepted' : 'dynamic', + type: interceptionMarker + ? `dynamic-intercepted-${interceptionMarker}` + : 'dynamic', param: segment.slice(1, -1), } } @@ -46,10 +50,19 @@ export function getSegmentParam(segment: string): { export function isCatchAll( type: DynamicParamTypes -): type is 'catchall' | 'catchall-intercepted' | 'optional-catchall' { +): type is + | 'catchall' + | 'catchall-intercepted-(..)(..)' + | 'catchall-intercepted-(.)' + | 'catchall-intercepted-(..)' + | 'catchall-intercepted-(...)' + | 'optional-catchall' { return ( type === 'catchall' || - type === 'catchall-intercepted' || + type === 'catchall-intercepted-(..)(..)' || + type === 'catchall-intercepted-(.)' || + type === 'catchall-intercepted-(..)' || + type === 'catchall-intercepted-(...)' || type === 'optional-catchall' ) } @@ -63,7 +76,10 @@ export function getParamProperties(paramType: DynamicParamTypes): { switch (paramType) { case 'catchall': - case 'catchall-intercepted': + case 'catchall-intercepted-(..)(..)': + case 'catchall-intercepted-(.)': + case 'catchall-intercepted-(..)': + case 'catchall-intercepted-(...)': repeat = true break case 'optional-catchall': @@ -71,7 +87,10 @@ export function getParamProperties(paramType: DynamicParamTypes): { optional = true break case 'dynamic': - case 'dynamic-intercepted': + case 'dynamic-intercepted-(..)(..)': + case 'dynamic-intercepted-(.)': + case 'dynamic-intercepted-(..)': + case 'dynamic-intercepted-(...)': break default: paramType satisfies never