diff --git a/packages/next/src/build/static-paths/app.ts b/packages/next/src/build/static-paths/app.ts index d115c1c5e7ad5..051e513d11fd4 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/navigation.ts b/packages/next/src/client/components/navigation.ts index bfd7af32aa0e1..9ffeda8d5ec2a 100644 --- a/packages/next/src/client/components/navigation.ts +++ b/packages/next/src/client/components/navigation.ts @@ -4,6 +4,7 @@ import React, { useContext, useMemo, use } from 'react' import { AppRouterContext, LayoutRouterContext, + GlobalLayoutRouterContext, type AppRouterInstance, } from '../../shared/lib/app-router-context.shared-runtime' import { @@ -17,6 +18,7 @@ import { getSelectedLayoutSegmentPath, } from '../../shared/lib/segment' import { ReadonlyURLSearchParams } from './readonly-url-search-params' +import { extractRouteFromFlightRouterState } from './router-reducer/extract-route-from-flight-router-state' const useDynamicRouteParams = typeof window === 'undefined' @@ -118,6 +120,59 @@ export function usePathname(): string { return pathname } +/** + * A [Client Component](https://nextjs.org/docs/app/building-your-application/rendering/client-components) hook + * that lets you read the canonical route structure including route groups, parallel routes, and dynamic parameters. + * + * Unlike `usePathname()` which returns the actual URL path, `useRoute()` returns the file-system structure + * of the route, preserving route groups `(group)`, parallel routes `@slot`, and dynamic parameters `[param]`. When + * an intercepted route is active, the route will be the path of the intercepted route. + * + * @example + * ```ts + * "use client" + * import { useRoute } from 'next/navigation' + * + * export default function Page() { + * const route = useRoute() + * // On /blog/my-post, returns "/blog/[slug]" + * // On /dashboard with @modal slot active, returns "/dashboard/@modal/..." + * // With route group (marketing), returns "/(marketing)/about" + * // ... + * } + * ``` + */ +// Client components API +export function useRoute(): string { + useDynamicRouteParams?.('useRoute()') + + const pathname = useContext(PathnameContext) + const globalContext = useContext(GlobalLayoutRouterContext) + const tree = globalContext?.tree + + // Compute the canonical route from the tree + // The tree structure itself represents the active route state, + // so we just traverse it to build the canonical path + // Memoized to avoid expensive tree traversal on every render + const route = useMemo(() => { + if (!tree || !pathname) { + return '/' + } + return extractRouteFromFlightRouterState(pathname, tree) ?? '/' + }, [pathname, tree]) + + // Instrument with Suspense DevTools (dev-only) + if (process.env.NODE_ENV !== 'production' && 'use' in React) { + const navigationPromises = use(NavigationPromisesContext) + if (navigationPromises) { + // TODO: Add instrumented promise for route if needed for DevTools + // For now, return the computed value directly + } + } + + return route +} + // Client components API export { ServerInsertedHTMLContext, diff --git a/packages/next/src/client/components/router-reducer/extract-route-from-flight-router-state.test.ts b/packages/next/src/client/components/router-reducer/extract-route-from-flight-router-state.test.ts new file mode 100644 index 0000000000000..c83ef556889e8 --- /dev/null +++ b/packages/next/src/client/components/router-reducer/extract-route-from-flight-router-state.test.ts @@ -0,0 +1,2077 @@ +import type { FlightRouterState } from '../../../shared/lib/app-router-types' +import { extractRouteFromFlightRouterState } from './extract-route-from-flight-router-state' + +describe('extractRouteFromFlightRouterState', () => { + describe('Static Routes', () => { + describe('Basic Static Segments', () => { + it('should return "/" for root page', () => { + const tree: FlightRouterState = [ + '', + { + children: ['__PAGE__', {}, '/', undefined, true], + }, + undefined, + undefined, + true, + ] + + expect(extractRouteFromFlightRouterState('/', tree)).toBe('/') + }) + + it('should extract simple static route', () => { + const tree: FlightRouterState = [ + '', + { + children: [ + 'about', + { + children: ['__PAGE__', {}, '/about'], + }, + ], + }, + undefined, + undefined, + true, + ] + + expect(extractRouteFromFlightRouterState('/about', tree)).toBe('/about') + }) + + it('should return null for non-matching pathname', () => { + const tree: FlightRouterState = [ + '', + { + children: [ + 'about', + { + children: ['__PAGE__', {}, '/about'], + }, + ], + }, + undefined, + undefined, + true, + ] + + expect(extractRouteFromFlightRouterState('/contact', tree)).toBe(null) + }) + + it('should return null when searching for root page that does not exist', () => { + const tree: FlightRouterState = [ + '', + { + children: [ + 'about', + { + children: ['__PAGE__', {}, '/about'], + }, + ], + }, + undefined, + undefined, + true, + ] + + // Tree has /about but no root page - should return null, not '/' + expect(extractRouteFromFlightRouterState('/', tree)).toBe(null) + }) + }) + + describe('Static Segments + Route Groups', () => { + it('should preserve single route group', () => { + const tree: FlightRouterState = [ + '', + { + children: [ + '(marketing)', + { + children: [ + 'about', + { + children: ['__PAGE__', {}, '/about'], + }, + ], + }, + ], + }, + undefined, + undefined, + true, + ] + + expect(extractRouteFromFlightRouterState('/about', tree)).toBe( + '/(marketing)/about' + ) + }) + + it('should handle multiple nested route groups', () => { + const tree: FlightRouterState = [ + '', + { + children: [ + '(app)', + { + children: [ + '(dashboard)', + { + children: [ + 'settings', + { + children: ['__PAGE__', {}, '/settings'], + }, + ], + }, + ], + }, + ], + }, + undefined, + undefined, + true, + ] + + expect(extractRouteFromFlightRouterState('/settings', tree)).toBe( + '/(app)/(dashboard)/settings' + ) + }) + + it('should handle consecutive route groups', () => { + const tree: FlightRouterState = [ + '', + { + children: [ + '(root)', + { + children: [ + '(auth)', + { + children: [ + '(forms)', + { + children: [ + 'login', + { + children: ['__PAGE__', {}, '/login'], + }, + ], + }, + ], + }, + ], + }, + ], + }, + undefined, + undefined, + true, + ] + + expect(extractRouteFromFlightRouterState('/login', tree)).toBe( + '/(root)/(auth)/(forms)/login' + ) + }) + }) + }) + + describe('Dynamic Routes', () => { + describe('Dynamic Parameters ([param]) - Type: d', () => { + it('should extract single dynamic parameter', () => { + const tree: FlightRouterState = [ + '', + { + children: [ + 'blog', + { + children: [ + ['slug', 'my-post', 'd'], + { + children: ['__PAGE__', {}, '/blog/my-post'], + }, + ], + }, + ], + }, + undefined, + undefined, + true, + ] + + expect(extractRouteFromFlightRouterState('/blog/my-post', tree)).toBe( + '/blog/[slug]' + ) + }) + + it('should handle multiple nested dynamic parameters', () => { + const tree: FlightRouterState = [ + '', + { + children: [ + 'shop', + { + children: [ + ['category', 'electronics', 'd'], + { + children: [ + ['product', 'laptop', 'd'], + { + children: ['__PAGE__', {}, '/shop/electronics/laptop'], + }, + ], + }, + ], + }, + ], + }, + undefined, + undefined, + true, + ] + + expect( + extractRouteFromFlightRouterState('/shop/electronics/laptop', tree) + ).toBe('/shop/[category]/[product]') + }) + + it('should handle dynamic parameters with route groups', () => { + const tree: FlightRouterState = [ + '', + { + children: [ + '(shop)', + { + children: [ + ['category', 'electronics', 'd'], + { + children: [ + ['product', 'laptop', 'd'], + { + children: ['__PAGE__', {}, '/electronics/laptop'], + }, + ], + }, + ], + }, + ], + }, + undefined, + undefined, + true, + ] + + expect( + extractRouteFromFlightRouterState('/electronics/laptop', tree) + ).toBe('/(shop)/[category]/[product]') + }) + }) + + describe('Catch-all Parameters ([...param]) - Type: c', () => { + it('should handle catch-all parameters', () => { + const tree: FlightRouterState = [ + '', + { + children: [ + 'docs', + { + children: [ + ['slug', 'api/reference', 'c'], + { + children: ['__PAGE__', {}, '/docs/api/reference'], + }, + ], + }, + ], + }, + undefined, + undefined, + true, + ] + + expect( + extractRouteFromFlightRouterState('/docs/api/reference', tree) + ).toBe('/docs/[...slug]') + }) + + it('should handle catch-all at root level', () => { + const tree: FlightRouterState = [ + '', + { + children: [ + ['path', 'blog/posts', 'c'], + { + children: ['__PAGE__', {}, '/blog/posts'], + }, + ], + }, + undefined, + undefined, + true, + ] + + expect(extractRouteFromFlightRouterState('/blog/posts', tree)).toBe( + '/[...path]' + ) + }) + }) + + describe('Optional Catch-all Parameters ([[...param]]) - Type: oc', () => { + it('should handle optional catch-all parameters', () => { + const tree: FlightRouterState = [ + '', + { + children: [ + 'docs', + { + children: [ + ['slug', 'api/reference', 'oc'], + { + children: ['__PAGE__', {}, '/docs/api/reference'], + }, + ], + }, + ], + }, + undefined, + undefined, + true, + ] + + expect( + extractRouteFromFlightRouterState('/docs/api/reference', tree) + ).toBe('/docs/[[...slug]]') + }) + + it('should handle optional catch-all at root level', () => { + const tree: FlightRouterState = [ + '', + { + children: [ + ['slug', 'about/team', 'oc'], + { + children: ['__PAGE__', {}, '/about/team'], + }, + ], + }, + undefined, + undefined, + true, + ] + + expect(extractRouteFromFlightRouterState('/about/team', tree)).toBe( + '/[[...slug]]' + ) + }) + }) + + describe('Dynamic Intercepted Parameters - Type: di', () => { + it('should handle interception folder with separate dynamic parameter', () => { + const tree: FlightRouterState = [ + '', + { + children: [ + 'posts', + { + children: ['__PAGE__', {}, '/posts'], + modal: [ + '(slot)', + { + children: [ + '(.)post', + { + children: [ + ['slug', 'my-post', 'd'], + { + children: ['__PAGE__', {}, '/posts/post/my-post'], + }, + ], + }, + ], + }, + ], + }, + ], + }, + undefined, + undefined, + true, + ] + + expect( + extractRouteFromFlightRouterState('/posts/post/my-post', tree) + ).toBe('/posts/@modal/(.)post/[slug]') + }) + + it('should handle combined interception marker + dynamic parameter folder', () => { + const tree: FlightRouterState = [ + '', + { + children: [ + 'gallery', + { + modal: [ + '(slot)', + { + children: [ + '(group)', + { + children: [ + ['id', '123', 'di(.)'], + { + children: ['__PAGE__', {}, '/gallery/123'], + }, + ], + }, + ], + }, + ], + }, + ], + }, + undefined, + undefined, + true, + ] + + // (.)[id] is a SINGLE folder combining interception with dynamic param + expect(extractRouteFromFlightRouterState('/gallery/123', tree)).toBe( + '/gallery/@modal/(group)/(.)[id]' + ) + }) + + it('should handle parent interception marker combined with dynamic parameter', () => { + const tree: FlightRouterState = [ + '', + { + children: [ + 'store', + { + children: [ + 'catalog', + { + children: ['__PAGE__', {}, '/store/catalog'], + modal: [ + '(slot)', + { + children: [ + ['productId', '999', 'di(..)'], + { + children: ['__PAGE__', {}, '/store/999'], + }, + ], + }, + ], + }, + ], + }, + ], + }, + undefined, + undefined, + true, + ] + + // (..)[productId] intercepts parent's dynamic segment + expect(extractRouteFromFlightRouterState('/store/999', tree)).toBe( + '/store/catalog/@modal/(..)[productId]' + ) + }) + + it('should handle root interception marker combined with dynamic parameter', () => { + const tree: FlightRouterState = [ + '', + { + children: [ + 'admin', + { + children: [ + 'panel', + { + children: ['__PAGE__', {}, '/admin/panel'], + overlay: [ + '(slot)', + { + children: [ + ['userId', '555', 'di(...)'], + { + children: ['__PAGE__', {}, '/555'], + }, + ], + }, + ], + }, + ], + }, + ], + }, + undefined, + undefined, + true, + ] + + // (...)[userId] intercepts root's dynamic segment + expect(extractRouteFromFlightRouterState('/555', tree)).toBe( + '/admin/panel/@overlay/(...)[userId]' + ) + }) + }) + + describe('Catch-all Intercepted Parameters - Type: ci', () => { + it('should handle interception folder with separate catch-all parameter', () => { + const tree: FlightRouterState = [ + '', + { + children: [ + 'wiki', + { + children: ['__PAGE__', {}, '/wiki'], + modal: [ + '(slot)', + { + children: [ + '(.)docs', + { + children: [ + ['path', 'getting-started/intro', 'c'], + { + children: [ + '__PAGE__', + {}, + '/wiki/docs/getting-started/intro', + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + undefined, + undefined, + true, + ] + + expect( + extractRouteFromFlightRouterState( + '/wiki/docs/getting-started/intro', + tree + ) + ).toBe('/wiki/@modal/(.)docs/[...path]') + }) + + it('should handle combined interception marker + catch-all parameter folder', () => { + const tree: FlightRouterState = [ + '', + { + children: [ + 'docs', + { + preview: [ + '(slot)', + { + children: [ + ['path', 'guides/intro', 'ci(.)'], + { + children: ['__PAGE__', {}, '/docs/guides/intro'], + }, + ], + }, + ], + }, + ], + }, + undefined, + undefined, + true, + ] + + // (.)[...path] is a SINGLE folder combining interception with catch-all + expect( + extractRouteFromFlightRouterState('/docs/guides/intro', tree) + ).toBe('/docs/@preview/(.)[...path]') + }) + + it('should handle two-level parent interception with catch-all', () => { + const tree: FlightRouterState = [ + '', + { + children: [ + 'app', + { + children: [ + 'dashboard', + { + children: [ + 'settings', + { + children: ['__PAGE__', {}, '/app/dashboard/settings'], + docs: [ + '(slot)', + { + children: [ + ['path', 'api/reference/config', 'ci(..)(..)'], + { + children: [ + '__PAGE__', + {}, + '/app/api/reference/config', + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + undefined, + undefined, + true, + ] + + // (..)(..)[...path] intercepts two levels up with catch-all + expect( + extractRouteFromFlightRouterState('/app/api/reference/config', tree) + ).toBe('/app/dashboard/settings/@docs/(..)(..)[...path]') + }) + + it('should handle root-level interception with catch-all', () => { + const tree: FlightRouterState = [ + '', + { + children: [ + 'admin', + { + children: [ + 'panel', + { + children: [ + 'users', + { + children: ['__PAGE__', {}, '/admin/panel/users'], + help: [ + '(slot)', + { + children: [ + ['segments', 'docs/faq/account', 'ci(...)'], + { + children: ['__PAGE__', {}, '/docs/faq/account'], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + undefined, + undefined, + true, + ] + + // (...)[...segments] intercepts from root with catch-all + expect( + extractRouteFromFlightRouterState('/docs/faq/account', tree) + ).toBe('/admin/panel/users/@help/(...)[...segments]') + }) + }) + }) + + describe('Parallel Routes', () => { + describe('Basic Parallel Routes (@slot)', () => { + it('should handle root-level parallel routes', () => { + const tree: FlightRouterState = [ + '', + { + children: ['__PAGE__', {}, '/'], + modal: [ + '(slot)', + { + children: [ + 'login', + { + children: ['__PAGE__', {}, '/login'], + }, + ], + }, + ], + }, + undefined, + undefined, + true, + ] + + expect(extractRouteFromFlightRouterState('/login', tree)).toBe( + '/@modal/login' + ) + }) + + it('should match non-children parallel route when children does not match', () => { + const tree: FlightRouterState = [ + '', + { + children: [ + 'app', + { + children: ['__PAGE__', {}, '/app'], + sidebar: [ + 'nav', + { + children: ['__PAGE__', {}, '/app/nav'], + }, + ], + }, + ], + }, + undefined, + undefined, + true, + ] + + // /app/nav doesn't exist in children, but exists in sidebar + expect(extractRouteFromFlightRouterState('/app/nav', tree)).toBe( + '/app/@sidebar/nav' + ) + }) + + it('should handle parallel route with independent page at slot level', () => { + const tree: FlightRouterState = [ + '', + { + children: [ + 'gallery', + { + children: [ + ['id', '123', 'd'], + { + children: ['__PAGE__', {}, '/gallery/123'], + }, + ], + modal: [ + '(slot)', + { + children: ['__PAGE__', {}, '/gallery/modal'], + }, + ], + }, + ], + }, + undefined, + undefined, + true, + ] + + // Modal has its own page at /gallery/modal (no dynamic segment) + expect(extractRouteFromFlightRouterState('/gallery/modal', tree)).toBe( + '/gallery/@modal' + ) + }) + + it('should filter synthetic (slot) segments after parallel routes', () => { + const tree: FlightRouterState = [ + '', + { + children: [ + 'see', + { + children: ['__PAGE__', {}, '/see'], + modal: [ + '(slot)', // synthetic segment - should be skipped + { + children: ['__PAGE__', {}, '/see'], + }, + ], + }, + ], + }, + undefined, + undefined, + true, + ] + + // Should match the children route, not the modal slot + // The (slot) segment is synthetic and should be filtered out + expect(extractRouteFromFlightRouterState('/see', tree)).toBe('/see') + }) + }) + + describe('Parallel Routes Priority', () => { + it('should prioritize children route over named slots', () => { + const tree: FlightRouterState = [ + '', + { + children: [ + 'dashboard', + { + children: ['__PAGE__', {}, '/dashboard'], + sidebar: [ + '(nav)', + { + children: ['__PAGE__', {}, '/dashboard'], + }, + ], + }, + ], + }, + undefined, + undefined, + true, + ] + + // When both children and sidebar match, children wins + expect(extractRouteFromFlightRouterState('/dashboard', tree)).toBe( + '/dashboard' + ) + }) + + it('should handle multiple named slots with first match winning', () => { + const tree: FlightRouterState = [ + '', + { + children: [ + 'dashboard', + { + children: ['__PAGE__', {}, '/dashboard'], + analytics: [ + 'chart', + { + children: ['__PAGE__', {}, '/dashboard/chart'], + }, + ], + sidebar: [ + 'chart', + { + children: ['__PAGE__', {}, '/dashboard/chart'], + }, + ], + }, + ], + }, + undefined, + undefined, + true, + ] + + // Both analytics and sidebar have 'chart', but analytics is checked first + const result = extractRouteFromFlightRouterState( + '/dashboard/chart', + tree + ) + expect(result).toBe('/dashboard/@analytics/chart') + }) + }) + + describe('Nested Parallel Routes', () => { + it('should handle parallel routes at multiple nested levels', () => { + const tree: FlightRouterState = [ + '', + { + children: [ + 'app', + { + children: [ + 'dashboard', + { + children: ['__PAGE__', {}, '/app/dashboard'], + panel: [ + 'stats', + { + children: ['__PAGE__', {}, '/app/dashboard/stats'], + chart: [ + 'line', + { + children: [ + '__PAGE__', + {}, + '/app/dashboard/stats/line', + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + undefined, + undefined, + true, + ] + + expect( + extractRouteFromFlightRouterState('/app/dashboard/stats/line', tree) + ).toBe('/app/dashboard/@panel/stats/@chart/line') + }) + + it('should handle parallel routes with different depths', () => { + const tree: FlightRouterState = [ + '', + { + children: [ + 'app', + { + children: [ + 'level1', + { + children: [ + 'level2', + { + children: [ + 'level3', + { + children: [ + '__PAGE__', + {}, + '/app/level1/level2/level3', + ], + }, + ], + }, + ], + }, + ], + shortcut: [ + 'direct', + { + children: ['__PAGE__', {}, '/app/direct'], + }, + ], + }, + ], + }, + undefined, + undefined, + true, + ] + + // shortcut route is much shorter than children route + expect(extractRouteFromFlightRouterState('/app/direct', tree)).toBe( + '/app/@shortcut/direct' + ) + }) + }) + + describe('Parallel Routes + Dynamic Segments', () => { + it('should handle dynamic segment in both children and parallel route', () => { + const tree: FlightRouterState = [ + '', + { + children: [ + 'test', + { + children: [ + ['id', '123', 'd'], + { + children: ['__PAGE__', {}, '/test/123'], + }, + ], + modal: [ + '(slot)', + { + children: [ + ['id', '123', 'd'], + { + children: ['__PAGE__', {}, '/test/123'], + }, + ], + }, + ], + }, + ], + }, + undefined, + undefined, + true, + ] + + // When both children and modal have [id], children should win (priority) + expect(extractRouteFromFlightRouterState('/test/123', tree)).toBe( + '/test/[id]' + ) + }) + + it('should match parallel route when children has different dynamic segment', () => { + const tree: FlightRouterState = [ + '', + { + children: [ + 'test', + { + children: [ + ['productId', '456', 'd'], + { + children: ['__PAGE__', {}, '/test/product/456'], + }, + ], + modal: [ + '(slot)', + { + children: [ + ['itemId', '123', 'd'], + { + children: ['__PAGE__', {}, '/test/123'], + }, + ], + }, + ], + }, + ], + }, + undefined, + undefined, + true, + ] + + // /test/123 matches modal's [itemId], not children's [productId] + expect(extractRouteFromFlightRouterState('/test/123', tree)).toBe( + '/test/@modal/[itemId]' + ) + }) + + it('should handle dynamic parameters with parallel routes', () => { + const tree: FlightRouterState = [ + '', + { + children: [ + 'see', + { + children: ['__PAGE__', {}, '/see'], + modal: [ + '(slot)', + { + children: [ + '(group)', + { + children: [ + ['guid', '14CE0C38-483F-42F0-B2DF-4B1E23C20EFE', 'd'], + { + children: [ + '__PAGE__', + {}, + '/see/14CE0C38-483F-42F0-B2DF-4B1E23C20EFE', + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + undefined, + undefined, + true, + ] + + expect( + extractRouteFromFlightRouterState( + '/see/14CE0C38-483F-42F0-B2DF-4B1E23C20EFE', + tree + ) + ).toBe('/see/@modal/(group)/[guid]') + }) + + it('should handle catch-all before parallel route', () => { + const tree: FlightRouterState = [ + '', + { + children: [ + ['path', 'blog/posts', 'c'], + { + children: ['__PAGE__', {}, '/blog/posts'], + sidebar: [ + 'nav', + { + children: ['__PAGE__', {}, '/blog/posts/nav'], + }, + ], + }, + ], + }, + undefined, + undefined, + true, + ] + + expect(extractRouteFromFlightRouterState('/blog/posts/nav', tree)).toBe( + '/[...path]/@sidebar/nav' + ) + }) + + it('should handle optional catch-all before parallel route', () => { + const tree: FlightRouterState = [ + '', + { + children: [ + ['path', 'docs', 'oc'], + { + children: ['__PAGE__', {}, '/docs'], + toc: [ + 'sidebar', + { + children: ['__PAGE__', {}, '/docs/sidebar'], + }, + ], + }, + ], + }, + undefined, + undefined, + true, + ] + + expect(extractRouteFromFlightRouterState('/docs/sidebar', tree)).toBe( + '/[[...path]]/@toc/sidebar' + ) + }) + }) + + describe('Parallel Routes + Route Groups', () => { + it('should preserve user-defined (slot) route groups vs synthetic', () => { + // Test case: @modal/(group)/(slot)/[id] where the second (slot) is user-defined + const tree: FlightRouterState = [ + '', + { + children: [ + 'product', + { + children: ['__PAGE__', {}, '/product'], + modal: [ + '(slot)', // synthetic - should be skipped + { + children: [ + '(group)', + { + children: [ + '(slot)', // user-defined - should be preserved! + { + children: [ + ['id', '123', 'd'], + { + children: ['__PAGE__', {}, '/product/123'], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + undefined, + undefined, + true, + ] + + // The first (slot) is synthetic (right after @modal) - skip it + // The second (slot) is user-defined (after (group)) - keep it + expect(extractRouteFromFlightRouterState('/product/123', tree)).toBe( + '/product/@modal/(group)/(slot)/[id]' + ) + }) + + it('should handle route group before parallel route', () => { + const tree: FlightRouterState = [ + '', + { + children: [ + '(app)', + { + children: [ + 'dashboard', + { + children: ['__PAGE__', {}, '/dashboard'], + panel: [ + 'stats', + { + children: ['__PAGE__', {}, '/dashboard/stats'], + }, + ], + }, + ], + }, + ], + }, + undefined, + undefined, + true, + ] + + expect( + extractRouteFromFlightRouterState('/dashboard/stats', tree) + ).toBe('/(app)/dashboard/@panel/stats') + }) + }) + + describe('Parallel Routes Edge Cases', () => { + it('should return null when no parallel routes match', () => { + const tree: FlightRouterState = [ + '', + { + children: [ + 'app', + { + children: ['__PAGE__', {}, '/app'], + sidebar: [ + 'nav', + { + children: ['__PAGE__', {}, '/app/nav'], + }, + ], + }, + ], + }, + undefined, + undefined, + true, + ] + + // /app/settings doesn't exist in any parallel route + expect(extractRouteFromFlightRouterState('/app/settings', tree)).toBe( + null + ) + }) + }) + }) + + describe('Interception Routes', () => { + describe('Same-level Interception (.) - Separate Folders', () => { + it('should preserve same-level interception marker', () => { + const tree: FlightRouterState = [ + '', + { + children: [ + 'feed', + { + children: ['__PAGE__', {}, '/feed'], + modal: [ + '(slot)', + { + children: [ + '(.)photo', + { + children: [ + ['id', '123', 'd'], + { + children: ['__PAGE__', {}, '/feed/photo/123'], + }, + ], + }, + ], + }, + ], + }, + ], + }, + undefined, + undefined, + true, + ] + + expect(extractRouteFromFlightRouterState('/feed/photo/123', tree)).toBe( + '/feed/@modal/(.)photo/[id]' + ) + }) + + it('should handle interception with catch-all parameters', () => { + const tree: FlightRouterState = [ + '', + { + children: [ + 'blog', + { + children: ['__PAGE__', {}, '/blog'], + modal: [ + '(slot)', + { + children: [ + '(.)docs', + { + children: [ + ['slug', 'api/reference/config', 'c'], + { + children: [ + '__PAGE__', + {}, + '/blog/docs/api/reference/config', + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + undefined, + undefined, + true, + ] + + expect( + extractRouteFromFlightRouterState( + '/blog/docs/api/reference/config', + tree + ) + ).toBe('/blog/@modal/(.)docs/[...slug]') + }) + + it('should handle interception with route groups', () => { + const tree: FlightRouterState = [ + '', + { + children: [ + 'gallery', + { + children: ['__PAGE__', {}, '/gallery'], + modal: [ + '(slot)', + { + children: [ + '(.)(modal-group)', + { + children: [ + ['id', '999', 'd'], + { + children: ['__PAGE__', {}, '/gallery/999'], + }, + ], + }, + ], + }, + ], + }, + ], + }, + undefined, + undefined, + true, + ] + + expect(extractRouteFromFlightRouterState('/gallery/999', tree)).toBe( + '/gallery/@modal/(.)(modal-group)/[id]' + ) + }) + }) + + describe('Same-level Interception (.) - Combined with Dynamic', () => { + it('should handle interception marker combined with dynamic parameter', () => { + const tree: FlightRouterState = [ + '', + { + children: [ + 'gallery', + { + modal: [ + '(slot)', + { + children: [ + '(group)', + { + children: [ + ['id', '123', 'di(.)'], + { + children: ['__PAGE__', {}, '/gallery/123'], + }, + ], + }, + ], + }, + ], + }, + ], + }, + undefined, + undefined, + true, + ] + + // (.)[id] is a single segment combining interception with dynamic param + expect(extractRouteFromFlightRouterState('/gallery/123', tree)).toBe( + '/gallery/@modal/(group)/(.)[id]' + ) + }) + + it('should handle interception marker combined with catch-all parameter', () => { + const tree: FlightRouterState = [ + '', + { + children: [ + 'docs', + { + children: ['__PAGE__', {}, '/docs'], + preview: [ + '(slot)', + { + children: [ + ['path', 'guides/intro', 'ci(.)'], + { + children: ['__PAGE__', {}, '/docs/guides/intro'], + }, + ], + }, + ], + }, + ], + }, + undefined, + undefined, + true, + ] + + // (.)[...path] is interception marker combined with catch-all + expect( + extractRouteFromFlightRouterState('/docs/guides/intro', tree) + ).toBe('/docs/@preview/(.)[...path]') + }) + }) + + describe('Parent-level Interception (..) - Separate Folders', () => { + it('should preserve parent-level interception marker', () => { + const tree: FlightRouterState = [ + '', + { + children: [ + 'app', + { + children: [ + 'feed', + { + children: ['__PAGE__', {}, '/app/feed'], + modal: [ + '(slot)', + { + children: [ + '(..)photo', + { + children: [ + ['id', '456', 'd'], + { + children: ['__PAGE__', {}, '/app/photo/456'], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + undefined, + undefined, + true, + ] + + expect(extractRouteFromFlightRouterState('/app/photo/456', tree)).toBe( + '/app/feed/@modal/(..)photo/[id]' + ) + }) + + it('should handle interception with optional catch-all', () => { + const tree: FlightRouterState = [ + '', + { + children: [ + 'docs', + { + children: ['__PAGE__', {}, '/docs'], + modal: [ + '(slot)', + { + children: [ + '(..)preview', + { + children: [ + ['slug', 'api/components', 'oc'], + { + children: [ + '__PAGE__', + {}, + '/preview/api/components', + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + undefined, + undefined, + true, + ] + + expect( + extractRouteFromFlightRouterState('/preview/api/components', tree) + ).toBe('/docs/@modal/(..)preview/[[...slug]]') + }) + }) + + describe('Parent-level Interception (..) - Combined with Dynamic', () => { + it('should handle parent interception marker combined with dynamic parameter', () => { + const tree: FlightRouterState = [ + '', + { + children: [ + 'store', + { + children: [ + 'catalog', + { + children: ['__PAGE__', {}, '/store/catalog'], + modal: [ + '(slot)', + { + children: [ + ['productId', '999', 'di(..)'], + { + children: ['__PAGE__', {}, '/store/999'], + }, + ], + }, + ], + }, + ], + }, + ], + }, + undefined, + undefined, + true, + ] + + // (..)[productId] intercepts parent's dynamic segment + expect(extractRouteFromFlightRouterState('/store/999', tree)).toBe( + '/store/catalog/@modal/(..)[productId]' + ) + }) + }) + + describe('Root-level Interception (...) - Separate Folders', () => { + it('should preserve root-level interception marker', () => { + const tree: FlightRouterState = [ + '', + { + children: [ + 'dashboard', + { + children: [ + 'settings', + { + children: ['__PAGE__', {}, '/dashboard/settings'], + modal: [ + '(slot)', + { + children: [ + '(...)photo', + { + children: [ + ['id', '789', 'd'], + { + children: ['__PAGE__', {}, '/photo/789'], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + undefined, + undefined, + true, + ] + + expect(extractRouteFromFlightRouterState('/photo/789', tree)).toBe( + '/dashboard/settings/@modal/(...)photo/[id]' + ) + }) + }) + + describe('Root-level Interception (...) - Combined with Dynamic', () => { + it('should handle root interception marker combined with dynamic parameter', () => { + const tree: FlightRouterState = [ + '', + { + children: [ + 'admin', + { + children: [ + 'panel', + { + children: ['__PAGE__', {}, '/admin/panel'], + overlay: [ + '(slot)', + { + children: [ + ['userId', '555', 'di(...)'], + { + children: ['__PAGE__', {}, '/555'], + }, + ], + }, + ], + }, + ], + }, + ], + }, + undefined, + undefined, + true, + ] + + // (...)[userId] intercepts root's dynamic segment + expect(extractRouteFromFlightRouterState('/555', tree)).toBe( + '/admin/panel/@overlay/(...)[userId]' + ) + }) + }) + + describe('Multiple-level Interception', () => { + it('should handle two-level interception marker (..)(..)', () => { + const tree: FlightRouterState = [ + '', + { + children: [ + 'app', + { + children: [ + 'dashboard', + { + children: [ + 'deep', + { + children: ['__PAGE__', {}, '/app/dashboard/deep'], + modal: [ + '(slot)', + { + children: [ + '(..)(..)photo', + { + children: [ + ['id', 'abc', 'd'], + { + children: [ + '__PAGE__', + {}, + '/app/photo/abc', + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + undefined, + undefined, + true, + ] + + expect(extractRouteFromFlightRouterState('/app/photo/abc', tree)).toBe( + '/app/dashboard/deep/@modal/(..)(..)photo/[id]' + ) + }) + + it('should handle three-level interception marker (..)(..)(..)', () => { + const tree: FlightRouterState = [ + '', + { + children: [ + 'app', + { + children: [ + 'level1', + { + children: [ + 'level2', + { + children: [ + 'level3', + { + children: [ + '__PAGE__', + {}, + '/app/level1/level2/level3', + ], + modal: [ + '(slot)', + { + children: [ + '(..)(..)(..)photo', + { + children: [ + ['id', '999', 'd'], + { + children: [ + '__PAGE__', + {}, + '/photo/999', + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + undefined, + undefined, + true, + ] + + expect(extractRouteFromFlightRouterState('/photo/999', tree)).toBe( + '/app/level1/level2/level3/@modal/(..)(..)(..)photo/[id]' + ) + }) + }) + + describe('Optional Catch-all in Parallel Routes', () => { + it('should handle optional catch-all parameter in parallel route', () => { + const tree: FlightRouterState = [ + '', + { + children: [ + 'wiki', + { + children: ['__PAGE__', {}, '/wiki'], + sidebar: [ + '(slot)', + { + children: [ + ['segments', 'advanced/routing', 'oc'], + { + children: ['__PAGE__', {}, '/wiki/advanced/routing'], + }, + ], + }, + ], + }, + ], + }, + undefined, + undefined, + true, + ] + + // Note: This is 'oc' type (optional catch-all), NOT 'oci' type. + // Interception markers combined with optional catch-all (oci) are not + // currently supported in the type system - only separate interception + // folders with optional catch-all segments are possible. + expect( + extractRouteFromFlightRouterState('/wiki/advanced/routing', tree) + ).toBe('/wiki/@sidebar/[[...segments]]') + }) + }) + }) + + describe('Complex Multi-Feature Combinations', () => { + describe('Route Groups + Dynamic + Parallel', () => { + it('should handle route groups + dynamic params + parallel routes', () => { + const tree: FlightRouterState = [ + '', + { + children: [ + '(shop)', + { + children: [ + ['category', 'electronics', 'd'], + { + children: [ + ['product', 'laptop', 'd'], + { + children: ['__PAGE__', {}, '/electronics/laptop'], + reviews: [ + 'list', + { + children: [ + '__PAGE__', + {}, + '/electronics/laptop/reviews', + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + undefined, + undefined, + true, + ] + + expect( + extractRouteFromFlightRouterState('/electronics/laptop/reviews', tree) + ).toBe('/(shop)/[category]/[product]/@reviews/list') + }) + }) + + describe('Multiple Dynamic + Parallel + Interception', () => { + it('should handle multiple dynamic params + parallel + interception', () => { + const tree: FlightRouterState = [ + '', + { + children: [ + ['locale', 'en', 'd'], + { + children: [ + 'photos', + { + children: [ + ['id', '123', 'd'], + { + children: ['__PAGE__', {}, '/en/photos/123'], + }, + ], + modal: [ + '(slot)', + { + children: [ + '(.)photo', + { + children: [ + ['photoId', '456', 'd'], + { + children: ['__PAGE__', {}, '/en/photos/456'], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + undefined, + undefined, + true, + ] + + expect(extractRouteFromFlightRouterState('/en/photos/456', tree)).toBe( + '/[locale]/photos/@modal/(.)photo/[photoId]' + ) + }) + + it('should handle multiple dynamic params before parallel + interception', () => { + const tree: FlightRouterState = [ + '', + { + children: [ + ['locale', 'en', 'd'], + { + children: [ + ['region', 'us', 'd'], + { + children: [ + 'blog', + { + children: ['__PAGE__', {}, '/en/us/blog'], + modal: [ + '(slot)', + { + children: [ + '(.)post', + { + children: [ + ['id', '123', 'd'], + { + children: [ + '__PAGE__', + {}, + '/en/us/blog/post/123', + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + undefined, + undefined, + true, + ] + + expect( + extractRouteFromFlightRouterState('/en/us/blog/post/123', tree) + ).toBe('/[locale]/[region]/blog/@modal/(.)post/[id]') + }) + }) + + describe('Route Groups + Interception + Multiple Dynamic', () => { + it('should handle interception + multiple route groups + dynamic params', () => { + const tree: FlightRouterState = [ + '', + { + children: [ + '(marketing)', + { + children: [ + 'products', + { + children: ['__PAGE__', {}, '/products'], + modal: [ + '(slot)', + { + children: [ + '(.)(modal-layout)', + { + children: [ + '(modal-content)', + { + children: [ + ['productId', 'abc123', 'd'], + { + children: [ + '__PAGE__', + {}, + '/products/abc123', + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + undefined, + undefined, + true, + ] + + expect( + extractRouteFromFlightRouterState('/products/abc123', tree) + ).toBe( + '/(marketing)/products/@modal/(.)(modal-layout)/(modal-content)/[productId]' + ) + }) + }) + }) + + describe('Edge Cases & Special Behaviors', () => { + describe('Client-Side Navigation (null/undefined URLs)', () => { + it('should match page with null URL (active page during navigation)', () => { + const tree: FlightRouterState = [ + '', + { + children: [ + 'products', + { + children: [ + ['id', '123', 'd'], + { + children: ['__PAGE__', {}, null], // null URL during client navigation + }, + ], + }, + ], + }, + undefined, + undefined, + true, + ] + + expect(extractRouteFromFlightRouterState('/products/123', tree)).toBe( + '/products/[id]' + ) + }) + + it('should match page with undefined URL', () => { + const tree: FlightRouterState = [ + '', + { + children: [ + 'blog', + { + children: [ + ['slug', 'my-post', 'd'], + { + children: ['__PAGE__', {}, undefined], // undefined URL + }, + ], + }, + ], + }, + undefined, + undefined, + true, + ] + + expect(extractRouteFromFlightRouterState('/blog/my-post', tree)).toBe( + '/blog/[slug]' + ) + }) + }) + + describe('Empty Structures', () => { + it('should handle empty parallel routes object', () => { + const tree: FlightRouterState = [ + '', + { + children: [ + 'page', + { + children: ['__PAGE__', {}, '/page'], + }, + ], + }, + undefined, + undefined, + true, + ] + + expect(extractRouteFromFlightRouterState('/page', tree)).toBe('/page') + }) + }) + }) +}) diff --git a/packages/next/src/client/components/router-reducer/extract-route-from-flight-router-state.ts b/packages/next/src/client/components/router-reducer/extract-route-from-flight-router-state.ts new file mode 100644 index 0000000000000..7c8e8f39071d9 --- /dev/null +++ b/packages/next/src/client/components/router-reducer/extract-route-from-flight-router-state.ts @@ -0,0 +1,162 @@ +import type { + FlightRouterState, + Segment, +} from '../../../shared/lib/app-router-types' +import { convertDynamicParamType } from '../../../shared/lib/convert-dynamic-param-type' +import { PAGE_SEGMENT_KEY } from '../../../shared/lib/segment' + +/** + * Extracts the route structure from a FlightRouterState tree by finding + * the page that matches the given pathname. The route includes: + * - Route groups: (groupName) + * - Parallel routes: @slotName + * - Dynamic parameters: [paramName] + * + * This differs from extractPathFromFlightRouterState in that it: + * 1. Searches for a specific pathname match first + * 2. Preserves route groups and parallel route markers + * 3. Returns the file-system structure rather than the URL structure + * + * @param targetPathname - The pathname to find in the tree (e.g., "/blog/post-1") + * @param flightRouterState - The FlightRouterState tree to search + * @returns The canonical route (e.g., "/blog/[slug]") or null if not found + */ +export function extractRouteFromFlightRouterState( + targetPathname: string, + flightRouterState: FlightRouterState +): string | null { + return extract(targetPathname, flightRouterState, []) +} + +function extract( + targetPathname: string, + flightRouterState: FlightRouterState, + segments: string[] +): string | null { + const [segment, parallelRoutes, url] = flightRouterState + + // Skip root segment (empty string) but check all parallel routes + if (segment === '') { + if (parallelRoutes) { + // Try children first (the default parallel route) + if (parallelRoutes.children) { + const result = extract( + targetPathname, + parallelRoutes.children, + segments + ) + if (result !== null) { + return result + } + } + + // If children didn't match, try other parallel routes + for (const parallelRouteKey in parallelRoutes) { + if (parallelRouteKey === 'children') continue + const parallelRouteValue = parallelRoutes[parallelRouteKey] + + // For root-level named parallel routes, add the @slot marker + const segmentsWithSlot = [`@${parallelRouteKey}`] + + const result = extract( + targetPathname, + parallelRouteValue, + segmentsWithSlot + ) + + if (result !== null) { + return result + } + } + } + // If nothing matched in any parallel route, return null + return null + } + + // Check if we've reached a page marker + if (typeof segment === 'string' && segment.startsWith(PAGE_SEGMENT_KEY)) { + // During client-side navigation, the url field may be null/undefined + // If the url matches OR is null (meaning this is the active page for the current path), + // we should return the canonical route we've built + const urlMatches = url === targetPathname + const isNullUrl = url === null || url === undefined + + if (urlMatches || isNullUrl) { + return segments.length > 0 ? '/' + segments.join('/') : '/' + } + // This page doesn't match - return null to continue searching + return null + } + + // Get the segment value for the canonical route + const segmentValue = getCanonicalSegmentValue(segment) + + // Skip synthetic '(slot)' segments that Next.js adds internally for parallel routes + // These only appear immediately after a @parallelRoute marker, e.g., @modal/(slot) + // We must be careful not to skip user-defined (slot) route groups elsewhere + const lastSegment = segments.length > 0 ? segments[segments.length - 1] : null + const isAfterParallelRoute = lastSegment?.startsWith('@') + const isSyntheticSlot = segmentValue === '(slot)' && isAfterParallelRoute + + // Build the current path with this segment (skip synthetic slots) + const currentSegments = isSyntheticSlot + ? segments + : [...segments, segmentValue] + + // Search parallel routes in priority order + if (parallelRoutes) { + // Try children first (the default parallel route) + // If children matches, return immediately - don't check other parallel routes + if (parallelRoutes.children) { + const result = extract( + targetPathname, + parallelRoutes.children, + currentSegments + ) + if (result !== null) { + return result + } + } + + // Only check other parallel routes (named slots) if children didn't match + // This happens during intercepted routes where the modal is active + for (const parallelRouteKey in parallelRoutes) { + // Skip children since we already tried it + if (parallelRouteKey === 'children') continue + const parallelRouteValue = parallelRoutes[parallelRouteKey] + + // For named parallel routes, the @slot marker comes AFTER the parent segment + // Example: /see/@modal/(slot) where "see" is parent, @modal is the slot marker + const segmentsWithSlot = [...currentSegments, `@${parallelRouteKey}`] + + const result = extract( + targetPathname, + parallelRouteValue, + segmentsWithSlot + ) + + if (result !== null) { + return result + } + } + } + + return null +} + +/** + * Converts a Segment to its canonical string representation matching the file system structure: + * - Dynamic segments: [paramName, value, 'd'|'di'] → [paramName] + * - Catch-all segments: [paramName, value, 'c'|'ci'] → [...paramName] + * - Optional catch-all segments: [paramName, value, 'oc'] → [[...paramName]] + * - Static segments/route groups: kept as-is + */ +function getCanonicalSegmentValue(segment: Segment): string { + if (Array.isArray(segment)) { + const [paramName, , paramType] = segment + return convertDynamicParamType(paramType, paramName) + } + + // Static segment or route group - keep as-is + return segment +} 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 c3023e919cd5a..b52dd11a053af 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 dbd392d5d5f03..dc49ce882f5b1 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 0c484211515ee..fc4097dac32e9 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 060c1094ae29d..90659b7386905 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 02076da5ca552..93d077969721e 100644 --- a/packages/next/src/server/dev/on-demand-entry-handler.ts +++ b/packages/next/src/server/dev/on-demand-entry-handler.ts @@ -2,7 +2,6 @@ import type ws from 'next/dist/compiled/ws' import type { webpack } from 'next/dist/compiled/webpack/webpack' import type { NextConfigComplete } from '../config-shared' import type { - DynamicParamTypesShort, FlightRouterState, FlightSegmentPath, } from '../../shared/lib/app-router-types' @@ -48,6 +47,7 @@ import { PAGE_TYPES } from '../../lib/page-types' import { getNextFlightSegmentPath } from '../../client/flight-data-helpers' import { handleErrorStateResponse } from '../mcp/tools/get-errors' import { handlePageMetadataResponse } from '../mcp/tools/get-page-metadata' +import { convertDynamicParamType } from '../../shared/lib/convert-dynamic-param-type' const debug = createDebug('next:on-demand-entry-handler') @@ -81,24 +81,6 @@ function treePathToEntrypoint( return treePathToEntrypoint(childSegmentPath, path) } -function convertDynamicParamTypeToSyntax( - dynamicParamTypeShort: DynamicParamTypesShort, - param: string -) { - switch (dynamicParamTypeShort) { - case 'c': - case 'ci': - return `[...${param}]` - case 'oc': - return `[[...${param}]]` - case 'd': - case 'di': - return `[${param}]` - default: - throw new Error('Unknown dynamic param type') - } -} - /** * format: {compiler type}@{page type}@{page path} * e.g. client@pages@/index @@ -137,7 +119,7 @@ function getEntrypointsFromTree( const [segment, parallelRoutes] = tree const currentSegment = Array.isArray(segment) - ? convertDynamicParamTypeToSyntax(segment[2], segment[0]) + ? convertDynamicParamType(segment[2], segment[0]) : segment const isPageSegment = currentSegment.startsWith(PAGE_SEGMENT_KEY) diff --git a/packages/next/src/shared/lib/app-router-types.ts b/packages/next/src/shared/lib/app-router-types.ts index aa050987ab693..176efd7a96734 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/convert-dynamic-param-type.ts b/packages/next/src/shared/lib/convert-dynamic-param-type.ts new file mode 100644 index 0000000000000..2eb5060777148 --- /dev/null +++ b/packages/next/src/shared/lib/convert-dynamic-param-type.ts @@ -0,0 +1,35 @@ +import type { DynamicParamTypesShort } from './app-router-types' + +export function convertDynamicParamType( + dynamicParamTypeShort: DynamicParamTypesShort, + param: string +) { + let result: string + switch (dynamicParamTypeShort) { + case 'c': + result = `[...${param}]` + break + case 'ci(..)(..)': + case 'ci(.)': + case 'ci(..)': + case 'ci(...)': + result = `${dynamicParamTypeShort.slice(2)}[...${param}]` + break + case 'oc': + result = `[[...${param}]]` + break + case 'd': + result = `[${param}]` + break + case 'di(..)(..)': + case 'di(.)': + case 'di(..)': + case 'di(...)': + result = `${dynamicParamTypeShort.slice(2)}[${param}]` + break + default: + dynamicParamTypeShort satisfies never + result = '' + } + return result +} 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 139c497052861..90cb985ea93b4 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 093c4a97d0377..eb024b9d6c5af 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 af7ce9d19aaf8..0f3fa5fbb185d 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 diff --git a/test/e2e/app-dir/app-use-route/app/(app)/(dashboard)/settings/page.tsx b/test/e2e/app-dir/app-use-route/app/(app)/(dashboard)/settings/page.tsx new file mode 100644 index 0000000000000..0c18a699aa66d --- /dev/null +++ b/test/e2e/app-dir/app-use-route/app/(app)/(dashboard)/settings/page.tsx @@ -0,0 +1,10 @@ +import { RouteDisplay } from '../../../components/route-display' + +export default function Page() { + return ( +