diff --git a/packages/next/errors.json b/packages/next/errors.json index 0b3f1ee8a2a0a..576ce44b32593 100644 --- a/packages/next/errors.json +++ b/packages/next/errors.json @@ -852,5 +852,7 @@ "851": "Pass either `webpack` or `turbopack`, not both.", "852": "Only custom servers can pass `webpack`, `turbo`, or `turbopack`.", "853": "Turbopack build failed", - "854": "Expected a %s request header." + "854": "Expected a %s request header.", + "855": "No reference found for param: %s in reference: %s", + "856": "No reference found for segment: %s with reference: %s" } diff --git a/packages/next/src/client/components/app-router.tsx b/packages/next/src/client/components/app-router.tsx index 43f6c019f599a..a93e05255d6c4 100644 --- a/packages/next/src/client/components/app-router.tsx +++ b/packages/next/src/client/components/app-router.tsx @@ -396,7 +396,7 @@ function Router({ } }, []) - const { cache, tree, nextUrl, focusAndScrollRef } = state + const { cache, tree, nextUrl, focusAndScrollRef, previousNextUrl } = state const matchingHead = useMemo(() => { return findHeadInCache(cache, tree[1]) @@ -423,8 +423,9 @@ function Router({ tree, focusAndScrollRef, nextUrl, + previousNextUrl, } - }, [tree, focusAndScrollRef, nextUrl]) + }, [tree, focusAndScrollRef, nextUrl, previousNextUrl]) let head if (matchingHead !== null) { diff --git a/packages/next/src/client/components/layout-router.tsx b/packages/next/src/client/components/layout-router.tsx index ad968d8c54327..46d5da38a4185 100644 --- a/packages/next/src/client/components/layout-router.tsx +++ b/packages/next/src/client/components/layout-router.tsx @@ -389,7 +389,14 @@ function InnerLayoutRouter({ new URL(url, location.origin), { flightRouterState: refetchTree, - nextUrl: includeNextUrl ? context.nextUrl : null, + nextUrl: includeNextUrl + ? // We always send the last next-url, not the current when + // performing a dynamic request. This is because we update + // the next-url after a navigation, but we want the same + // interception route to be matched that used the last + // next-url. + context.previousNextUrl || context.nextUrl + : null, } ).then((serverResponse) => { startTransition(() => { diff --git a/packages/next/src/client/components/router-reducer/create-initial-router-state.test.tsx b/packages/next/src/client/components/router-reducer/create-initial-router-state.test.tsx index 98a5c5db8828a..8205e8eb66d0e 100644 --- a/packages/next/src/client/components/router-reducer/create-initial-router-state.test.tsx +++ b/packages/next/src/client/components/router-reducer/create-initial-router-state.test.tsx @@ -138,6 +138,7 @@ describe('createInitialRouterState', () => { }, cache: expectedCache, nextUrl: '/linking', + previousNextUrl: null, } expect(state).toMatchObject(expected) diff --git a/packages/next/src/client/components/router-reducer/create-initial-router-state.ts b/packages/next/src/client/components/router-reducer/create-initial-router-state.ts index e128c9e289cb8..10e0114966618 100644 --- a/packages/next/src/client/components/router-reducer/create-initial-router-state.ts +++ b/packages/next/src/client/components/router-reducer/create-initial-router-state.ts @@ -110,6 +110,7 @@ export function createInitialRouterState({ // the || operator is intentional, the pathname can be an empty string (extractPathFromFlightRouterState(initialTree) || location?.pathname) ?? null, + previousNextUrl: null, } if (process.env.NODE_ENV !== 'development' && location) { diff --git a/packages/next/src/client/components/router-reducer/handle-mutable.ts b/packages/next/src/client/components/router-reducer/handle-mutable.ts index 88c5e38b3be4b..aae3287998b48 100644 --- a/packages/next/src/client/components/router-reducer/handle-mutable.ts +++ b/packages/next/src/client/components/router-reducer/handle-mutable.ts @@ -16,6 +16,7 @@ export function handleMutable( // shouldScroll is true by default, can override to false. const shouldScroll = mutable.shouldScroll ?? true + let previousNextUrl = state.previousNextUrl let nextUrl = state.nextUrl if (isNotUndefined(mutable.patchedTree)) { @@ -23,6 +24,7 @@ export function handleMutable( const changedPath = computeChangedPath(state.tree, mutable.patchedTree) if (changedPath) { // If the tree changed, we need to update the nextUrl + previousNextUrl = nextUrl nextUrl = changedPath } else if (!nextUrl) { // if the tree ends up being the same (ie, no changed path), and we don't have a nextUrl, then we should use the canonicalUrl @@ -84,5 +86,6 @@ export function handleMutable( ? mutable.patchedTree : state.tree, nextUrl, + previousNextUrl: previousNextUrl, } } diff --git a/packages/next/src/client/components/router-reducer/reducers/navigate-reducer.ts b/packages/next/src/client/components/router-reducer/reducers/navigate-reducer.ts index 39063dbf89255..1764e21400293 100644 --- a/packages/next/src/client/components/router-reducer/reducers/navigate-reducer.ts +++ b/packages/next/src/client/components/router-reducer/reducers/navigate-reducer.ts @@ -387,7 +387,12 @@ export function navigateReducer( new URL(updatedCanonicalUrl, url.origin), { flightRouterState: dynamicRequestTree, - nextUrl: state.nextUrl, + // We always send the last next-url, not the current when + // performing a dynamic request. This is because we update + // the next-url after a navigation, but we want the same + // interception route to be matched that used the last + // next-url. + nextUrl: state.previousNextUrl || state.nextUrl, } ) diff --git a/packages/next/src/client/components/router-reducer/reducers/restore-reducer.ts b/packages/next/src/client/components/router-reducer/reducers/restore-reducer.ts index 6ded9259524ed..1fbfd3e08d514 100644 --- a/packages/next/src/client/components/router-reducer/reducers/restore-reducer.ts +++ b/packages/next/src/client/components/router-reducer/reducers/restore-reducer.ts @@ -45,5 +45,6 @@ export function restoreReducer( // Restore provided tree tree: treeToRestore, nextUrl: extractPathFromFlightRouterState(treeToRestore) ?? url.pathname, + previousNextUrl: null, } } diff --git a/packages/next/src/client/components/router-reducer/reducers/server-action-reducer.ts b/packages/next/src/client/components/router-reducer/reducers/server-action-reducer.ts index 5898f08bee072..2acc1752f48f6 100644 --- a/packages/next/src/client/components/router-reducer/reducers/server-action-reducer.ts +++ b/packages/next/src/client/components/router-reducer/reducers/server-action-reducer.ts @@ -257,8 +257,14 @@ export function serverActionReducer( // Otherwise the server action might be intercepted with the wrong action id // (ie, one that corresponds with the intercepted route) const nextUrl = - state.nextUrl && hasInterceptionRouteInCurrentTree(state.tree) - ? state.nextUrl + // We always send the last next-url, not the current when + // performing a dynamic request. This is because we update + // the next-url after a navigation, but we want the same + // interception route to be matched that used the last + // next-url. + (state.previousNextUrl || state.nextUrl) && + hasInterceptionRouteInCurrentTree(state.tree) + ? state.previousNextUrl || state.nextUrl : null const navigatedAt = Date.now() diff --git a/packages/next/src/client/components/router-reducer/router-reducer-types.ts b/packages/next/src/client/components/router-reducer/router-reducer-types.ts index 7c1eb87daa31c..3930fd037eeb0 100644 --- a/packages/next/src/client/components/router-reducer/router-reducer-types.ts +++ b/packages/next/src/client/components/router-reducer/router-reducer-types.ts @@ -254,6 +254,11 @@ export type AppRouterState = { * The underlying "url" representing the UI state, which is used for intercepting routes. */ nextUrl: string | null + + /** + * The previous next-url that was used previous to a dynamic navigation. + */ + previousNextUrl: string | null } export type ReadonlyReducerState = Readonly diff --git a/packages/next/src/lib/build-custom-route.ts b/packages/next/src/lib/build-custom-route.ts index b5eec9564a76e..78d5d41212a3a 100644 --- a/packages/next/src/lib/build-custom-route.ts +++ b/packages/next/src/lib/build-custom-route.ts @@ -45,7 +45,19 @@ export function buildCustomRoute( ) } - const regex = normalizeRouteRegex(source) + // If this is an internal rewrite and it already provides a regex, use it + // otherwise, normalize the source to a regex. + let regex: string + if ( + !route.internal || + type !== 'rewrite' || + !('regex' in route) || + typeof route.regex !== 'string' + ) { + regex = normalizeRouteRegex(source) + } else { + regex = route.regex + } if (type !== 'redirect') { return { ...route, regex } diff --git a/packages/next/src/lib/generate-interception-routes-rewrites.test.ts b/packages/next/src/lib/generate-interception-routes-rewrites.test.ts new file mode 100644 index 0000000000000..b20854e78fb01 --- /dev/null +++ b/packages/next/src/lib/generate-interception-routes-rewrites.test.ts @@ -0,0 +1,1627 @@ +import type { Rewrite } from './load-custom-routes' +import { generateInterceptionRoutesRewrites } from './generate-interception-routes-rewrites' + +/** + * Helper to create regex matchers from a rewrite object. + * The router automatically adds ^ and $ anchors to header patterns via matchHas(), + * so we add them here for testing to match production behavior. + */ +function getRewriteMatchers(rewrite: Rewrite) { + return { + sourceRegex: new RegExp(rewrite.regex!), + headerRegex: new RegExp(`^${rewrite.has![0].value!}$`), + } +} + +describe('generateInterceptionRoutesRewrites', () => { + describe('(.) same-level interception', () => { + it('should generate rewrite for root-level slot intercepting root-level route', () => { + const rewrites = generateInterceptionRoutesRewrites(['/@slot/(.)nested']) + + expect(rewrites).toHaveLength(1) + const rewrite = rewrites[0] + + // Source should be the intercepted route (where user navigates TO) + expect(rewrite.source).toBe('/nested') + + // Destination should be the intercepting route path + expect(rewrite.destination).toBe('/@slot/(.)nested') + + // The Next-Url header should match routes at the same level as the intercepting route + // Since @slot is normalized to /, it should match root-level routes + expect(rewrite.has).toHaveLength(1) + expect(rewrite.has?.[0].key).toBe('next-url') + + // The regex should match: + // - / (root) + // - /nested-link (any root-level route) + // - /foo (any other root-level route) + // But NOT: + // - /foo/bar (nested routes) + const { headerRegex } = getRewriteMatchers(rewrite) + expect(headerRegex.source).toMatchInlineSnapshot(`"^(\\/[^/]+)?\\/?$"`) + + expect(headerRegex.test('/')).toBe(true) + expect(headerRegex.test('/nested-link')).toBe(true) + expect(headerRegex.test('/foo')).toBe(true) + expect(headerRegex.test('/foo/bar')).toBe(false) + expect(headerRegex.test('/a/b/c')).toBe(false) + }) + + it('should generate rewrite for nested route intercepting sibling', () => { + const rewrites = generateInterceptionRoutesRewrites([ + '/intercepting-routes/feed/(.)photos/[id]', + ]) + + expect(rewrites).toHaveLength(1) + const rewrite = rewrites[0] + + // Source should be the intercepted route with named parameter + expect(rewrite.source).toBe('/intercepting-routes/feed/photos/:nxtPid') + + // Destination should be the intercepting route with the same named parameter + expect(rewrite.destination).toBe( + '/intercepting-routes/feed/(.)photos/:nxtPid' + ) + + // Verify the regex in the rewrite can match actual URLs + const { sourceRegex, headerRegex } = getRewriteMatchers(rewrite) + expect(headerRegex.source).toMatchInlineSnapshot( + `"^\\/intercepting\\-routes\\/feed(\\/[^/]+)?\\/?$"` + ) + + expect(sourceRegex.test('/intercepting-routes/feed/photos/123')).toBe( + true + ) + expect(sourceRegex.test('/intercepting-routes/feed/photos/abc')).toBe( + true + ) + + // The Next-Url header should match routes at /intercepting-routes/feed level + // Should match routes at the same level + expect(headerRegex.test('/intercepting-routes/feed')).toBe(true) + expect(headerRegex.test('/intercepting-routes/feed/nested')).toBe(true) + + // Should NOT match parent or deeper nested routes + expect(headerRegex.test('/intercepting-routes')).toBe(false) + expect(headerRegex.test('/intercepting-routes/feed/nested/deep')).toBe( + false + ) + }) + + it('should handle (.) with dynamic parameters in intercepting route', () => { + const rewrites = generateInterceptionRoutesRewrites([ + '/intercepting-siblings/@modal/(.)[id]', + ]) + + expect(rewrites).toHaveLength(1) + const rewrite = rewrites[0] + + // Source should have the [id] parameter with nxtP prefix (from intercepted route) + // Destination uses the same prefix for parameter substitution + expect(rewrite.source).toBe('/intercepting-siblings/:nxtPid') + expect(rewrite.destination).toBe( + '/intercepting-siblings/@modal/(.):nxtPid' + ) + + // Verify the source regex matches actual URLs + const { sourceRegex, headerRegex } = getRewriteMatchers(rewrite) + expect(headerRegex.source).toMatchInlineSnapshot( + `"^\\/intercepting\\-siblings(\\/[^/]+)?\\/?$"` + ) + + expect(sourceRegex.test('/intercepting-siblings/123')).toBe(true) + expect(sourceRegex.test('/intercepting-siblings/user-abc')).toBe(true) + + // Should match routes at /intercepting-siblings level + expect(headerRegex.test('/intercepting-siblings')).toBe(true) + }) + + it('should handle (.) with multiple dynamic parameters', () => { + const rewrites = generateInterceptionRoutesRewrites([ + '/intercepting-routes-dynamic/photos/(.)[author]/[id]', + ]) + + expect(rewrites).toHaveLength(1) + const rewrite = rewrites[0] + + // Source should have both parameters with nxtP prefix (from intercepted route) + // Both source and destination use the same prefixes for proper substitution + expect(rewrite.source).toBe( + '/intercepting-routes-dynamic/photos/:nxtPauthor/:nxtPid' + ) + expect(rewrite.destination).toBe( + '/intercepting-routes-dynamic/photos/(.):nxtPauthor/:nxtPid' + ) + + // Verify the source regex matches actual URLs with both parameters + const { sourceRegex, headerRegex } = getRewriteMatchers(rewrite) + expect(headerRegex.source).toMatchInlineSnapshot( + `"^\\/intercepting\\-routes\\-dynamic\\/photos(\\/[^/]+)?\\/?$"` + ) + + expect( + sourceRegex.test('/intercepting-routes-dynamic/photos/john/123') + ).toBe(true) + expect( + sourceRegex.test('/intercepting-routes-dynamic/photos/jane/post-456') + ).toBe(true) + + // Should match the parent directory + expect(headerRegex.test('/intercepting-routes-dynamic/photos')).toBe(true) + }) + + it('should handle (.) with optional catchall in intercepting route', () => { + const rewrites = generateInterceptionRoutesRewrites([ + '/[[...locale]]/(.)settings', + ]) + + expect(rewrites).toHaveLength(1) + const rewrite = rewrites[0] + + // Source should have optional catchall parameter with * suffix + expect(rewrite.source).toBe('/:nxtPlocale*/settings') + + // Destination should use the same parameter with * suffix + // The marker (.) comes after the parameter in the destination + expect(rewrite.destination).toBe('/:nxtPlocale*/(.)settings') + + // Verify source regex matches actual URLs with 0 or more catchall segments + const { sourceRegex, headerRegex } = getRewriteMatchers(rewrite) + expect(headerRegex.source).toMatchInlineSnapshot( + `"^(?:\\/(?.+?))?(\\/[^/]+)?\\/?$"` + ) + + expect(sourceRegex.test('/settings')).toBe(true) + expect(sourceRegex.test('/en/settings')).toBe(true) + expect(sourceRegex.test('/en/us/settings')).toBe(true) + + // Header should match routes at the same level as the intercepting route + // The optional catchall can match zero or more segments + expect(headerRegex.test('/')).toBe(true) // Zero locale segments (root level) + expect(headerRegex.test('/en')).toBe(true) // One locale segment + expect(headerRegex.test('/en/us')).toBe(true) // Multiple locale segments + + // Should match direct children at each level (same-level interception allows one child) + expect(headerRegex.test('/other-page')).toBe(true) // Direct child at root + expect(headerRegex.test('/en/settings')).toBe(true) // Direct child at /en level + expect(headerRegex.test('/en/us/nested')).toBe(true) // Direct child at /en/us level + + // With optional catchall, any depth of catchall + one child is valid + expect(headerRegex.test('/en/settings/deep')).toBe(true) // /en/settings level + child + expect(headerRegex.test('/en/us/nested/deeper')).toBe(true) // /en/us/nested level + child + + // Should NOT match when there's no valid "catchall + child" or "just catchall" interpretation + expect(headerRegex.test('/a/b/c/d/e')).toBe(true) // Actually matches: /a/b/c/d as catchall + /e as child + }) + }) + + describe('(..) one-level-up interception', () => { + it('should generate header regex that matches child routes for (..) marker', () => { + // Test WITHOUT catchall sibling - should only match exact level + const rewritesWithoutCatchall = generateInterceptionRoutesRewrites([ + '/templates/(..)showcase/[...catchAll]', + ]) + + expect(rewritesWithoutCatchall).toHaveLength(1) + const rewriteWithoutCatchall = rewritesWithoutCatchall[0] + + expect(rewriteWithoutCatchall.source).toBe('/showcase/:nxtPcatchAll+') + expect(rewriteWithoutCatchall.destination).toBe( + '/templates/(..)showcase/:nxtPcatchAll+' + ) + + const { headerRegex: headerWithoutCatchall } = getRewriteMatchers( + rewriteWithoutCatchall + ) + expect(headerWithoutCatchall.source).toMatchInlineSnapshot( + `"^\\/templates$"` + ) + + // Without catchall sibling: should match exact level only + expect(headerWithoutCatchall.test('/templates')).toBe(true) + expect(headerWithoutCatchall.test('/templates/multi')).toBe(false) + expect(headerWithoutCatchall.test('/templates/multi/slug')).toBe(false) + + // Test WITH catchall sibling - should match exact level AND catchall paths + const rewritesWithCatchall = generateInterceptionRoutesRewrites([ + '/templates/(..)showcase/[...catchAll]', + '/templates/[...catchAll]', // Catchall sibling at same level + ]) + + expect(rewritesWithCatchall).toHaveLength(1) + const rewriteWithCatchall = rewritesWithCatchall[0] + + const { headerRegex: headerWithCatchall } = + getRewriteMatchers(rewriteWithCatchall) + expect(headerWithCatchall.source).toMatchInlineSnapshot( + `"^\\/templates(\\/.+)?$"` + ) + + // With catchall sibling: should match exact level AND catchall paths + expect(headerWithCatchall.test('/templates')).toBe(true) + expect(headerWithCatchall.test('/templates/multi')).toBe(true) + expect(headerWithCatchall.test('/templates/multi/slug')).toBe(true) + expect(headerWithCatchall.test('/templates/single')).toBe(true) + expect(headerWithCatchall.test('/templates/another/slug')).toBe(true) + + // Both should NOT match unrelated routes + expect(headerWithoutCatchall.test('/other-route')).toBe(false) + expect(headerWithoutCatchall.test('/showcase/test')).toBe(false) + expect(headerWithCatchall.test('/other-route')).toBe(false) + expect(headerWithCatchall.test('/showcase/test')).toBe(false) + }) + + it('should generate rewrite for parallel modal intercepting one level up', () => { + const rewrites = generateInterceptionRoutesRewrites([ + '/(group)/intercepting-parallel-modal/[username]/@modal/(..)photo/[id]', + ]) + + expect(rewrites).toHaveLength(1) + const rewrite = rewrites[0] + + // Source is the intercepted route + // Note: photo is at /intercepting-parallel-modal/photo, not /photo + // because it's inside the intercepting-parallel-modal directory + expect(rewrite.source).toBe('/intercepting-parallel-modal/photo/:nxtPid') + + // Destination should include the full intercepting path (with route group) + expect(rewrite.destination).toBe( + '/(group)/intercepting-parallel-modal/:nxtPusername/@modal/(..)photo/:nxtPid' + ) + + // Verify source regex matches actual photo URLs + const { sourceRegex, headerRegex } = getRewriteMatchers(rewrite) + expect(headerRegex.source).toMatchInlineSnapshot( + `"^\\/intercepting\\-parallel\\-modal\\/(?[^/]+?)$"` + ) + + expect(sourceRegex.test('/intercepting-parallel-modal/photo/123')).toBe( + true + ) + expect(sourceRegex.test('/intercepting-parallel-modal/photo/abc')).toBe( + true + ) + + // The (..) marker generates a pattern that matches the intercepting route level and its children + // Should match the intercepting route itself with actual dynamic segment values + expect(headerRegex.test('/intercepting-parallel-modal/john')).toBe(true) + expect(headerRegex.test('/intercepting-parallel-modal/jane')).toBe(true) + + // Should not match child routes + expect(headerRegex.test('/intercepting-parallel-modal/john/child')).toBe( + false + ) + expect( + headerRegex.test('/intercepting-parallel-modal/jane/deep/nested') + ).toBe(false) + + // Should NOT match parent routes without the required parameter + expect(headerRegex.test('/intercepting-parallel-modal')).toBe(false) + }) + + it('should generate rewrite with dynamic segment in parent', () => { + const rewrites = generateInterceptionRoutesRewrites([ + '/[lang]/foo/(..)photos', + ]) + + expect(rewrites).toHaveLength(1) + const rewrite = rewrites[0] + + // Source has the dynamic parameter from the parent path + expect(rewrite.source).toBe('/:nxtPlang/photos') + + // Destination should use the same parameter name + expect(rewrite.destination).toBe('/:nxtPlang/foo/(..)photos') + + // Verify source regex matches actual URLs + const { sourceRegex, headerRegex } = getRewriteMatchers(rewrite) + expect(headerRegex.source).toMatchInlineSnapshot( + `"^\\/(?[^/]+?)\\/foo$"` + ) + + expect(sourceRegex.test('/en/photos')).toBe(true) + expect(sourceRegex.test('/es/photos')).toBe(true) + expect(sourceRegex.test('/fr/photos')).toBe(true) + + // Should match child routes of /[lang]/foo with actual parameter values + // Since the route ends with a static segment (foo), children are required + expect(headerRegex.test('/en/foo')).toBe(true) + expect(headerRegex.test('/es/foo')).toBe(true) + + expect(headerRegex.test('/en/foo/bar')).toBe(false) + }) + + it('should handle (..) with optional catchall in intercepting route', () => { + const rewrites = generateInterceptionRoutesRewrites([ + '/[[...locale]]/dashboard/(..)settings', + ]) + + expect(rewrites).toHaveLength(1) + const rewrite = rewrites[0] + + // Source should have optional catchall parameter with * suffix (from shared parent) + expect(rewrite.source).toBe('/:nxtPlocale*/settings') + + // Destination should use the same parameter with * suffix + expect(rewrite.destination).toBe('/:nxtPlocale*/dashboard/(..)settings') + + // Verify source regex matches actual URLs with 0 or more catchall segments + const { sourceRegex, headerRegex } = getRewriteMatchers(rewrite) + expect(headerRegex.source).toMatchInlineSnapshot( + `"^(?:\\/(?.+?))?\\/dashboard$"` + ) + + expect(sourceRegex.test('/settings')).toBe(true) + expect(sourceRegex.test('/en/settings')).toBe(true) + expect(sourceRegex.test('/en/us/settings')).toBe(true) + + // Header should match routes at the intercepting route level (/[[...locale]]/dashboard) + // The optional catchall can match zero or more segments + expect(headerRegex.test('/dashboard')).toBe(true) // Zero locale segments + expect(headerRegex.test('/en/dashboard')).toBe(true) // One locale segment + expect(headerRegex.test('/en/us/dashboard')).toBe(true) // Multiple locale segments + }) + + it('should handle (..) with optional catchall and catchall sibling', () => { + // Test WITH catchall sibling - should match exact level AND catchall paths + const rewrites = generateInterceptionRoutesRewrites([ + '/[[...locale]]/dashboard/(..)settings', + '/[[...locale]]/dashboard/[...slug]', // Catchall sibling at dashboard level + ]) + + expect(rewrites).toHaveLength(1) + const rewrite = rewrites[0] + + expect(rewrite.source).toBe('/:nxtPlocale*/settings') + expect(rewrite.destination).toBe('/:nxtPlocale*/dashboard/(..)settings') + + const { headerRegex } = getRewriteMatchers(rewrite) + expect(headerRegex.source).toMatchInlineSnapshot( + `"^(?:\\/(?.+?))?\\/dashboard(\\/.+)?$"` + ) + + // With catchall sibling: should match exact level AND catchall paths + // Optional catchall allows zero segments + expect(headerRegex.test('/dashboard')).toBe(true) // Zero locale segments + expect(headerRegex.test('/en/dashboard')).toBe(true) // One locale segment + expect(headerRegex.test('/en/us/dashboard')).toBe(true) // Multiple locale segments + + // With catchall sibling, should also match nested paths under the level + expect(headerRegex.test('/dashboard/foo')).toBe(true) + expect(headerRegex.test('/en/dashboard/foo')).toBe(true) + expect(headerRegex.test('/en/us/dashboard/foo/bar')).toBe(true) + }) + + it('should handle (..) with multiple dynamic segments including optional catchall', () => { + const rewrites = generateInterceptionRoutesRewrites([ + '/[[...locale]]/[userId]/(..)profile', + ]) + + expect(rewrites).toHaveLength(1) + const rewrite = rewrites[0] + + // Source should have both parameters + expect(rewrite.source).toBe('/:nxtPlocale*/profile') + + // Destination should have both parameters + expect(rewrite.destination).toBe('/:nxtPlocale*/:nxtPuserId/(..)profile') + + const { sourceRegex, headerRegex } = getRewriteMatchers(rewrite) + expect(headerRegex.source).toMatchInlineSnapshot( + `"^(?:\\/(?.+?))?\\/(?[^/]+?)$"` + ) + + // Source should match with 0 or more locale segments + expect(sourceRegex.test('/profile')).toBe(true) + expect(sourceRegex.test('/en/profile')).toBe(true) + expect(sourceRegex.test('/en/us/profile')).toBe(true) + + // Header should match the intercepting route level + // Optional catchall + required userId + expect(headerRegex.test('/user123')).toBe(true) + expect(headerRegex.test('/en/user123')).toBe(true) + expect(headerRegex.test('/en/us/user123')).toBe(true) + }) + }) + + describe('(...) root-level interception', () => { + it('should generate rewrite for root interception from nested route', () => { + const rewrites = generateInterceptionRoutesRewrites([ + '/[locale]/example/@modal/(...)[locale]/intercepted', + ]) + + expect(rewrites).toHaveLength(1) + const rewrite = rewrites[0] + + // Source is the intercepted route at root + expect(rewrite.source).toBe('/:nxtPlocale/intercepted') + + // Destination should include the full intercepting path with parameter + expect(rewrite.destination).toBe( + '/:nxtPlocale/example/@modal/(...):nxtPlocale/intercepted' + ) + + // Verify source regex matches actual URLs + const { sourceRegex, headerRegex } = getRewriteMatchers(rewrite) + expect(headerRegex.source).toMatchInlineSnapshot( + `"^\\/(?[^/]+?)\\/example(?:\\/)?$"` + ) + + expect(sourceRegex.test('/en/intercepted')).toBe(true) + expect(sourceRegex.test('/es/intercepted')).toBe(true) + + // Should match routes at the intercepting route level + // The intercepting route is /[locale]/example + expect(headerRegex.test('/en/example')).toBe(true) + expect(headerRegex.test('/es/example')).toBe(true) + + // Should NOT match deeper routes + expect(headerRegex.test('/en/example/nested')).toBe(false) + }) + + it('should generate rewrite for (...) in basepath context', () => { + const rewrites = generateInterceptionRoutesRewrites([ + '/[foo_id]/[bar_id]/@modal/(...)baz_id/[baz_id]', + ]) + + expect(rewrites).toHaveLength(1) + const rewrite = rewrites[0] + + // Source should be the root-level route with parameter (underscores preserved) + expect(rewrite.source).toBe('/baz_id/:nxtPbaz_id') + + // Destination should include all parameters from both paths (underscores preserved) + expect(rewrite.destination).toBe( + '/:nxtPfoo_id/:nxtPbar_id/@modal/(...)baz_id/:nxtPbaz_id' + ) + + // Verify source regex matches actual URLs + const { sourceRegex, headerRegex } = getRewriteMatchers(rewrite) + expect(headerRegex.source).toMatchInlineSnapshot( + `"^\\/(?[^/]+?)\\/(?[^/]+?)(?:\\/)?$"` + ) + + expect(sourceRegex.test('/baz_id/123')).toBe(true) + expect(sourceRegex.test('/baz_id/abc')).toBe(true) + + // Should match the intercepting route level + expect(headerRegex.test('/foo/bar')).toBe(true) + }) + + it('should handle (...) with optional catchall in intercepted route', () => { + const rewrites = generateInterceptionRoutesRewrites([ + '/[[...locale]]/dashboard/@modal/(...)[[...locale]]/intercepted', + ]) + + expect(rewrites).toHaveLength(1) + const rewrite = rewrites[0] + + // Source is the intercepted route at root with optional catchall + expect(rewrite.source).toBe('/:nxtPlocale*/intercepted') + + // Destination should include all parameters from both paths + expect(rewrite.destination).toBe( + '/:nxtPlocale*/dashboard/@modal/(...):nxtPlocale*/intercepted' + ) + + const { sourceRegex, headerRegex } = getRewriteMatchers(rewrite) + expect(headerRegex.source).toMatchInlineSnapshot( + `"^(?:\\/(?.+?))?\\/dashboard(?:\\/)?$"` + ) + + // Source should match with 0 or more locale segments + expect(sourceRegex.test('/intercepted')).toBe(true) + expect(sourceRegex.test('/en/intercepted')).toBe(true) + expect(sourceRegex.test('/en/us/intercepted')).toBe(true) + + // Header should match routes at the intercepting route level + // The intercepting route is /[[...locale]]/dashboard + expect(headerRegex.test('/dashboard')).toBe(true) + expect(headerRegex.test('/en/dashboard')).toBe(true) + expect(headerRegex.test('/en/us/dashboard')).toBe(true) + }) + }) + + describe('(..)(..) two-levels-up interception', () => { + it('should generate rewrite for two levels up', () => { + const rewrites = generateInterceptionRoutesRewrites([ + '/foo/bar/(..)(..)hoge', + ]) + + expect(rewrites).toHaveLength(1) + const rewrite = rewrites[0] + + // Source is the intercepted route at root + expect(rewrite.source).toBe('/hoge') + + // Destination should be the full intercepting path + expect(rewrite.destination).toBe('/foo/bar/(..)(..)hoge') + + // Verify source regex matches actual URLs + const { sourceRegex, headerRegex } = getRewriteMatchers(rewrite) + expect(headerRegex.source).toMatchInlineSnapshot( + `"^\\/foo\\/bar(?:\\/)?$"` + ) + + expect(sourceRegex.test('/hoge')).toBe(true) + + // Should match routes at /foo/bar level (two levels below root) + expect(headerRegex.test('/foo/bar')).toBe(true) + + // Should NOT match parent or deeper routes + expect(headerRegex.test('/foo')).toBe(false) + expect(headerRegex.test('/foo/bar/baz')).toBe(false) + }) + + it('should handle (..)(..) with optional catchall in intercepting route', () => { + const rewrites = generateInterceptionRoutesRewrites([ + '/[[...locale]]/foo/bar/(..)(..)hoge', + ]) + + expect(rewrites).toHaveLength(1) + const rewrite = rewrites[0] + + // Source is the intercepted route two levels up + expect(rewrite.source).toBe('/:nxtPlocale*/hoge') + + // Destination should include the full intercepting path + expect(rewrite.destination).toBe('/:nxtPlocale*/foo/bar/(..)(..)hoge') + + const { sourceRegex, headerRegex } = getRewriteMatchers(rewrite) + expect(headerRegex.source).toMatchInlineSnapshot( + `"^(?:\\/(?.+?))?\\/foo\\/bar(?:\\/)?$"` + ) + + // Source should match with 0 or more locale segments + expect(sourceRegex.test('/hoge')).toBe(true) + expect(sourceRegex.test('/en/hoge')).toBe(true) + expect(sourceRegex.test('/en/us/hoge')).toBe(true) + + // Header should match routes at /[[...locale]]/foo/bar level + expect(headerRegex.test('/foo/bar')).toBe(true) + expect(headerRegex.test('/en/foo/bar')).toBe(true) + expect(headerRegex.test('/en/us/foo/bar')).toBe(true) + + // Should NOT match parent or deeper routes + expect(headerRegex.test('/foo')).toBe(false) + expect(headerRegex.test('/en/foo')).toBe(false) + expect(headerRegex.test('/foo/bar/baz')).toBe(false) + }) + }) + + describe('catchall and optional catchall segments', () => { + it('should generate path-to-regexp format with + suffix for catchall parameters', () => { + const rewrites = generateInterceptionRoutesRewrites([ + '/templates/(..)showcase/[...catchAll]', + ]) + + expect(rewrites).toHaveLength(1) + const rewrite = rewrites[0] + + // The key improvement: catchall parameters should get * suffix for path-to-regexp + expect(rewrite.source).toBe('/showcase/:nxtPcatchAll+') + expect(rewrite.destination).toBe('/templates/(..)showcase/:nxtPcatchAll+') + + // Test with multiple catchall parameters + const multiCatchallRewrites = generateInterceptionRoutesRewrites([ + '/blog/[...category]/(..)archives/[...path]', + ]) + + expect(multiCatchallRewrites).toHaveLength(1) + const multiRewrite = multiCatchallRewrites[0] + + // The source should only contain the intercepted route parameters (path) + // The intercepting route parameters (category) are not part of the source + expect(multiRewrite.source).toBe('/blog/archives/:nxtPpath+') + expect(multiRewrite.destination).toBe( + '/blog/:nxtPcategory+/(..)archives/:nxtPpath+' + ) + }) + + it('should handle mixed parameter types correctly', () => { + const rewrites = generateInterceptionRoutesRewrites([ + '/shop/[category]/(..)products/[id]/reviews/[...path]', + ]) + + expect(rewrites).toHaveLength(1) + const rewrite = rewrites[0] + + // Source should only contain the intercepted route (products/[id]/reviews/[...path]) + // Regular params get no suffix, catchall gets * suffix + expect(rewrite.source).toBe('/shop/products/:nxtPid/reviews/:nxtPpath+') + expect(rewrite.destination).toBe( + '/shop/:nxtPcategory/(..)products/:nxtPid/reviews/:nxtPpath+' + ) + }) + + it('should handle (.) with catchall segments', () => { + const rewrites = generateInterceptionRoutesRewrites([ + '/intercepting-routes-dynamic-catchall/photos/(.)catchall/[...id]', + ]) + + expect(rewrites).toHaveLength(1) + const rewrite = rewrites[0] + + // Source should handle catchall with proper parameter and * suffix + expect(rewrite.source).toBe( + '/intercepting-routes-dynamic-catchall/photos/catchall/:nxtPid+' + ) + + // Destination should include the catchall parameter with * suffix + expect(rewrite.destination).toBe( + '/intercepting-routes-dynamic-catchall/photos/(.)catchall/:nxtPid+' + ) + + // Verify source regex matches catchall URLs + const { sourceRegex, headerRegex } = getRewriteMatchers(rewrite) + expect(headerRegex.source).toMatchInlineSnapshot( + `"^\\/intercepting\\-routes\\-dynamic\\-catchall\\/photos(\\/[^/]+)?\\/?$"` + ) + + expect( + sourceRegex.test( + '/intercepting-routes-dynamic-catchall/photos/catchall/a' + ) + ).toBe(true) + expect( + sourceRegex.test( + '/intercepting-routes-dynamic-catchall/photos/catchall/a/b' + ) + ).toBe(true) + expect( + sourceRegex.test( + '/intercepting-routes-dynamic-catchall/photos/catchall/a/b/c' + ) + ).toBe(true) + + // Should match the parent level + expect( + headerRegex.test('/intercepting-routes-dynamic-catchall/photos') + ).toBe(true) + }) + + it('should handle (.) with optional catchall segments', () => { + const rewrites = generateInterceptionRoutesRewrites([ + '/intercepting-routes-dynamic-catchall/photos/(.)optional-catchall/[[...id]]', + ]) + + expect(rewrites).toHaveLength(1) + const rewrite = rewrites[0] + + // Source should handle optional catchall with * suffix + expect(rewrite.source).toBe( + '/intercepting-routes-dynamic-catchall/photos/optional-catchall/:nxtPid*' + ) + + // Destination should include the optional catchall parameter with * suffix + expect(rewrite.destination).toBe( + '/intercepting-routes-dynamic-catchall/photos/(.)optional-catchall/:nxtPid*' + ) + + // Verify source regex matches both with and without segments + const { sourceRegex, headerRegex } = getRewriteMatchers(rewrite) + expect(headerRegex.source).toMatchInlineSnapshot( + `"^\\/intercepting\\-routes\\-dynamic\\-catchall\\/photos(\\/[^/]+)?\\/?$"` + ) + + expect( + sourceRegex.test( + '/intercepting-routes-dynamic-catchall/photos/optional-catchall' + ) + ).toBe(true) + expect( + sourceRegex.test( + '/intercepting-routes-dynamic-catchall/photos/optional-catchall/a' + ) + ).toBe(true) + expect( + sourceRegex.test( + '/intercepting-routes-dynamic-catchall/photos/optional-catchall/a/b' + ) + ).toBe(true) + + // Should match the parent level + expect( + headerRegex.test('/intercepting-routes-dynamic-catchall/photos') + ).toBe(true) + }) + }) + + describe('edge cases with route groups and parallel routes', () => { + it('should normalize route groups in intercepting route', () => { + const rewrites = generateInterceptionRoutesRewrites([ + '/(group)/intercepting-parallel-modal/[username]/@modal/(..)photo/[id]', + ]) + + expect(rewrites).toHaveLength(1) + + // Route groups should be normalized away + // (group) should not appear in the interceptingRoute calculation + // Note: Router adds ^ and $ anchors automatically via matchHas() + const { headerRegex } = getRewriteMatchers(rewrites[0]) + expect(headerRegex.source).toMatchInlineSnapshot( + `"^\\/intercepting\\-parallel\\-modal\\/(?[^/]+?)$"` + ) + + // With (..) marker, should match child routes. + expect(headerRegex.test('/intercepting-parallel-modal/john')).toBe(true) + expect(headerRegex.test('/intercepting-parallel-modal/jane')).toBe(true) + }) + + it('should ignore @slot prefix when calculating interception level', () => { + const rewrites = generateInterceptionRoutesRewrites(['/@slot/(.)nested']) + + expect(rewrites).toHaveLength(1) + + // @slot is a parallel route and shouldn't count as a segment + // So interceptingRoute should be / (root) + // Note: Router adds ^ and $ anchors automatically via matchHas() + const { headerRegex } = getRewriteMatchers(rewrites[0]) + expect(headerRegex.source).toMatchInlineSnapshot(`"^(\\/[^/]+)?\\/?$"`) + + // Should match root-level routes + expect(headerRegex.test('/')).toBe(true) + expect(headerRegex.test('/nested-link')).toBe(true) + }) + + it('should handle parallel routes at nested levels', () => { + const rewrites = generateInterceptionRoutesRewrites([ + '/parallel-layout/(.)sub/[slug]', + ]) + + expect(rewrites).toHaveLength(1) + + // Note: Router adds ^ and $ anchors automatically via matchHas() + const { headerRegex } = getRewriteMatchers(rewrites[0]) + expect(headerRegex.source).toMatchInlineSnapshot( + `"^\\/parallel\\-layout(\\/[^/]+)?\\/?$"` + ) + + // Should match routes at /parallel-layout level + expect(headerRegex.test('/parallel-layout')).toBe(true) + }) + + it('should handle optional catchall in route groups with (..) interception', () => { + const rewrites = generateInterceptionRoutesRewrites([ + '/(group)/[[...locale]]/dashboard/(..)settings', + ]) + + expect(rewrites).toHaveLength(1) + const rewrite = rewrites[0] + + // Route groups should be normalized away from paths + expect(rewrite.source).toBe('/:nxtPlocale*/settings') + expect(rewrite.destination).toBe( + '/(group)/:nxtPlocale*/dashboard/(..)settings' + ) + + const { headerRegex } = getRewriteMatchers(rewrite) + expect(headerRegex.source).toMatchInlineSnapshot( + `"^(?:\\/(?.+?))?\\/dashboard$"` + ) + + // Route group should be normalized, so header should match without it + expect(headerRegex.test('/dashboard')).toBe(true) + expect(headerRegex.test('/en/dashboard')).toBe(true) + expect(headerRegex.test('/en/us/dashboard')).toBe(true) + }) + + it('should handle optional catchall in parallel routes with (.) interception', () => { + const rewrites = generateInterceptionRoutesRewrites([ + '/@slot/[[...locale]]/(.)settings', + ]) + + expect(rewrites).toHaveLength(1) + const rewrite = rewrites[0] + + // @slot is a parallel route and should be normalized + expect(rewrite.source).toBe('/:nxtPlocale*/settings') + expect(rewrite.destination).toBe('/@slot/:nxtPlocale*/(.)settings') + + const { headerRegex } = getRewriteMatchers(rewrite) + expect(headerRegex.source).toMatchInlineSnapshot( + `"^(?:\\/(?.+?))?(\\/[^/]+)?\\/?$"` + ) + + // @slot should be normalized away, so interceptingRoute is root + // With optional catchall at root level + expect(headerRegex.test('/')).toBe(true) // Zero locale segments (root level) + expect(headerRegex.test('/en')).toBe(true) // One locale segment + expect(headerRegex.test('/en/us')).toBe(true) // Multiple locale segments + + // Should match direct children at each level (same-level interception allows one child) + expect(headerRegex.test('/other-page')).toBe(true) // Direct child at root + expect(headerRegex.test('/en/settings')).toBe(true) // Direct child at /en level + expect(headerRegex.test('/en/us/nested')).toBe(true) // Direct child at /en/us level + + // With optional catchall, any depth of catchall + one child is valid + expect(headerRegex.test('/en/settings/deep')).toBe(true) // /en/settings level + child + expect(headerRegex.test('/en/us/nested/deeper')).toBe(true) // /en/us/nested level + child + + // Should NOT match when there's no valid "catchall + child" or "just catchall" interpretation + expect(headerRegex.test('/a/b/c/d/e')).toBe(true) // Actually matches: /a/b/c/d as catchall + /e as child + }) + }) + + describe('basePath support', () => { + it('should include basePath in source and destination but not in header check', () => { + const rewrites = generateInterceptionRoutesRewrites( + ['/@slot/(.)nested'], + '/base' + ) + + expect(rewrites).toHaveLength(1) + const rewrite = rewrites[0] + + // Source and destination should include basePath + expect(rewrite.source).toBe('/base/nested') + expect(rewrite.destination).toBe('/base/@slot/(.)nested') + + // Verify source regex includes basePath and matches actual URLs + const { sourceRegex, headerRegex } = getRewriteMatchers(rewrite) + expect(headerRegex.source).toMatchInlineSnapshot(`"^(\\/[^/]+)?\\/?$"`) + + expect(sourceRegex.test('/base/nested')).toBe(true) + expect(sourceRegex.test('/nested')).toBe(false) // Should NOT match without basePath + + // But Next-Url header check should NOT include basePath + // (comment in code says "The Next-Url header does not contain the base path") + + // Should match root-level routes (without basePath in the check) + expect(headerRegex.test('/')).toBe(true) + expect(headerRegex.test('/nested-link')).toBe(true) + expect(headerRegex.test('/base')).toBe(true) // Matches because it's a root-level route + + // Should NOT match deeply nested routes + expect(headerRegex.test('/nested-link/deep')).toBe(false) + }) + + it('should handle optional catchall with basePath', () => { + const rewrites = generateInterceptionRoutesRewrites( + ['/[[...locale]]/(.)settings'], + '/base' + ) + + expect(rewrites).toHaveLength(1) + const rewrite = rewrites[0] + + // Source and destination should include basePath + expect(rewrite.source).toBe('/base/:nxtPlocale*/settings') + expect(rewrite.destination).toBe('/base/:nxtPlocale*/(.)settings') + + const { sourceRegex, headerRegex } = getRewriteMatchers(rewrite) + expect(headerRegex.source).toMatchInlineSnapshot( + `"^(?:\\/(?.+?))?(\\/[^/]+)?\\/?$"` + ) + + // Source regex should include basePath + expect(sourceRegex.test('/base/settings')).toBe(true) + expect(sourceRegex.test('/base/en/settings')).toBe(true) + expect(sourceRegex.test('/base/en/us/settings')).toBe(true) + expect(sourceRegex.test('/settings')).toBe(false) // Without basePath + + // Header check should NOT include basePath + // The optional catchall allows zero or more segments at root level + expect(headerRegex.test('/')).toBe(true) // Zero locale segments + expect(headerRegex.test('/en')).toBe(true) // One locale segment + expect(headerRegex.test('/en/us')).toBe(true) // Multiple locale segments + }) + }) + + describe('special parameter names', () => { + it('should handle parameters with special characters', () => { + const rewrites = generateInterceptionRoutesRewrites([ + '/[this-is-my-route]/@intercept/(.)some-page', + ]) + + expect(rewrites).toHaveLength(1) + + // Should properly handle parameter names with hyphens + // The parameter [this-is-my-route] should be sanitized to "thisismyroute" and prefixed + expect(rewrites[0].has![0].value).toContain('thisismyroute') + expect(rewrites[0].has![0].value).toMatch(/\(\?<.*thisismyroute.*>/) + + // Note: Router adds ^ and $ anchors automatically via matchHas() + const { headerRegex } = getRewriteMatchers(rewrites[0]) + expect(headerRegex.source).toMatchInlineSnapshot( + `"^\\/(?[^/]+?)(\\/[^/]+)?\\/?$"` + ) + + // Should match routes at the parent level + expect(headerRegex.test('/foo')).toBe(true) + }) + }) + + describe('parameter consistency between source, destination, and regex', () => { + it('should use consistent parameter names for (.) with dynamic segments', () => { + const rewrites = generateInterceptionRoutesRewrites([ + '/photos/(.)[author]/[id]', + ]) + + expect(rewrites).toHaveLength(1) + const rewrite = rewrites[0] + + // Extract parameter names from source (path-to-regexp format) + const sourceParams = rewrite.source + .match(/:(\w+)/g) + ?.map((p) => p.slice(1)) + expect(sourceParams).toEqual(['nxtPauthor', 'nxtPid']) + + // Extract parameter names from destination + const destParams = rewrite.destination + .match(/:(\w+)/g) + ?.map((p) => p.slice(1)) + expect(destParams).toEqual(['nxtPauthor', 'nxtPid']) + + // Extract capture group names from regex + const regexParams = Array.from( + rewrite.regex!.matchAll(/\(\?<(\w+)>/g) + ).map((m) => m[1]) + expect(regexParams).toEqual(['nxtPauthor', 'nxtPid']) + + // All three should match exactly + expect(sourceParams).toEqual(destParams) + expect(sourceParams).toEqual(regexParams) + }) + + it('should use consistent parameter names for (..) with dynamic segments', () => { + const rewrites = generateInterceptionRoutesRewrites([ + '/[org]/projects/(..)team/[id]', + ]) + + expect(rewrites).toHaveLength(1) + const rewrite = rewrites[0] + + // Extract and verify all parameters match + const sourceParams = rewrite.source + .match(/:(\w+)/g) + ?.map((p) => p.slice(1)) + const destParams = rewrite.destination + .match(/:(\w+)/g) + ?.map((p) => p.slice(1)) + const regexParams = Array.from( + rewrite.regex!.matchAll(/\(\?<(\w+)>/g) + ).map((m) => m[1]) + + // Should have org parameter in source but not in destination (it's above the interception level) + expect(sourceParams).toEqual(['nxtPorg', 'nxtPid']) + expect(destParams).toEqual(['nxtPorg', 'nxtPid']) + expect(regexParams).toEqual(['nxtPorg', 'nxtPid']) + + expect(sourceParams).toEqual(destParams) + expect(sourceParams).toEqual(regexParams) + }) + + it('should use consistent parameter names for (...) with dynamic segments', () => { + const rewrites = generateInterceptionRoutesRewrites([ + '/[locale]/dashboard/@modal/(...)auth/[provider]', + ]) + + expect(rewrites).toHaveLength(1) + const rewrite = rewrites[0] + + // For (...) root interception, source is the intercepted route (at root) + // Destination includes params from BOTH intercepting route and intercepted route + expect(rewrite.source).toBe('/auth/:nxtPprovider') + expect(rewrite.destination).toBe( + '/:nxtPlocale/dashboard/@modal/(...)auth/:nxtPprovider' + ) + + // Source only has provider (from intercepted route) + const sourceParams = rewrite.source + .match(/:(\w+)/g) + ?.map((p) => p.slice(1)) + expect(sourceParams).toEqual(['nxtPprovider']) + + // Destination has both locale (from intercepting route) and provider (from intercepted route) + const destParams = rewrite.destination + .match(/:(\w+)/g) + ?.map((p) => p.slice(1)) + expect(destParams).toEqual(['nxtPlocale', 'nxtPprovider']) + + // Regex only matches the source, so only has provider + const regexParams = Array.from( + rewrite.regex!.matchAll(/\(\?<(\w+)>/g) + ).map((m) => m[1]) + expect(regexParams).toEqual(['nxtPprovider']) + + // All should use nxtP prefix + expect(sourceParams!.every((p) => p.startsWith('nxtP'))).toBe(true) + expect(destParams!.every((p) => p.startsWith('nxtP'))).toBe(true) + }) + + it('should handle parameter substitution correctly', () => { + const rewrites = generateInterceptionRoutesRewrites([ + '/shop/(.)[category]/[productId]', + ]) + + expect(rewrites).toHaveLength(1) + const rewrite = rewrites[0] + + // Simulate what the router does: + // 1. Match the source URL against the regex + const { sourceRegex } = getRewriteMatchers(rewrite) + const match = sourceRegex.exec('/shop/electronics/12345') + + expect(match).toBeTruthy() + expect(match!.groups).toEqual({ + nxtPcategory: 'electronics', + nxtPproductId: '12345', + }) + + // 2. Extract the named groups + const params = match!.groups! + + // 3. Verify we can substitute into destination + let destination = rewrite.destination + for (const [key, value] of Object.entries(params)) { + destination = destination.replace(`:${key}`, value) + } + + expect(destination).toBe('/shop/(.)electronics/12345') + }) + + it('should handle catchall parameters with consistent naming', () => { + const rewrites = generateInterceptionRoutesRewrites([ + '/docs/(.)[...slug]', + ]) + + expect(rewrites).toHaveLength(1) + const rewrite = rewrites[0] + + // Verify catchall parameters get * suffix in path-to-regexp format + expect(rewrite.source).toBe('/docs/:nxtPslug+') + expect(rewrite.destination).toBe('/docs/(.):nxtPslug+') + + const sourceParams = rewrite.source + .match(/:(\w+)\*?/g) + ?.map((p) => p.slice(1).replace('*', '')) + const destParams = rewrite.destination + .match(/:(\w+)\*?/g) + ?.map((p) => p.slice(1).replace('*', '')) + const regexParams = Array.from( + rewrite.regex!.matchAll(/\(\?<(\w+)>/g) + ).map((m) => m[1]) + + expect(sourceParams).toEqual(['nxtPslug']) + expect(destParams).toEqual(['nxtPslug']) + expect(regexParams).toEqual(['nxtPslug']) + + // Test actual matching and substitution + const { sourceRegex } = getRewriteMatchers(rewrite) + const match = sourceRegex.exec('/docs/getting-started/installation') + + expect(match).toBeTruthy() + expect(match!.groups!.nxtPslug).toBe('getting-started/installation') + }) + + it('should handle multiple parameters with mixed types consistently', () => { + const rewrites = generateInterceptionRoutesRewrites([ + '/blog/[year]/[month]/(.)[slug]/comments/[...commentPath]', + ]) + + expect(rewrites).toHaveLength(1) + const rewrite = rewrites[0] + + // Verify source and destination have correct format with * suffix for catchall + expect(rewrite.source).toBe( + '/blog/:nxtPyear/:nxtPmonth/:nxtPslug/comments/:nxtPcommentPath+' + ) + expect(rewrite.destination).toBe( + '/blog/:nxtPyear/:nxtPmonth/(.):nxtPslug/comments/:nxtPcommentPath+' + ) + + // All parameters should use nxtP prefix (no nxtI for intercepted route source) + // Extract parameter names, removing * suffix from catchall + const sourceParams = rewrite.source + .match(/:(\w+)\*?/g) + ?.map((p) => p.slice(1).replace('*', '')) + const destParams = rewrite.destination + .match(/:(\w+)\*?/g) + ?.map((p) => p.slice(1).replace('*', '')) + const regexParams = Array.from( + rewrite.regex!.matchAll(/\(\?<(\w+)>/g) + ).map((m) => m[1]) + + expect(sourceParams).toEqual([ + 'nxtPyear', + 'nxtPmonth', + 'nxtPslug', + 'nxtPcommentPath', + ]) + expect(destParams).toEqual([ + 'nxtPyear', + 'nxtPmonth', + 'nxtPslug', + 'nxtPcommentPath', + ]) + expect(regexParams).toEqual([ + 'nxtPyear', + 'nxtPmonth', + 'nxtPslug', + 'nxtPcommentPath', + ]) + + expect(sourceParams).toEqual(destParams) + expect(sourceParams).toEqual(regexParams) + }) + + it('should verify the actual failing case from the bug report', () => { + const rewrites = generateInterceptionRoutesRewrites([ + '/intercepting-routes-dynamic/photos/(.)[author]/[id]', + ]) + + expect(rewrites).toHaveLength(1) + const rewrite = rewrites[0] + + // This is the exact case that was failing + expect(rewrite.source).toBe( + '/intercepting-routes-dynamic/photos/:nxtPauthor/:nxtPid' + ) + expect(rewrite.destination).toBe( + '/intercepting-routes-dynamic/photos/(.):nxtPauthor/:nxtPid' + ) + + // The bug was: regex had (? but source had :nxtIauthor + // Now they should match: + const regexParams = Array.from( + rewrite.regex!.matchAll(/\(\?<(\w+)>/g) + ).map((m) => m[1]) + expect(regexParams).toEqual(['nxtPauthor', 'nxtPid']) + + const sourceParams = rewrite.source + .match(/:(\w+)/g) + ?.map((p) => p.slice(1)) + expect(sourceParams).toEqual(['nxtPauthor', 'nxtPid']) + + // Verify actual URL matching and substitution works + const { sourceRegex } = getRewriteMatchers(rewrite) + const match = sourceRegex.exec( + '/intercepting-routes-dynamic/photos/next/123' + ) + + expect(match).toBeTruthy() + expect(match!.groups).toEqual({ + nxtPauthor: 'next', + nxtPid: '123', + }) + + // Verify substitution produces correct destination + let destination = rewrite.destination + for (const [key, value] of Object.entries(match!.groups!)) { + destination = destination.replace(`:${key}`, value) + } + + expect(destination).toBe( + '/intercepting-routes-dynamic/photos/(.)next/123' + ) + }) + }) + + describe('additional edge cases', () => { + describe('multiple parallel routes in sequence', () => { + it('should handle multiple parallel routes @slot1/@slot2', () => { + const rewrites = generateInterceptionRoutesRewrites([ + '/@slot1/@slot2/(.)photos', + ]) + + expect(rewrites).toHaveLength(1) + const rewrite = rewrites[0] + + // Both @slot1 and @slot2 should be normalized to / + expect(rewrite.source).toBe('/photos') + expect(rewrite.destination).toBe('/@slot1/@slot2/(.)photos') + + // Header should match root-level routes (both slots normalized away) + const { headerRegex } = getRewriteMatchers(rewrite) + expect(headerRegex.source).toMatchInlineSnapshot(`"^(\\/[^/]+)?\\/?$"`) + + expect(headerRegex.test('/')).toBe(true) + expect(headerRegex.test('/home')).toBe(true) + expect(headerRegex.test('/home/nested')).toBe(false) + }) + }) + + describe('optional catchall siblings', () => { + it('should detect optional catchall sibling [[...]] for (..) interception', () => { + const rewrites = generateInterceptionRoutesRewrites([ + '/templates/(..)showcase', + '/templates/[[...slug]]', // Optional catchall sibling + ]) + + expect(rewrites).toHaveLength(1) + const rewrite = rewrites[0] + + expect(rewrite.source).toBe('/showcase') + expect(rewrite.destination).toBe('/templates/(..)showcase') + + const { headerRegex } = getRewriteMatchers(rewrite) + expect(headerRegex.source).toMatchInlineSnapshot( + `"^\\/templates(\\/.+)?$"` + ) + + // With optional catchall sibling, should match exact level AND nested paths + expect(headerRegex.test('/templates')).toBe(true) + expect(headerRegex.test('/templates/foo')).toBe(true) + expect(headerRegex.test('/templates/foo/bar')).toBe(true) + }) + + it('should handle optional catchall at intercepting route level', () => { + const rewrites = generateInterceptionRoutesRewrites([ + '/[[...locale]]/dashboard/(..)settings', + '/[[...locale]]/dashboard/[...slug]', // Required catchall sibling + ]) + + expect(rewrites).toHaveLength(1) + const rewrite = rewrites[0] + + expect(rewrite.source).toBe('/:nxtPlocale*/settings') + expect(rewrite.destination).toBe('/:nxtPlocale*/dashboard/(..)settings') + + const { headerRegex } = getRewriteMatchers(rewrite) + expect(headerRegex.source).toMatchInlineSnapshot( + `"^(?:\\/(?.+?))?\\/dashboard(\\/.+)?$"` + ) + + // Should match dashboard with and without locale, plus nested paths + expect(headerRegex.test('/dashboard')).toBe(true) + expect(headerRegex.test('/en/dashboard')).toBe(true) + expect(headerRegex.test('/dashboard/foo')).toBe(true) + expect(headerRegex.test('/en/dashboard/foo/bar')).toBe(true) + }) + }) + + describe('intercepting catchall routes', () => { + it('should intercept a required catchall route with (.)', () => { + const rewrites = generateInterceptionRoutesRewrites([ + '/feed/(.)blog/[...slug]', + ]) + + expect(rewrites).toHaveLength(1) + const rewrite = rewrites[0] + + // Source should have catchall parameter with + suffix + expect(rewrite.source).toBe('/feed/blog/:nxtPslug+') + expect(rewrite.destination).toBe('/feed/(.)blog/:nxtPslug+') + + const { sourceRegex, headerRegex } = getRewriteMatchers(rewrite) + expect(headerRegex.source).toMatchInlineSnapshot( + `"^\\/feed(\\/[^/]+)?\\/?$"` + ) + + // Source should match catchall paths + expect(sourceRegex.test('/feed/blog/post-1')).toBe(true) + expect(sourceRegex.test('/feed/blog/2024/post-1')).toBe(true) + expect(sourceRegex.test('/feed/blog/a/b/c')).toBe(true) + + // Header should match /feed level + expect(headerRegex.test('/feed')).toBe(true) + expect(headerRegex.test('/feed/home')).toBe(true) + }) + + it('should intercept an optional catchall route with (..)', () => { + const rewrites = generateInterceptionRoutesRewrites([ + '/dashboard/settings/(..)docs/[[...path]]', + ]) + + expect(rewrites).toHaveLength(1) + const rewrite = rewrites[0] + + // Optional catchall gets * suffix + expect(rewrite.source).toBe('/dashboard/docs/:nxtPpath*') + expect(rewrite.destination).toBe( + '/dashboard/settings/(..)docs/:nxtPpath*' + ) + + const { sourceRegex, headerRegex } = getRewriteMatchers(rewrite) + expect(headerRegex.source).toMatchInlineSnapshot( + `"^\\/dashboard\\/settings$"` + ) + + // Source should match with 0 or more path segments + expect(sourceRegex.test('/dashboard/docs')).toBe(true) + expect(sourceRegex.test('/dashboard/docs/intro')).toBe(true) + expect(sourceRegex.test('/dashboard/docs/intro/getting-started')).toBe( + true + ) + + // Header should match intercepting route level + expect(headerRegex.test('/dashboard/settings')).toBe(true) + }) + + it('should handle (...) intercepting a catchall at root', () => { + const rewrites = generateInterceptionRoutesRewrites([ + '/app/dashboard/@modal/(...)docs/[...path]', + ]) + + expect(rewrites).toHaveLength(1) + const rewrite = rewrites[0] + + // Source is at root with catchall + expect(rewrite.source).toBe('/docs/:nxtPpath+') + + // Destination includes full path with @modal + expect(rewrite.destination).toBe( + '/app/dashboard/@modal/(...)docs/:nxtPpath+' + ) + + const { sourceRegex, headerRegex } = getRewriteMatchers(rewrite) + expect(headerRegex.source).toMatchInlineSnapshot( + `"^\\/app\\/dashboard(?:\\/)?$"` + ) + + // Source should match catchall paths + expect(sourceRegex.test('/docs/getting-started')).toBe(true) + expect(sourceRegex.test('/docs/api/reference')).toBe(true) + + // Header should match intercepting route level + expect(headerRegex.test('/app/dashboard')).toBe(true) + }) + }) + + describe('static segment special characters', () => { + it('should escape dots in static segments', () => { + const rewrites = generateInterceptionRoutesRewrites([ + '/api.v1/(.)endpoint.users', + ]) + + expect(rewrites).toHaveLength(1) + const rewrite = rewrites[0] + + expect(rewrite.source).toBe('/api.v1/endpoint.users') + expect(rewrite.destination).toBe('/api.v1/(.)endpoint.users') + + const { sourceRegex, headerRegex } = getRewriteMatchers(rewrite) + + // Dots should be escaped in the regex + expect(sourceRegex.test('/api.v1/endpoint.users')).toBe(true) + expect(sourceRegex.test('/apixv1/endpointxusers')).toBe(false) + + // Header regex should also escape dots + expect(headerRegex.test('/api.v1')).toBe(true) + expect(headerRegex.test('/apixv1')).toBe(false) + }) + + it('should handle hyphens and underscores in static segments', () => { + const rewrites = generateInterceptionRoutesRewrites([ + '/my-route_name/(.)my-nested_path', + ]) + + expect(rewrites).toHaveLength(1) + const rewrite = rewrites[0] + + expect(rewrite.source).toBe('/my-route_name/my-nested_path') + expect(rewrite.destination).toBe('/my-route_name/(.)my-nested_path') + + const { sourceRegex } = getRewriteMatchers(rewrite) + + // Hyphens and underscores should match literally + expect(sourceRegex.test('/my-route_name/my-nested_path')).toBe(true) + expect(sourceRegex.test('/myroutename/mynestedpath')).toBe(false) + }) + }) + + describe('basePath edge cases', () => { + it('should handle basePath with special characters', () => { + const rewrites = generateInterceptionRoutesRewrites( + ['/@slot/(.)nested'], + '/my-app.v1' + ) + + expect(rewrites).toHaveLength(1) + const rewrite = rewrites[0] + + // Source and destination should include basePath with special chars + expect(rewrite.source).toBe('/my-app.v1/nested') + expect(rewrite.destination).toBe('/my-app.v1/@slot/(.)nested') + + const { sourceRegex } = getRewriteMatchers(rewrite) + + // Should match with basePath + expect(sourceRegex.test('/my-app.v1/nested')).toBe(true) + expect(sourceRegex.test('/nested')).toBe(false) + expect(sourceRegex.test('/my-appxv1/nested')).toBe(false) + }) + + it('should handle deeply nested basePath', () => { + const rewrites = generateInterceptionRoutesRewrites( + ['/dashboard/(.)settings'], + '/app/v2/admin' + ) + + expect(rewrites).toHaveLength(1) + const rewrite = rewrites[0] + + expect(rewrite.source).toBe('/app/v2/admin/dashboard/settings') + expect(rewrite.destination).toBe('/app/v2/admin/dashboard/(.)settings') + + const { sourceRegex, headerRegex } = getRewriteMatchers(rewrite) + + // Source should include basePath + expect(sourceRegex.test('/app/v2/admin/dashboard/settings')).toBe(true) + expect(sourceRegex.test('/dashboard/settings')).toBe(false) + + // Header should NOT include basePath (per comment in code) + expect(headerRegex.test('/dashboard')).toBe(true) + expect(headerRegex.test('/dashboard/settings')).toBe(true) + }) + + it('should handle basePath with dynamic segments in path', () => { + const rewrites = generateInterceptionRoutesRewrites( + ['/[locale]/dashboard/(.)profile'], + '/base' + ) + + expect(rewrites).toHaveLength(1) + const rewrite = rewrites[0] + + expect(rewrite.source).toBe('/base/:nxtPlocale/dashboard/profile') + expect(rewrite.destination).toBe( + '/base/:nxtPlocale/dashboard/(.)profile' + ) + + const { sourceRegex, headerRegex } = getRewriteMatchers(rewrite) + + // Source should include basePath + expect(sourceRegex.test('/base/en/dashboard/profile')).toBe(true) + expect(sourceRegex.test('/en/dashboard/profile')).toBe(false) + + // Header should NOT include basePath but should have dynamic segment + expect(headerRegex.test('/en/dashboard')).toBe(true) + expect(headerRegex.test('/es/dashboard')).toBe(true) + }) + }) + + describe('parameter naming edge cases', () => { + it('should handle same parameter names in intercepting and intercepted routes', () => { + const rewrites = generateInterceptionRoutesRewrites([ + '/posts/[id]/(.)comments/[id]', + ]) + + expect(rewrites).toHaveLength(1) + const rewrite = rewrites[0] + + // Both [id] parameters should get unique prefixed names + expect(rewrite.source).toBe('/posts/:nxtPid/comments/:nxtPid') + expect(rewrite.destination).toBe('/posts/:nxtPid/(.)comments/:nxtPid') + + // Verify the regex has consistent parameter names + const regexParams = Array.from( + rewrite.regex!.matchAll(/\(\?<(\w+)>/g) + ).map((m) => m[1]) + + // Should have both id parameters with consistent naming + expect(regexParams).toContain('nxtPid') + }) + + it('should handle parameters with numbers and underscores', () => { + const rewrites = generateInterceptionRoutesRewrites([ + '/[user_id123]/(.)[post_id456]', + ]) + + expect(rewrites).toHaveLength(1) + const rewrite = rewrites[0] + + // Underscores should be preserved in parameter names + expect(rewrite.source).toBe('/:nxtPuser_id123/:nxtPpost_id456') + expect(rewrite.destination).toBe('/:nxtPuser_id123/(.):nxtPpost_id456') + + const { sourceRegex } = getRewriteMatchers(rewrite) + const match = sourceRegex.exec('/user123/post456') + + expect(match).toBeTruthy() + expect(match!.groups).toEqual({ + nxtPuser_id123: 'user123', + nxtPpost_id456: 'post456', + }) + }) + + it('should handle all-dynamic intercepting route path', () => { + const rewrites = generateInterceptionRoutesRewrites([ + '/[org]/[repo]/[branch]/(.)file/[path]', + ]) + + expect(rewrites).toHaveLength(1) + const rewrite = rewrites[0] + + expect(rewrite.source).toBe( + '/:nxtPorg/:nxtPrepo/:nxtPbranch/file/:nxtPpath' + ) + expect(rewrite.destination).toBe( + '/:nxtPorg/:nxtPrepo/:nxtPbranch/(.)file/:nxtPpath' + ) + + const { sourceRegex, headerRegex } = getRewriteMatchers(rewrite) + + // Source should match all dynamic segments + expect(sourceRegex.test('/vercel/next.js/canary/file/README.md')).toBe( + true + ) + + // Header should match the intercepting route with all dynamic segments + const match = headerRegex.exec('/vercel/next.js/canary') + expect(match).toBeTruthy() + expect(match![0]).toBe('/vercel/next.js/canary') + }) + + it('should handle consecutive dynamic segments', () => { + const rewrites = generateInterceptionRoutesRewrites([ + '/[a]/[b]/[c]/[d]/(.)photos', + ]) + + expect(rewrites).toHaveLength(1) + const rewrite = rewrites[0] + + expect(rewrite.source).toBe('/:nxtPa/:nxtPb/:nxtPc/:nxtPd/photos') + expect(rewrite.destination).toBe( + '/:nxtPa/:nxtPb/:nxtPc/:nxtPd/(.)photos' + ) + + const { sourceRegex, headerRegex } = getRewriteMatchers(rewrite) + + expect(sourceRegex.test('/1/2/3/4/photos')).toBe(true) + + // Header should match four consecutive dynamic segments + expect(headerRegex.test('/1/2/3/4')).toBe(true) + expect(headerRegex.test('/a/b/c/d')).toBe(true) + }) + }) + + describe('mixed catchall types in same path', () => { + it('should handle required and optional catchalls in same path', () => { + const rewrites = generateInterceptionRoutesRewrites([ + '/[[...locale]]/docs/[...path]/(.)modal', + ]) + + expect(rewrites).toHaveLength(1) + const rewrite = rewrites[0] + + // Optional catchall gets *, required gets + + // The 'docs' static segment is part of the intercepting route path + expect(rewrite.source).toBe('/:nxtPlocale*/docs/:nxtPpath+/modal') + expect(rewrite.destination).toBe( + '/:nxtPlocale*/docs/:nxtPpath+/(.)modal' + ) + + const { sourceRegex, headerRegex } = getRewriteMatchers(rewrite) + + // Source should match various combinations including 'docs' segment + expect(sourceRegex.test('/modal')).toBe(false) // Need docs segment + expect(sourceRegex.test('/docs/modal')).toBe(false) // path is required catchall (need at least one path segment) + expect(sourceRegex.test('/docs/intro/modal')).toBe(true) // locale=empty, path=intro + expect(sourceRegex.test('/en/docs/intro/modal')).toBe(true) // locale=en, path=intro + + // Header should handle optional catchall + expect(headerRegex.test('/docs/intro')).toBe(true) + expect(headerRegex.test('/en/docs/intro')).toBe(true) + }) + + it('should handle (..) with optional catchall intercepting required catchall', () => { + const rewrites = generateInterceptionRoutesRewrites([ + '/[[...locale]]/dashboard/(..)blog/[...slug]', + ]) + + expect(rewrites).toHaveLength(1) + const rewrite = rewrites[0] + + expect(rewrite.source).toBe('/:nxtPlocale*/blog/:nxtPslug+') + expect(rewrite.destination).toBe( + '/:nxtPlocale*/dashboard/(..)blog/:nxtPslug+' + ) + + const { sourceRegex, headerRegex } = getRewriteMatchers(rewrite) + + // Source should match with optional locale and required slug + expect(sourceRegex.test('/blog/post-1')).toBe(true) + expect(sourceRegex.test('/en/blog/post-1')).toBe(true) + expect(sourceRegex.test('/en/us/blog/2024/post-1')).toBe(true) + + // Header should match dashboard with optional locale + expect(headerRegex.test('/dashboard')).toBe(true) + expect(headerRegex.test('/en/dashboard')).toBe(true) + expect(headerRegex.test('/en/us/dashboard')).toBe(true) + }) + }) + }) +}) diff --git a/packages/next/src/lib/generate-interception-routes-rewrites.ts b/packages/next/src/lib/generate-interception-routes-rewrites.ts index 3bc28eee427ab..119b32a9de7bd 100644 --- a/packages/next/src/lib/generate-interception-routes-rewrites.ts +++ b/packages/next/src/lib/generate-interception-routes-rewrites.ts @@ -2,25 +2,234 @@ import { NEXT_URL } from '../client/components/app-router-headers' import { extractInterceptionRouteInformation, isInterceptionRouteAppPath, + INTERCEPTION_ROUTE_MARKERS, } from '../shared/lib/router/utils/interception-routes' import type { Rewrite } from './load-custom-routes' -import { safePathToRegexp } from '../shared/lib/router/utils/route-match-utils' import type { DeepReadonly } from '../shared/lib/deep-readonly' +import { getNamedRouteRegex } from '../shared/lib/router/utils/route-regex' +import { + getSegmentParam, + isCatchAll, +} from '../server/app-render/get-segment-param' +import { InvariantError } from '../shared/lib/invariant-error' +import { escapeStringRegexp } from '../shared/lib/escape-regexp' + +/** + * Detects which interception marker is used in the app path + */ +function getInterceptionMarker( + appPath: string +): (typeof INTERCEPTION_ROUTE_MARKERS)[number] | undefined { + for (const segment of appPath.split('/')) { + const marker = INTERCEPTION_ROUTE_MARKERS.find((m) => segment.startsWith(m)) + if (marker) { + return marker + } + } + return undefined +} + +/** + * Generates a regex pattern that matches routes at the same level as the intercepting route. + * For (.) same-level interception, we need to match: + * - The intercepting route itself + * - Any direct child of the intercepting route + * But NOT deeper nested routes + */ +function generateSameLevelHeaderRegex( + interceptingRoute: string, + reference: Record +): string { + // Build the pattern for matching the intercepting route and its direct children + const segments = + interceptingRoute === '/' + ? [] + : interceptingRoute.split('/').filter(Boolean) + + const patterns: string[] = [] + const optionalIndices: number[] = [] + + for (let i = 0; i < segments.length; i++) { + const segment = segments[i] + const param = getSegmentParam(segment) + if (param) { + // Dynamic segment - use named capture group + // Use the reference mapping which has the correct param -> prefixedKey mapping + const prefixedKey = reference[param.param] + if (!prefixedKey) { + throw new InvariantError( + `No reference found for param: ${param.param} in reference: ${JSON.stringify(reference)}` + ) + } + + // Check if this is a catchall (repeat) parameter + if (isCatchAll(param.type)) { + patterns.push(`(?<${prefixedKey}>.+?)`) + // Track optional catchall segments so we can wrap them later + if (param.type === 'optional-catchall') { + optionalIndices.push(i) + } + } else { + patterns.push(`(?<${prefixedKey}>[^/]+?)`) + } + } else { + // Static segment + patterns.push(escapeStringRegexp(segment)) + } + } + + // Build the header regex, wrapping optional catchall segments + let pattern = '' + for (let i = 0; i < patterns.length; i++) { + if (optionalIndices.includes(i)) { + // Optional catchall: wrap the segment with its leading / in an optional group + pattern += `(?:/${patterns[i]})?` + } else { + pattern += `/${patterns[i]}` + } + } + + // Match the pattern, optionally followed by a single segment, with optional trailing slash + // Note: Don't add ^ and $ anchors here - matchHas() will add them automatically + return `${pattern}(/[^/]+)?/?` +} + +/** + * Check if there's a catchall route sibling at the intercepting route level. + * For example, if interceptingRoute is '/templates', this checks for + * '/templates/[...catchAll]'. + */ +function hasCatchallSiblingAtLevel( + appPaths: string[], + interceptingRoute: string +): boolean { + const targetSegments = + interceptingRoute === '/' + ? [] + : interceptingRoute.split('/').filter(Boolean) + const targetDepth = targetSegments.length -// a function that converts normalised paths (e.g. /foo/[bar]/[baz]) to the format expected by pathToRegexp (e.g. /foo/:bar/:baz) -function toPathToRegexpPath(path: string): string { - return path.replace(/\[\[?([^\]]+)\]\]?/g, (_, capture) => { - // path-to-regexp only supports word characters, so we replace any non-word characters with underscores - const paramName = capture.replace(/\W+/g, '_') + return appPaths.some((path) => { + const segments = path.split('/').filter(Boolean) - // handle catch-all segments (e.g. /foo/bar/[...baz] or /foo/bar/[[...baz]]) - if (capture.startsWith('...')) { - return `:${capture.slice(3)}*` + // Check if this path is at the same depth + 1 (parent segments + the catchall segment) + if (segments.length !== targetDepth + 1) { + return false } - return ':' + paramName + + // Check if the first targetDepth segments match exactly + for (let i = 0; i < targetDepth; i++) { + // Skip interception routes + if ( + INTERCEPTION_ROUTE_MARKERS.some((marker) => + segments[i].startsWith(marker) + ) + ) { + return false + } + + if (segments[i] !== targetSegments[i]) { + return false + } + } + + // Check if the last segment is a catchall parameter + const lastSegment = segments[segments.length - 1] + const param = getSegmentParam(lastSegment) + return param !== null && isCatchAll(param.type) }) } +/** + * Generates the appropriate header regex based on the interception marker type. + * @param marker The interception route marker (e.g., '(.)', '(..)')) + * @param interceptingRoute The route that intercepts (e.g., '/templates') + * @param headerReference The reference mapping from param names to prefixed keys + * @param appPaths All app paths (used for catchall sibling detection) + * @param defaultHeaderRegex The default regex to use if no marker-specific logic applies + * @returns The header regex pattern to match against the Next-URL header + */ +function generateInterceptionHeaderRegex( + marker: (typeof INTERCEPTION_ROUTE_MARKERS)[number] | undefined, + interceptingRoute: string, + headerReference: Record, + appPaths: string[], + defaultHeaderRegex: string +): string { + // Generate the appropriate header regex based on the marker type + let headerRegex: string + if (marker === '(.)') { + // For same-level interception, match routes at the same level as the intercepting route + // Use header.reference which has the param -> prefixedKey mapping + headerRegex = generateSameLevelHeaderRegex( + interceptingRoute, + headerReference + ) + } else if (marker === '(..)') { + // For parent-level interception, match routes at the intercepting route level + // Check if there's a catchall sibling at the intercepting route level + const hasCatchallSibling = hasCatchallSiblingAtLevel( + appPaths, + interceptingRoute + ) + + // Build regex pattern that handles dynamic segments correctly + const patterns: string[] = [] + const optionalIndices: number[] = [] + + const segments = interceptingRoute.split('/').filter(Boolean) + for (let i = 0; i < segments.length; i++) { + const segment = segments[i] + const param = getSegmentParam(segment) + if (param) { + // Dynamic segment - use named capture group from header.reference + const key = headerReference[param.param] + if (!key) { + throw new InvariantError( + `No reference found for param: ${param.param} in reference: ${JSON.stringify(headerReference)}` + ) + } + + // Check if this is a catchall (repeat) parameter + if (isCatchAll(param.type)) { + patterns.push(`(?<${key}>.+?)`) + // Track optional catchall segments so we can wrap them later + if (param.type === 'optional-catchall') { + optionalIndices.push(i) + } + } else { + patterns.push(`(?<${key}>[^/]+?)`) + } + } else { + // Static segment + patterns.push(escapeStringRegexp(segment)) + } + } + + // Build the header regex, wrapping optional catchall segments + let headerPattern = '' + for (let i = 0; i < patterns.length; i++) { + if (optionalIndices.includes(i)) { + // Optional catchall: wrap the segment with its leading / in an optional group + headerPattern += `(?:/${patterns[i]})?` + } else { + headerPattern += `/${patterns[i]}` + } + } + + // Note: Don't add ^ and $ anchors - matchHas() will add them automatically + // If there's a catchall sibling, match the level and its children (catchall paths) + // Otherwise, only match the exact level + headerRegex = `${headerPattern}${hasCatchallSibling ? '(/.+)?' : ''}` + } else { + // For other markers, use the default behavior (match exact intercepting route) + // Strip ^ and $ anchors since matchHas() will add them automatically + headerRegex = defaultHeaderRegex + } + + return headerRegex +} + export function generateInterceptionRoutesRewrites( appPaths: string[], basePath = '' @@ -32,30 +241,57 @@ export function generateInterceptionRoutesRewrites( const { interceptingRoute, interceptedRoute } = extractInterceptionRouteInformation(appPath) - const normalizedInterceptingRoute = `${ - interceptingRoute !== '/' ? toPathToRegexpPath(interceptingRoute) : '' - }/(.*)?` + // Detect which marker is being used + const marker = getInterceptionMarker(appPath) - const normalizedInterceptedRoute = toPathToRegexpPath(interceptedRoute) - const normalizedAppPath = toPathToRegexpPath(appPath) + // The Next-Url header does not contain the base path, so just use the + // intercepting route. We don't handle duplicate keys here with the + // backreferenceDuplicateKeys option because it's not a valid pathname + // with them in this case. + const header = getNamedRouteRegex(interceptingRoute, { + prefixRouteKeys: true, + }) + + // The source is the intercepted route with the base path, it's matched by + // the router. Generate this first to get the correct parameter prefixes. + // We don't handle duplicate keys here with the backreferenceDuplicateKeys + // option because it's not a valid pathname with them in this case. + const source = getNamedRouteRegex(basePath + interceptedRoute, { + prefixRouteKeys: true, + }) + + // The destination should use the same parameter reference as the source + // so that parameter substitution works correctly. This ensures that when + // the router extracts params from the source, they can be substituted + // into the destination. We don't handle duplicate keys here with the + // backreferenceDuplicateKeys option because we don't use the regexp + // itself in this case, only the pathToRegexpPattern. + const destination = getNamedRouteRegex(basePath + appPath, { + prefixRouteKeys: true, + reference: source.reference, + }) - // pathToRegexp returns a regex that matches the path, but we need to - // convert it to a string that can be used in a header value - // to the format that Next/the proxy expects - let interceptingRouteRegex = safePathToRegexp(normalizedInterceptingRoute) - .toString() - .slice(2, -3) + // Generate the header regex based on the interception marker type + const headerRegex = generateInterceptionHeaderRegex( + marker, + interceptingRoute, + header.reference, + appPaths, + header.namedRegex.replace(/^\^/, '').replace(/\$$/, '') + ) rewrites.push({ - source: `${basePath}${normalizedInterceptedRoute}`, - destination: `${basePath}${normalizedAppPath}`, + source: source.pathToRegexpPattern, + destination: destination.pathToRegexpPattern, has: [ { type: 'header', key: NEXT_URL, - value: interceptingRouteRegex, + value: headerRegex, }, ], + internal: true, + regex: source.namedRegex, }) } } diff --git a/packages/next/src/lib/load-custom-routes.ts b/packages/next/src/lib/load-custom-routes.ts index 382e808cbb0e8..c13829ba79fdc 100644 --- a/packages/next/src/lib/load-custom-routes.ts +++ b/packages/next/src/lib/load-custom-routes.ts @@ -31,6 +31,11 @@ export type Rewrite = { * @internal - used internally for routing */ internal?: boolean + + /** + * @internal - used internally for routing + */ + regex?: string } export type Header = { diff --git a/packages/next/src/server/app-render/get-segment-param.tsx b/packages/next/src/server/app-render/get-segment-param.tsx index 4409110dde5b6..7dd25dffa33f0 100644 --- a/packages/next/src/server/app-render/get-segment-param.tsx +++ b/packages/next/src/server/app-render/get-segment-param.tsx @@ -43,3 +43,13 @@ export function getSegmentParam(segment: string): { return null } + +export function isCatchAll( + type: DynamicParamTypes +): type is 'catchall' | 'catchall-intercepted' | 'optional-catchall' { + return ( + type === 'catchall' || + type === 'catchall-intercepted' || + type === 'optional-catchall' + ) +} diff --git a/packages/next/src/server/lib/router-utils/resolve-routes.ts b/packages/next/src/server/lib/router-utils/resolve-routes.ts index 57a541de966cc..d55fb4669e968 100644 --- a/packages/next/src/server/lib/router-utils/resolve-routes.ts +++ b/packages/next/src/server/lib/router-utils/resolve-routes.ts @@ -4,7 +4,7 @@ import type { NextConfigComplete } from '../../config-shared' import type { RenderServer, initialize } from '../router-server' import type { PatchMatcher } from '../../../shared/lib/router/utils/path-match' import type { Redirect } from '../../../types' -import type { Header, Rewrite } from '../../../lib/load-custom-routes' +import type { Header } from '../../../lib/load-custom-routes' import type { UnwrapPromise } from '../../../lib/coalesced-function' import type { NextUrlWithParsedQuery } from '../../request-meta' @@ -42,12 +42,8 @@ import type { TLSSocket } from 'tls' import { NEXT_REWRITTEN_PATH_HEADER, NEXT_REWRITTEN_QUERY_HEADER, - NEXT_ROUTER_STATE_TREE_HEADER, RSC_HEADER, } from '../../../client/components/app-router-headers' -import { getSelectedParams } from '../../../client/components/router-reducer/compute-changed-path' -import { isInterceptionRouteRewrite } from '../../../lib/generate-interception-routes-rewrites' -import { parseAndValidateFlightRouterState } from '../../app-render/parse-and-validate-flight-router-state' const debug = setupDebug('next:router-server:resolve-routes') @@ -772,27 +768,6 @@ export function getResolveRoutes( if (route.destination) { let rewriteParams = params - try { - // An interception rewrite might reference a dynamic param for a route the user - // is currently on, which wouldn't be extractable from the matched route params. - // This attempts to extract the dynamic params from the provided router state. - if (isInterceptionRouteRewrite(route as Rewrite)) { - const stateHeader = req.headers[NEXT_ROUTER_STATE_TREE_HEADER] - - if (stateHeader) { - rewriteParams = { - ...getSelectedParams( - parseAndValidateFlightRouterState(stateHeader) - ), - ...params, - } - } - } - } catch (err) { - // this is a no-op -- we couldn't extract dynamic params from the provided router state, - // so we'll just use the params from the route matcher - } - const { parsedDestination } = prepareDestination({ appendParamsToQuery: true, destination: route.destination, diff --git a/packages/next/src/server/server-utils.ts b/packages/next/src/server/server-utils.ts index 09fe92c872ae7..c7c2bbef39014 100644 --- a/packages/next/src/server/server-utils.ts +++ b/packages/next/src/server/server-utils.ts @@ -27,10 +27,6 @@ import { decodeQueryPathParameter } from './lib/decode-query-path-parameter' import type { DeepReadonly } from '../shared/lib/deep-readonly' import { parseReqUrl } from '../lib/url' import { formatUrl } from '../shared/lib/router/utils/format-url' -import { parseAndValidateFlightRouterState } from './app-render/parse-and-validate-flight-router-state' -import { isInterceptionRouteRewrite } from '../lib/generate-interception-routes-rewrites' -import { NEXT_ROUTER_STATE_TREE_HEADER } from '../client/components/app-router-headers' -import { getSelectedParams } from '../client/components/router-reducer/compute-changed-path' function filterInternalQuery( query: Record, @@ -266,27 +262,6 @@ export function getServerUtils({ } if (params) { - try { - // An interception rewrite might reference a dynamic param for a route the user - // is currently on, which wouldn't be extractable from the matched route params. - // This attempts to extract the dynamic params from the provided router state. - if (isInterceptionRouteRewrite(rewrite as Rewrite)) { - const stateHeader = req.headers[NEXT_ROUTER_STATE_TREE_HEADER] - - if (stateHeader) { - params = { - ...getSelectedParams( - parseAndValidateFlightRouterState(stateHeader) - ), - ...params, - } - } - } - } catch (err) { - // this is a no-op -- we couldn't extract dynamic params from the provided router state, - // so we'll just use the params from the route matcher - } - const { parsedDestination, destQuery } = prepareDestination({ appendParamsToQuery: true, destination: rewrite.destination, @@ -303,20 +278,6 @@ export function getServerUtils({ Object.assign(rewrittenParsedUrl.query, parsedDestination.query) delete (parsedDestination as any).query - // for each property in rewrittenParsedUrl.query, if the value is parametrized (eg :foo), look up the value - // in rewriteParams and replace the parametrized value with the actual value - // this is used when the rewrite destination does not contain the original source param - // and so the value is still parametrized and needs to be replaced with the actual rewrite param - Object.entries(rewrittenParsedUrl.query).forEach(([key, value]) => { - if (value && typeof value === 'string' && value.startsWith(':')) { - const paramName = value.slice(1) - const actualValue = rewriteParams[paramName] - if (actualValue) { - rewrittenParsedUrl.query[key] = actualValue - } - } - }) - Object.assign(rewrittenParsedUrl, parsedDestination) fsPathname = rewrittenParsedUrl.pathname diff --git a/packages/next/src/shared/lib/app-router-context.shared-runtime.ts b/packages/next/src/shared/lib/app-router-context.shared-runtime.ts index 9dca0e629a665..c1aa54e39b3ce 100644 --- a/packages/next/src/shared/lib/app-router-context.shared-runtime.ts +++ b/packages/next/src/shared/lib/app-router-context.shared-runtime.ts @@ -68,6 +68,7 @@ export const GlobalLayoutRouterContext = React.createContext<{ tree: FlightRouterState focusAndScrollRef: FocusAndScrollRef nextUrl: string | null + previousNextUrl: string | null }>(null as any) export const TemplateContext = React.createContext(null as any) diff --git a/packages/next/src/shared/lib/router/utils/route-regex.test.ts b/packages/next/src/shared/lib/router/utils/route-regex.test.ts index c6c826eb62f4d..045fd9691d490 100644 --- a/packages/next/src/shared/lib/router/utils/route-regex.test.ts +++ b/packages/next/src/shared/lib/router/utils/route-regex.test.ts @@ -1,5 +1,24 @@ import { getNamedRouteRegex } from './route-regex' import { parseParameter } from './get-dynamic-param' +import { pathToRegexp } from 'next/dist/compiled/path-to-regexp' + +/** + * Helper function to compile a pathToRegexpPattern from a route and test it against paths + */ +function compilePattern( + route: string, + options: Parameters[1] +) { + const regex = getNamedRouteRegex(route, options) + + const compiled = pathToRegexp(regex.pathToRegexpPattern, [], { + strict: true, + sensitive: false, + delimiter: '/', + }) + + return { regex, compiled } +} describe('getNamedRouteRegex', () => { it('should handle interception markers adjacent to dynamic path segments', () => { @@ -7,22 +26,33 @@ describe('getNamedRouteRegex', () => { prefixRouteKeys: true, }) - expect(regex.routeKeys).toEqual({ - nxtIauthor: 'nxtIauthor', - nxtPid: 'nxtPid', - }) - - expect(regex.groups['author']).toEqual({ - pos: 1, - repeat: false, - optional: false, - }) - - expect(regex.groups['id']).toEqual({ - pos: 2, - repeat: false, - optional: false, - }) + expect(regex).toMatchInlineSnapshot(` + { + "groups": { + "author": { + "optional": false, + "pos": 1, + "repeat": false, + }, + "id": { + "optional": false, + "pos": 2, + "repeat": false, + }, + }, + "namedRegex": "^/photos/\\(\\.\\)(?[^/]+?)/(?[^/]+?)(?:/)?$", + "pathToRegexpPattern": "/photos/(.):nxtIauthor/:nxtPid", + "re": /\\^\\\\/photos\\\\/\\\\\\(\\\\\\.\\\\\\)\\(\\[\\^/\\]\\+\\?\\)\\\\/\\(\\[\\^/\\]\\+\\?\\)\\(\\?:\\\\/\\)\\?\\$/, + "reference": { + "author": "nxtIauthor", + "id": "nxtPid", + }, + "routeKeys": { + "nxtIauthor": "nxtIauthor", + "nxtPid": "nxtPid", + }, + } + `) expect(regex.re.exec('/photos/(.)next/123')).toMatchInlineSnapshot(` [ @@ -37,6 +67,35 @@ describe('getNamedRouteRegex', () => { let regex = getNamedRouteRegex('/(.)[author]/[id]', { prefixRouteKeys: true, }) + + expect(regex).toMatchInlineSnapshot(` + { + "groups": { + "author": { + "optional": false, + "pos": 1, + "repeat": false, + }, + "id": { + "optional": false, + "pos": 2, + "repeat": false, + }, + }, + "namedRegex": "^/\\(\\.\\)(?[^/]+?)/(?[^/]+?)(?:/)?$", + "pathToRegexpPattern": "/(.):nxtIauthor/:nxtPid", + "re": /\\^\\\\/\\\\\\(\\\\\\.\\\\\\)\\(\\[\\^/\\]\\+\\?\\)\\\\/\\(\\[\\^/\\]\\+\\?\\)\\(\\?:\\\\/\\)\\?\\$/, + "reference": { + "author": "nxtIauthor", + "id": "nxtPid", + }, + "routeKeys": { + "nxtIauthor": "nxtIauthor", + "nxtPid": "nxtPid", + }, + } + `) + let namedRegexp = new RegExp(regex.namedRegex) expect(namedRegexp.test('/[author]/[id]')).toBe(false) expect(namedRegexp.test('/(.)[author]/[id]')).toBe(true) @@ -44,9 +103,35 @@ describe('getNamedRouteRegex', () => { regex = getNamedRouteRegex('/(..)(..)[author]/[id]', { prefixRouteKeys: true, }) - expect(regex.namedRegex).toMatchInlineSnapshot( - `"^/\\(\\.\\.\\)\\(\\.\\.\\)(?[^/]+?)/(?[^/]+?)(?:/)?$"` - ) + + expect(regex).toMatchInlineSnapshot(` + { + "groups": { + "author": { + "optional": false, + "pos": 1, + "repeat": false, + }, + "id": { + "optional": false, + "pos": 2, + "repeat": false, + }, + }, + "namedRegex": "^/\\(\\.\\.\\)\\(\\.\\.\\)(?[^/]+?)/(?[^/]+?)(?:/)?$", + "pathToRegexpPattern": "/(..)(..):nxtIauthor/:nxtPid", + "re": /\\^\\\\/\\\\\\(\\\\\\.\\\\\\.\\\\\\)\\\\\\(\\\\\\.\\\\\\.\\\\\\)\\(\\[\\^/\\]\\+\\?\\)\\\\/\\(\\[\\^/\\]\\+\\?\\)\\(\\?:\\\\/\\)\\?\\$/, + "reference": { + "author": "nxtIauthor", + "id": "nxtPid", + }, + "routeKeys": { + "nxtIauthor": "nxtIauthor", + "nxtPid": "nxtPid", + }, + } + `) + namedRegexp = new RegExp(regex.namedRegex) expect(namedRegexp.test('/[author]/[id]')).toBe(false) expect(namedRegexp.test('/(..)(..)[author]/[id]')).toBe(true) @@ -57,26 +142,33 @@ describe('getNamedRouteRegex', () => { prefixRouteKeys: true, }) - expect(regex.routeKeys).toEqual({ - nxtIauthor: 'nxtIauthor', - nxtPid: 'nxtPid', - }) - - expect(regex.groups['author']).toEqual({ - pos: 1, - repeat: false, - optional: false, - }) - - expect(regex.groups['id']).toEqual({ - pos: 2, - repeat: false, - optional: false, - }) - - expect(regex.re.source).toMatchInlineSnapshot( - `"^\\/photos\\/\\(\\.\\.\\)\\(\\.\\.\\)([^/]+?)\\/([^/]+?)(?:\\/)?$"` - ) + expect(regex).toMatchInlineSnapshot(` + { + "groups": { + "author": { + "optional": false, + "pos": 1, + "repeat": false, + }, + "id": { + "optional": false, + "pos": 2, + "repeat": false, + }, + }, + "namedRegex": "^/photos/\\(\\.\\.\\)\\(\\.\\.\\)(?[^/]+?)/(?[^/]+?)(?:/)?$", + "pathToRegexpPattern": "/photos/(..)(..):nxtIauthor/:nxtPid", + "re": /\\^\\\\/photos\\\\/\\\\\\(\\\\\\.\\\\\\.\\\\\\)\\\\\\(\\\\\\.\\\\\\.\\\\\\)\\(\\[\\^/\\]\\+\\?\\)\\\\/\\(\\[\\^/\\]\\+\\?\\)\\(\\?:\\\\/\\)\\?\\$/, + "reference": { + "author": "nxtIauthor", + "id": "nxtPid", + }, + "routeKeys": { + "nxtIauthor": "nxtIauthor", + "nxtPid": "nxtPid", + }, + } + `) expect(regex.re.exec('/photos/(..)(..)next/123')).toMatchInlineSnapshot(` [ @@ -88,27 +180,45 @@ describe('getNamedRouteRegex', () => { }) it('should not remove extra parts beside the param segments', () => { - const { re, namedRegex, routeKeys } = getNamedRouteRegex( + const regex = getNamedRouteRegex( '/[locale]/about.segments/[...segmentPath].segment.rsc', { prefixRouteKeys: true, includeSuffix: true, } ) - expect(routeKeys).toEqual({ - nxtPlocale: 'nxtPlocale', - nxtPsegmentPath: 'nxtPsegmentPath', - }) - expect(namedRegex).toMatchInlineSnapshot( - `"^/(?[^/]+?)/about\\.segments/(?.+?)\\.segment\\.rsc(?:/)?$"` - ) - expect(re.source).toMatchInlineSnapshot( - `"^\\/([^/]+?)\\/about\\.segments\\/(.+?)\\.segment\\.rsc(?:\\/)?$"` - ) + + expect(regex).toMatchInlineSnapshot(` + { + "groups": { + "locale": { + "optional": false, + "pos": 1, + "repeat": false, + }, + "segmentPath": { + "optional": false, + "pos": 2, + "repeat": true, + }, + }, + "namedRegex": "^/(?[^/]+?)/about\\.segments/(?.+?)\\.segment\\.rsc(?:/)?$", + "pathToRegexpPattern": "/:nxtPlocale/about.segments/:nxtPsegmentPath+.segment.rsc", + "re": /\\^\\\\/\\(\\[\\^/\\]\\+\\?\\)\\\\/about\\\\\\.segments\\\\/\\(\\.\\+\\?\\)\\\\\\.segment\\\\\\.rsc\\(\\?:\\\\/\\)\\?\\$/, + "reference": { + "locale": "nxtPlocale", + "segmentPath": "nxtPsegmentPath", + }, + "routeKeys": { + "nxtPlocale": "nxtPlocale", + "nxtPsegmentPath": "nxtPsegmentPath", + }, + } + `) }) it('should not remove extra parts in front of the param segments', () => { - const { re, namedRegex, routeKeys } = getNamedRouteRegex( + const regex = getNamedRouteRegex( '/[locale]/about.segments/$dname$d[name].segment.rsc', { prefixRouteKeys: true, @@ -116,18 +226,36 @@ describe('getNamedRouteRegex', () => { includePrefix: true, } ) - expect(routeKeys).toEqual({ - nxtPlocale: 'nxtPlocale', - nxtPname: 'nxtPname', - }) - expect(namedRegex).toEqual( - '^/(?[^/]+?)/about\\.segments/\\$dname\\$d(?[^/]+?)\\.segment\\.rsc(?:/)?$' - ) - expect(re.source).toEqual( - '^\\/([^/]+?)\\/about\\.segments\\/\\$dname\\$d([^/]+?)\\.segment\\.rsc(?:\\/)?$' - ) - expect('/en/about.segments/$dname$dwyatt.segment.rsc'.match(re)) + expect(regex).toMatchInlineSnapshot(` + { + "groups": { + "locale": { + "optional": false, + "pos": 1, + "repeat": false, + }, + "name": { + "optional": false, + "pos": 2, + "repeat": false, + }, + }, + "namedRegex": "^/(?[^/]+?)/about\\.segments/\\$dname\\$d(?[^/]+?)\\.segment\\.rsc(?:/)?$", + "pathToRegexpPattern": "/:nxtPlocale/about.segments/$dname$d/:nxtPname.segment.rsc", + "re": /\\^\\\\/\\(\\[\\^/\\]\\+\\?\\)\\\\/about\\\\\\.segments\\\\/\\\\\\$dname\\\\\\$d\\(\\[\\^/\\]\\+\\?\\)\\\\\\.segment\\\\\\.rsc\\(\\?:\\\\/\\)\\?\\$/, + "reference": { + "locale": "nxtPlocale", + "name": "nxtPname", + }, + "routeKeys": { + "nxtPlocale": "nxtPlocale", + "nxtPname": "nxtPname", + }, + } + `) + + expect('/en/about.segments/$dname$dwyatt.segment.rsc'.match(regex.re)) .toMatchInlineSnapshot(` [ "/en/about.segments/$dname$dwyatt.segment.rsc", @@ -142,21 +270,26 @@ describe('getNamedRouteRegex', () => { prefixRouteKeys: true, }) - expect(regex.namedRegex).toMatchInlineSnapshot( - `"^/photos/\\(\\.\\)author/(?[^/]+?)(?:/)?$"` - ) - - expect(regex.routeKeys).toEqual({ - nxtPid: 'nxtPid', - }) - - expect(regex.groups['author']).toBeUndefined() - - expect(regex.groups['id']).toEqual({ - pos: 1, - repeat: false, - optional: false, - }) + expect(regex).toMatchInlineSnapshot(` + { + "groups": { + "id": { + "optional": false, + "pos": 1, + "repeat": false, + }, + }, + "namedRegex": "^/photos/\\(\\.\\)author/(?[^/]+?)(?:/)?$", + "pathToRegexpPattern": "/photos/(.)author/:nxtPid", + "re": /\\^\\\\/photos\\\\/\\\\\\(\\\\\\.\\\\\\)author\\\\/\\(\\[\\^/\\]\\+\\?\\)\\(\\?:\\\\/\\)\\?\\$/, + "reference": { + "id": "nxtPid", + }, + "routeKeys": { + "nxtPid": "nxtPid", + }, + } + `) expect(regex.re.exec('/photos/(.)author/123')).toMatchInlineSnapshot(` [ @@ -171,6 +304,27 @@ describe('getNamedRouteRegex', () => { prefixRouteKeys: true, }) + expect(regex).toMatchInlineSnapshot(` + { + "groups": { + "id": { + "optional": true, + "pos": 1, + "repeat": false, + }, + }, + "namedRegex": "^/photos(?:/(?[^/]+?))?(?:/)?$", + "pathToRegexpPattern": "/photos/:nxtPid", + "re": /\\^\\\\/photos\\\\/\\(\\[\\^/\\]\\+\\?\\)\\(\\?:\\\\/\\)\\?\\$/, + "reference": { + "id": "nxtPid", + }, + "routeKeys": { + "nxtPid": "nxtPid", + }, + } + `) + expect(regex.routeKeys).toEqual({ nxtPid: 'nxtPid', }) @@ -187,15 +341,26 @@ describe('getNamedRouteRegex', () => { prefixRouteKeys: true, }) - expect(regex.routeKeys).toEqual({ - nxtPid: 'nxtPid', - }) - - expect(regex.groups['id']).toEqual({ - pos: 1, - repeat: true, - optional: true, - }) + expect(regex).toMatchInlineSnapshot(` + { + "groups": { + "id": { + "optional": true, + "pos": 1, + "repeat": true, + }, + }, + "namedRegex": "^/photos(?:/(?.+?))?(?:/)?$", + "pathToRegexpPattern": "/photos/:nxtPid*", + "re": /\\^\\\\/photos\\(\\?:\\\\/\\(\\.\\+\\?\\)\\)\\?\\(\\?:\\\\/\\)\\?\\$/, + "reference": { + "id": "nxtPid", + }, + "routeKeys": { + "nxtPid": "nxtPid", + }, + } + `) expect(regex.re.exec('/photos/1')).toMatchInlineSnapshot(` [ @@ -218,6 +383,536 @@ describe('getNamedRouteRegex', () => { }) }) +describe('getNamedRouteRegex - Parameter Sanitization', () => { + it('should sanitize parameter names with hyphens', () => { + const regex = getNamedRouteRegex('/[foo-bar]/page', { + prefixRouteKeys: true, + }) + + expect(regex).toMatchInlineSnapshot(` + { + "groups": { + "foo-bar": { + "optional": false, + "pos": 1, + "repeat": false, + }, + }, + "namedRegex": "^/(?[^/]+?)/page(?:/)?$", + "pathToRegexpPattern": "/:nxtPfoobar/page", + "re": /\\^\\\\/\\(\\[\\^/\\]\\+\\?\\)\\\\/page\\(\\?:\\\\/\\)\\?\\$/, + "reference": { + "foo-bar": "nxtPfoobar", + }, + "routeKeys": { + "nxtPfoobar": "nxtPfoo-bar", + }, + } + `) + }) + + it('should sanitize parameter names with underscores', () => { + const regex = getNamedRouteRegex('/[foo_id]/page', { + prefixRouteKeys: true, + }) + + // Underscores should be removed from parameter names + expect(regex).toMatchInlineSnapshot(` + { + "groups": { + "foo_id": { + "optional": false, + "pos": 1, + "repeat": false, + }, + }, + "namedRegex": "^/(?[^/]+?)/page(?:/)?$", + "pathToRegexpPattern": "/:nxtPfoo_id/page", + "re": /\\^\\\\/\\(\\[\\^/\\]\\+\\?\\)\\\\/page\\(\\?:\\\\/\\)\\?\\$/, + "reference": { + "foo_id": "nxtPfoo_id", + }, + "routeKeys": { + "nxtPfoo_id": "nxtPfoo_id", + }, + } + `) + }) + + it('should handle parameters with multiple special characters', () => { + const regex = getNamedRouteRegex('/[this-is_my-route]/page', { + prefixRouteKeys: true, + }) + + // Special characters are removed for the sanitized key, but routeKeys maps back to original + expect(regex).toMatchInlineSnapshot(` + { + "groups": { + "this-is_my-route": { + "optional": false, + "pos": 1, + "repeat": false, + }, + }, + "namedRegex": "^/(?[^/]+?)/page(?:/)?$", + "pathToRegexpPattern": "/:nxtPthisis_myroute/page", + "re": /\\^\\\\/\\(\\[\\^/\\]\\+\\?\\)\\\\/page\\(\\?:\\\\/\\)\\?\\$/, + "reference": { + "this-is_my-route": "nxtPthisis_myroute", + }, + "routeKeys": { + "nxtPthisis_myroute": "nxtPthis-is_my-route", + }, + } + `) + }) + + it('should generate safe keys for invalid parameter names', () => { + // Parameter name that starts with a number gets the prefix but keeps numbers + const regex1 = getNamedRouteRegex('/[123invalid]/page', { + prefixRouteKeys: true, + }) + + // Numbers at the start cause fallback, but with prefix it becomes valid + expect(Object.keys(regex1.routeKeys)).toHaveLength(1) + const key1 = Object.keys(regex1.routeKeys)[0] + // With prefixRouteKeys, the nxtP prefix makes it valid even with leading numbers + expect(key1).toMatch(/^nxtP123invalid$/) + + // Parameter name that's too long (>30 chars) triggers fallback + const longName = 'a'.repeat(35) + const regex2 = getNamedRouteRegex(`/[${longName}]/page`, { + prefixRouteKeys: true, + }) + + // Should fall back to generated safe key + expect(Object.keys(regex2.routeKeys)).toHaveLength(1) + const key2 = Object.keys(regex2.routeKeys)[0] + // Fallback keys are just lowercase letters + expect(key2).toMatch(/^[a-z]+$/) + expect(key2.length).toBeLessThanOrEqual(30) + }) +}) + +describe('getNamedRouteRegex - Reference Mapping', () => { + it('should use provided reference for parameter mapping', () => { + // First call establishes the reference + const regex1 = getNamedRouteRegex('/[lang]/photos', { + prefixRouteKeys: true, + }) + + // Second call uses the reference from the first + const regex2 = getNamedRouteRegex('/[lang]/photos/[id]', { + prefixRouteKeys: true, + reference: regex1.reference, + }) + + // Both should use the same prefixed key for 'lang' + expect(regex1.reference.lang).toBe(regex2.reference.lang) + expect(regex2.reference.lang).toBe('nxtPlang') + + // New parameter should be added to the reference + expect(regex2.reference.id).toBe('nxtPid') + }) + + it('should maintain reference consistency across multiple paths', () => { + const baseRegex = getNamedRouteRegex('/[locale]/example', { + prefixRouteKeys: true, + }) + + const interceptedRegex = getNamedRouteRegex('/[locale]/intercepted', { + prefixRouteKeys: true, + reference: baseRegex.reference, + }) + + // Same parameter name should map to same prefixed key + expect(baseRegex.reference.locale).toBe(interceptedRegex.reference.locale) + expect(interceptedRegex.reference.locale).toBe('nxtPlocale') + }) + + it('should generate inverse pattern with correct parameter references', () => { + const regex = getNamedRouteRegex('/[lang]/posts/[id]', { + prefixRouteKeys: true, + }) + + // Inverse pattern should use the same prefixed keys + expect(regex.pathToRegexpPattern).toBe('/:nxtPlang/posts/:nxtPid') + + // And they should match the routeKeys + expect(regex.routeKeys.nxtPlang).toBe('nxtPlang') + expect(regex.routeKeys.nxtPid).toBe('nxtPid') + }) +}) + +describe('getNamedRouteRegex - Duplicate Keys', () => { + it('should handle duplicate parameters with backreferences', () => { + const regex = getNamedRouteRegex('/[id]/posts/[id]', { + prefixRouteKeys: true, + backreferenceDuplicateKeys: true, + }) + + // Should have only one key, named regex should contain a backreference for + // the second occurrence + expect(regex).toMatchInlineSnapshot(` + { + "groups": { + "id": { + "optional": false, + "pos": 2, + "repeat": false, + }, + }, + "namedRegex": "^/(?[^/]+?)/posts/\\k(?:/)?$", + "pathToRegexpPattern": "/:nxtPid/posts/:nxtPid", + "re": /\\^\\\\/\\(\\[\\^/\\]\\+\\?\\)\\\\/posts\\\\/\\(\\[\\^/\\]\\+\\?\\)\\(\\?:\\\\/\\)\\?\\$/, + "reference": { + "id": "nxtPid", + }, + "routeKeys": { + "nxtPid": "nxtPid", + }, + } + `) + }) + + it('should handle duplicate parameters without backreferences', () => { + const regex = getNamedRouteRegex('/[id]/posts/[id]', { + prefixRouteKeys: true, + backreferenceDuplicateKeys: false, + }) + + // Should still have only one key, but no backreference in the pattern. + expect(regex).toMatchInlineSnapshot(` + { + "groups": { + "id": { + "optional": false, + "pos": 2, + "repeat": false, + }, + }, + "namedRegex": "^/(?[^/]+?)/posts/(?[^/]+?)(?:/)?$", + "pathToRegexpPattern": "/:nxtPid/posts/:nxtPid", + "re": /\\^\\\\/\\(\\[\\^/\\]\\+\\?\\)\\\\/posts\\\\/\\(\\[\\^/\\]\\+\\?\\)\\(\\?:\\\\/\\)\\?\\$/, + "reference": { + "id": "nxtPid", + }, + "routeKeys": { + "nxtPid": "nxtPid", + }, + } + `) + }) +}) + +describe('getNamedRouteRegex - Complex Paths', () => { + it('should handle paths with multiple dynamic segments', () => { + const regex = getNamedRouteRegex('/[org]/[repo]/[branch]/[...path]', { + prefixRouteKeys: true, + }) + + expect(regex).toMatchInlineSnapshot(` + { + "groups": { + "branch": { + "optional": false, + "pos": 3, + "repeat": false, + }, + "org": { + "optional": false, + "pos": 1, + "repeat": false, + }, + "path": { + "optional": false, + "pos": 4, + "repeat": true, + }, + "repo": { + "optional": false, + "pos": 2, + "repeat": false, + }, + }, + "namedRegex": "^/(?[^/]+?)/(?[^/]+?)/(?[^/]+?)/(?.+?)(?:/)?$", + "pathToRegexpPattern": "/:nxtPorg/:nxtPrepo/:nxtPbranch/:nxtPpath+", + "re": /\\^\\\\/\\(\\[\\^/\\]\\+\\?\\)\\\\/\\(\\[\\^/\\]\\+\\?\\)\\\\/\\(\\[\\^/\\]\\+\\?\\)\\\\/\\(\\.\\+\\?\\)\\(\\?:\\\\/\\)\\?\\$/, + "reference": { + "branch": "nxtPbranch", + "org": "nxtPorg", + "path": "nxtPpath", + "repo": "nxtPrepo", + }, + "routeKeys": { + "nxtPbranch": "nxtPbranch", + "nxtPorg": "nxtPorg", + "nxtPpath": "nxtPpath", + "nxtPrepo": "nxtPrepo", + }, + } + `) + + // Test actual matching + const match = regex.re.exec('/vercel/next.js/canary/docs/api/reference') + expect(match).toBeTruthy() + expect(match![0]).toBe('/vercel/next.js/canary/docs/api/reference') + expect(match![1]).toBe('vercel') + expect(match![2]).toBe('next.js') + expect(match![3]).toBe('canary') + expect(match![4]).toBe('docs/api/reference') + }) + + it('should mark optional segments correctly', () => { + // Optional segments are marked as optional in the groups + const regex = getNamedRouteRegex('/posts/[[slug]]', { + prefixRouteKeys: true, + }) + + expect(regex.routeKeys).toEqual({ + nxtPslug: 'nxtPslug', + }) + + expect(regex.groups).toEqual({ + slug: { pos: 1, repeat: false, optional: true }, + }) + + // Regex should include optional pattern + expect(regex.namedRegex).toContain('?') + }) + + it('should handle all interception markers', () => { + const markers = ['(.)', '(..)', '(..)(..)', '(...)'] + + for (const marker of markers) { + const regex = getNamedRouteRegex(`/photos/${marker}[id]`, { + prefixRouteKeys: true, + }) + + // Should use interception prefix + expect(regex.routeKeys).toEqual({ + nxtIid: 'nxtIid', + }) + + // Should escape the marker in the regex + const escapedMarker = marker.replace(/[().]/g, '\\$&') + expect(regex.namedRegex).toContain(escapedMarker) + } + }) +}) + +describe('getNamedRouteRegex - Trailing Slash Behavior', () => { + it('should include optional trailing slash by default', () => { + const regex = getNamedRouteRegex('/posts/[id]', { + prefixRouteKeys: true, + }) + + // Should end with optional trailing slash + expect(regex).toMatchInlineSnapshot(` + { + "groups": { + "id": { + "optional": false, + "pos": 1, + "repeat": false, + }, + }, + "namedRegex": "^/posts/(?[^/]+?)(?:/)?$", + "pathToRegexpPattern": "/posts/:nxtPid", + "re": /\\^\\\\/posts\\\\/\\(\\[\\^/\\]\\+\\?\\)\\(\\?:\\\\/\\)\\?\\$/, + "reference": { + "id": "nxtPid", + }, + "routeKeys": { + "nxtPid": "nxtPid", + }, + } + `) + + // Should match both with and without trailing slash + const namedRe = new RegExp(regex.namedRegex) + expect(namedRe.test('/posts/123')).toBe(true) + expect(namedRe.test('/posts/123/')).toBe(true) + }) + + it('should exclude optional trailing slash when specified', () => { + const regex = getNamedRouteRegex('/posts/[id]', { + prefixRouteKeys: true, + excludeOptionalTrailingSlash: true, + }) + + // Should NOT have optional trailing slash + expect(regex.namedRegex).not.toMatch(/\(\?:\/\)\?\$/) + expect(regex.namedRegex).toMatch(/\$/) + + // Should still match without trailing slash + const namedRe = new RegExp(regex.namedRegex) + expect(namedRe.test('/posts/123')).toBe(true) + }) +}) + +describe('getNamedRouteRegex - Edge Cases', () => { + it('should handle root route', () => { + const regex = getNamedRouteRegex('/', { + prefixRouteKeys: true, + }) + + expect(regex.routeKeys).toEqual({}) + expect(regex.groups).toEqual({}) + expect(regex.namedRegex).toMatch(/^\^\//) + }) + + it('should handle route with only interception marker', () => { + const regex = getNamedRouteRegex('/(.)nested', { + prefixRouteKeys: true, + }) + + // No dynamic segments + expect(regex.routeKeys).toEqual({}) + + // Should escape the marker + expect(regex.namedRegex).toContain('\\(\\.\\)') + }) + + it('should handle interception marker followed by catchall segment', () => { + // Interception marker must be followed by a segment name, then catchall + const regex = getNamedRouteRegex('/photos/(.)images/[...path]', { + prefixRouteKeys: true, + }) + + expect(regex.routeKeys).toEqual({ + nxtPpath: 'nxtPpath', + }) + + expect(regex.groups.path).toEqual({ + pos: 1, + repeat: true, + optional: false, + }) + + // Should match multiple segments after the static segment + expect(regex.re.test('/photos/(.)images/a')).toBe(true) + expect(regex.re.test('/photos/(.)images/a/b/c')).toBe(true) + }) + + it('should handle dynamic segment with interception marker prefix', () => { + // Interception marker can be adjacent to dynamic segment + const regex = getNamedRouteRegex('/photos/(.)[id]', { + prefixRouteKeys: true, + }) + + expect(regex.routeKeys).toEqual({ + nxtIid: 'nxtIid', + }) + + expect(regex.groups.id).toEqual({ + pos: 1, + repeat: false, + optional: false, + }) + + // Should match single segment after the marker + expect(regex.re.test('/photos/(.)123')).toBe(true) + }) + + it('should handle prefix and suffix options together', () => { + const regex = getNamedRouteRegex('/api.v1/users.$type$[id].json', { + prefixRouteKeys: true, + includePrefix: true, + includeSuffix: true, + }) + + // Should preserve prefix and suffix in regex + expect(regex.namedRegex).toContain('\\$type\\$') + expect(regex.namedRegex).toContain('\\.json') + + // Test matching + const namedRe = new RegExp(regex.namedRegex) + expect(namedRe.test('/api.v1/users.$type$123.json')).toBe(true) + }) + + it('should generate correct inverse pattern for complex routes', () => { + const regex = getNamedRouteRegex('/[org]/@modal/(..)photo/[id]', { + prefixRouteKeys: true, + }) + + // When interception marker is not adjacent to a parameter, the [id] uses regular prefix + expect(regex.pathToRegexpPattern).toBe('/:nxtPorg/@modal/(..)photo/:nxtPid') + + // routeKeys should have both parameters with appropriate prefixes + expect(regex.routeKeys).toEqual({ + nxtPorg: 'nxtPorg', + nxtPid: 'nxtPid', + }) + }) + + it('should handle path with multiple separate segments', () => { + // Dynamic segments need to be separated by slashes + const regex = getNamedRouteRegex('/[org]/[repo]/[branch]', { + prefixRouteKeys: true, + }) + + expect(regex.routeKeys).toEqual({ + nxtPorg: 'nxtPorg', + nxtPrepo: 'nxtPrepo', + nxtPbranch: 'nxtPbranch', + }) + + // Each segment is captured separately + const match = regex.re.exec('/vercel/next.js/canary') + expect(match).toBeTruthy() + expect(match![1]).toBe('vercel') + expect(match![2]).toBe('next.js') + expect(match![3]).toBe('canary') + }) +}) + +describe('getNamedRouteRegex - Named Capture Groups', () => { + it('should extract values using named capture groups', () => { + const regex = getNamedRouteRegex('/posts/[category]/[id]', { + prefixRouteKeys: true, + }) + + const namedRe = new RegExp(regex.namedRegex) + const match = namedRe.exec('/posts/tech/123') + + expect(match).toBeTruthy() + expect(match?.groups).toEqual({ + nxtPcategory: 'tech', + nxtPid: '123', + }) + }) + + it('should extract values with interception markers', () => { + const regex = getNamedRouteRegex('/photos/(.)[author]/[id]', { + prefixRouteKeys: true, + }) + + const namedRe = new RegExp(regex.namedRegex) + const match = namedRe.exec('/photos/(.)john/123') + + expect(match).toBeTruthy() + expect(match?.groups).toEqual({ + nxtIauthor: 'john', + nxtPid: '123', + }) + }) + + it('should extract catchall values correctly', () => { + const regex = getNamedRouteRegex('/files/[...path]', { + prefixRouteKeys: true, + }) + + const namedRe = new RegExp(regex.namedRegex) + const match = namedRe.exec('/files/docs/api/reference.md') + + expect(match).toBeTruthy() + expect(match?.groups).toEqual({ + nxtPpath: 'docs/api/reference.md', + }) + }) +}) + describe('parseParameter', () => { it('should parse a optional catchall parameter', () => { const param = '[[...slug]]' @@ -254,3 +949,259 @@ describe('parseParameter', () => { expect(result).toEqual(expected) }) }) + +describe('getNamedRouteRegex - pathToRegexpPattern Conformance', () => { + describe('Basic Patterns', () => { + it('should generate a pattern that matches single dynamic segment routes', () => { + const { regex, compiled } = compilePattern('/posts/[id]', { + prefixRouteKeys: true, + }) + + // Verify the pattern format + expect(regex.pathToRegexpPattern).toBe('/posts/:nxtPid') + + // Should match valid routes + expect(compiled.exec('/posts/123')).toMatchInlineSnapshot(` + [ + "/posts/123", + "123", + ] + `) + expect(compiled.exec('/posts/abc-def')).toMatchInlineSnapshot(` + [ + "/posts/abc-def", + "abc-def", + ] + `) + + // Should not match invalid routes + expect(compiled.exec('/posts')).toBe(null) + expect(compiled.exec('/posts/123/extra')).toBe(null) + }) + + it('should generate a pattern that matches multiple dynamic segment routes', () => { + const { regex, compiled } = compilePattern('/[org]/[repo]/[branch]', { + prefixRouteKeys: true, + }) + + // Verify the pattern format + expect(regex.pathToRegexpPattern).toBe('/:nxtPorg/:nxtPrepo/:nxtPbranch') + + // Should match valid routes + expect(compiled.exec('/vercel/next.js/canary')).toMatchInlineSnapshot(` + [ + "/vercel/next.js/canary", + "vercel", + "next.js", + "canary", + ] + `) + + // Should not match incomplete routes + expect(compiled.exec('/vercel')).toBe(null) + expect(compiled.exec('/vercel/next.js')).toBe(null) + }) + }) + + describe('Catch-all Segments', () => { + it('should generate a pattern for required catch-all segments', () => { + const { regex, compiled } = compilePattern('/files/[...path]', { + prefixRouteKeys: true, + }) + + // Verify the pattern uses the + modifier for required catch-all + expect(regex.pathToRegexpPattern).toBe('/files/:nxtPpath+') + + // Should match single segments + expect(compiled.exec('/files/a')).toMatchInlineSnapshot(` + [ + "/files/a", + "a", + ] + `) + + // Should match multiple segments + expect(compiled.exec('/files/a/b/c')).toMatchInlineSnapshot(` + [ + "/files/a/b/c", + "a/b/c", + ] + `) + + // Should not match without any segments + expect(compiled.exec('/files')).toBe(null) + }) + + it('should generate a pattern for optional catch-all segments', () => { + const { regex, compiled } = compilePattern('/photos/[[...id]]', { + prefixRouteKeys: true, + }) + + // Verify the pattern uses the * modifier for optional catch-all + expect(regex.pathToRegexpPattern).toBe('/photos/:nxtPid*') + + // Should match without segments + expect(compiled.exec('/photos')).toMatchInlineSnapshot(` + [ + "/photos", + undefined, + ] + `) + + // Should match single segment + expect(compiled.exec('/photos/1')).toMatchInlineSnapshot(` + [ + "/photos/1", + "1", + ] + `) + + // Should match multiple segments + expect(compiled.exec('/photos/1/2/3')).toMatchInlineSnapshot(` + [ + "/photos/1/2/3", + "1/2/3", + ] + `) + }) + + it('should generate a pattern for catch-all after static segments', () => { + const { regex, compiled } = compilePattern('/docs/api/[...slug]', { + prefixRouteKeys: true, + }) + + expect(regex.pathToRegexpPattern).toBe('/docs/api/:nxtPslug+') + + expect(compiled.exec('/docs/api/reference')).toMatchInlineSnapshot(` + [ + "/docs/api/reference", + "reference", + ] + `) + expect(compiled.exec('/docs/api/v1/users/create')).toMatchInlineSnapshot(` + [ + "/docs/api/v1/users/create", + "v1/users/create", + ] + `) + + // Should not match without the catch-all segment + expect(compiled.exec('/docs/api')).toBe(null) + }) + }) + + describe('Optional Segments', () => { + it('should generate a pattern for optional single segments', () => { + const { regex, compiled } = compilePattern('/photos/[[id]]', { + prefixRouteKeys: true, + }) + + // Verify the pattern format for optional segments + expect(regex.pathToRegexpPattern).toBe('/photos/:nxtPid') + + // Should match with the segment + expect(compiled.exec('/photos/123')).toMatchInlineSnapshot(` + [ + "/photos/123", + "123", + ] + `) + + // Should match without the segment (note: path-to-regexp behavior) + // The pattern generated doesn't include a modifier, so this might not match + // This test verifies the actual behavior + const withoutSegment = compiled.exec('/photos') + expect(withoutSegment).toBe(null) + }) + + it('should generate a pattern for multiple optional segments', () => { + const { regex, compiled } = compilePattern('/posts/[[category]]/[[id]]', { + prefixRouteKeys: true, + }) + + expect(regex.pathToRegexpPattern).toBe('/posts/:nxtPcategory/:nxtPid') + + // Should match with all segments + expect(compiled.exec('/posts/tech/123')).toMatchInlineSnapshot(` + [ + "/posts/tech/123", + "tech", + "123", + ] + `) + + // Note: The pattern generated doesn't have optional modifiers, + // so it requires all segments to be present + expect(compiled.exec('/posts/tech')).toBe(null) + expect(compiled.exec('/posts')).toBe(null) + }) + }) + + describe('Complex Patterns', () => { + it('should generate a pattern for routes with prefixes and suffixes', () => { + const route = '/[locale]/about.segments/$dname$d[name].segment.rsc' + const regex = getNamedRouteRegex(route, { + prefixRouteKeys: true, + includeSuffix: true, + includePrefix: true, + }) + + expect(regex.pathToRegexpPattern).toBe( + '/:nxtPlocale/about.segments/$dname$d/:nxtPname.segment.rsc' + ) + + // For this complex pattern with special chars, verify the pattern format + // but don't test compilation since path-to-regexp may not handle all edge cases + // The important part is that pathToRegexpPattern is generated correctly + }) + + it('should generate a pattern for routes with catch-all and static segments', () => { + const { regex, compiled } = compilePattern( + '/[locale]/docs/v2/[...slug]', + { + prefixRouteKeys: true, + } + ) + + expect(regex.pathToRegexpPattern).toBe('/:nxtPlocale/docs/v2/:nxtPslug+') + + expect(compiled.exec('/en/docs/v2/api/reference')).toMatchInlineSnapshot(` + [ + "/en/docs/v2/api/reference", + "en", + "api/reference", + ] + `) + + // Should not match without locale + expect(compiled.exec('/docs/v2/api/reference')).toBe(null) + + // Should not match without catch-all + expect(compiled.exec('/en/docs/v2')).toBe(null) + }) + + it('should generate a pattern for deeply nested dynamic routes', () => { + const { regex, compiled } = compilePattern( + '/[org]/[repo]/[branch]/[...path]', + { + prefixRouteKeys: true, + } + ) + + expect(regex.pathToRegexpPattern).toBe( + '/:nxtPorg/:nxtPrepo/:nxtPbranch/:nxtPpath+' + ) + + expect(compiled.exec('/vercel/next.js/canary/docs/api/reference.md')) + .toMatchInlineSnapshot(` + [ + "/vercel/next.js/canary/docs/api/reference.md", + "vercel", + "next.js", + "canary", + "docs/api/reference.md", + ] + `) + }) + }) +}) diff --git a/packages/next/src/shared/lib/router/utils/route-regex.ts b/packages/next/src/shared/lib/router/utils/route-regex.ts index 5dfb7fb10b977..e4846049ae332 100644 --- a/packages/next/src/shared/lib/router/utils/route-regex.ts +++ b/packages/next/src/shared/lib/router/utils/route-regex.ts @@ -52,6 +52,13 @@ type GetNamedRouteRegexOptions = { * the routes-manifest during the build. */ backreferenceDuplicateKeys?: boolean + + /** + * If provided, this will be used as the reference for the dynamic parameter + * keys instead of generating them in context. This is currently only used for + * interception routes. + */ + reference?: Record } type GetRouteRegexOptions = { @@ -242,9 +249,15 @@ function getSafeKeyFromSegment({ pattern = `(?<${cleanedKey}>[^/]+?)` } - return optional - ? `(?:/${interceptionPrefix}${pattern})?` - : `/${interceptionPrefix}${pattern}` + return { + key, + pattern: optional + ? `(?:/${interceptionPrefix}${pattern})?` + : `/${interceptionPrefix}${pattern}`, + cleanedKey: cleanedKey, + optional, + repeat, + } } function getNamedParametrizedRoute( @@ -252,12 +265,18 @@ function getNamedParametrizedRoute( prefixRouteKeys: boolean, includeSuffix: boolean, includePrefix: boolean, - backreferenceDuplicateKeys: boolean + backreferenceDuplicateKeys: boolean, + reference: Record = {} ) { const getSafeRouteKey = buildGetSafeRouteKey() const routeKeys: { [named: string]: string } = {} const segments: string[] = [] + const inverseParts: string[] = [] + + // Ensure we don't mutate the original reference object. + reference = structuredClone(reference) + for (const segment of removeTrailingSlash(route).slice(1).split('/')) { const hasInterceptionMarker = INTERCEPTION_ROUTE_MARKERS.some((m) => segment.startsWith(m) @@ -267,7 +286,7 @@ function getNamedParametrizedRoute( if (hasInterceptionMarker && paramMatches && paramMatches[2]) { // If there's an interception marker, add it to the segments. - segments.push( + const { key, pattern, cleanedKey, repeat, optional } = getSafeKeyFromSegment({ getSafeRouteKey, interceptionMarker: paramMatches[1], @@ -278,40 +297,56 @@ function getNamedParametrizedRoute( : undefined, backreferenceDuplicateKeys, }) + + segments.push(pattern) + inverseParts.push( + `/${paramMatches[1]}:${reference[key] ?? cleanedKey}${repeat ? (optional ? '*' : '+') : ''}` ) + reference[key] ??= cleanedKey } else if (paramMatches && paramMatches[2]) { // If there's a prefix, add it to the segments if it's enabled. if (includePrefix && paramMatches[1]) { segments.push(`/${escapeStringRegexp(paramMatches[1])}`) + inverseParts.push(`/${paramMatches[1]}`) } - let s = getSafeKeyFromSegment({ - getSafeRouteKey, - segment: paramMatches[2], - routeKeys, - keyPrefix: prefixRouteKeys ? NEXT_QUERY_PARAM_PREFIX : undefined, - backreferenceDuplicateKeys, - }) + const { key, pattern, cleanedKey, repeat, optional } = + getSafeKeyFromSegment({ + getSafeRouteKey, + segment: paramMatches[2], + routeKeys, + keyPrefix: prefixRouteKeys ? NEXT_QUERY_PARAM_PREFIX : undefined, + backreferenceDuplicateKeys, + }) // Remove the leading slash if includePrefix already added it. + let s = pattern if (includePrefix && paramMatches[1]) { s = s.substring(1) } segments.push(s) + inverseParts.push( + `/:${reference[key] ?? cleanedKey}${repeat ? (optional ? '*' : '+') : ''}` + ) + reference[key] ??= cleanedKey } else { segments.push(`/${escapeStringRegexp(segment)}`) + inverseParts.push(`/${segment}`) } // If there's a suffix, add it to the segments if it's enabled. if (includeSuffix && paramMatches && paramMatches[3]) { segments.push(escapeStringRegexp(paramMatches[3])) + inverseParts.push(paramMatches[3]) } } return { namedParameterizedRoute: segments.join(''), routeKeys, + pathToRegexpPattern: inverseParts.join(''), + reference, } } @@ -332,7 +367,8 @@ export function getNamedRouteRegex( options.prefixRouteKeys, options.includeSuffix ?? false, options.includePrefix ?? false, - options.backreferenceDuplicateKeys ?? false + options.backreferenceDuplicateKeys ?? false, + options.reference ) let namedRegex = result.namedParameterizedRoute @@ -344,6 +380,8 @@ export function getNamedRouteRegex( ...getRouteRegex(normalizedRoute, options), namedRegex: `^${namedRegex}$`, routeKeys: result.routeKeys, + pathToRegexpPattern: result.pathToRegexpPattern, + reference: result.reference, } } @@ -375,7 +413,8 @@ export function getNamedMiddlewareRegex( false, false, false, - false + false, + undefined ) let catchAllGroupedRegex = catchAll ? '(?:(/.*)?)' : '' return {