diff --git a/packages/next/errors.json b/packages/next/errors.json index d08ebc67b673a..7b7a345ab483a 100644 --- a/packages/next/errors.json +++ b/packages/next/errors.json @@ -847,5 +847,7 @@ "846": "Route %s used \\`cookies()\\` inside a function cached with \\`unstable_cache()\\`. Accessing Dynamic data sources inside a cache scope is not supported. If you need this data inside a cached function use \\`cookies()\\` outside of the cached function and pass the required dynamic data in as an argument. See more info here: https://nextjs.org/docs/app/api-reference/functions/unstable_cache", "847": "Route %s with \\`dynamic = \"error\"\\` couldn't be rendered statically because it used \\`connection()\\`. See more info here: https://nextjs.org/docs/app/building-your-application/rendering/static-and-dynamic#dynamic-rendering", "848": "%sused %s. \\`searchParams\\` is a Promise and must be unwrapped with \\`await\\` or \\`React.use()\\` before accessing its properties. Learn more: https://nextjs.org/docs/messages/sync-dynamic-apis", - "849": "Route %s with \\`dynamic = \"error\"\\` couldn't be rendered statically because it used \\`cookies()\\`. See more info here: https://nextjs.org/docs/app/building-your-application/rendering/static-and-dynamic#dynamic-rendering" + "849": "Route %s with \\`dynamic = \"error\"\\` couldn't be rendered statically because it used \\`cookies()\\`. See more info here: https://nextjs.org/docs/app/building-your-application/rendering/static-and-dynamic#dynamic-rendering", + "850": "No reference found for param: %s in reference: %s", + "851": "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..4e86ed71c6dfd 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, lastNextUrl } = state const matchingHead = useMemo(() => { return findHeadInCache(cache, tree[1]) @@ -423,8 +423,9 @@ function Router({ tree, focusAndScrollRef, nextUrl, + lastNextUrl, } - }, [tree, focusAndScrollRef, nextUrl]) + }, [tree, focusAndScrollRef, nextUrl, lastNextUrl]) 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..4ad66ccd7a124 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.lastNextUrl || 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..589cac6878e54 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', + lastNextUrl: 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..97bf62e0b7335 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, + lastNextUrl: 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..a94ad89cac87c 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 lastNextUrl = state.lastNextUrl 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 + lastNextUrl = 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, + lastNextUrl, } } 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..152d05efc91c1 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.lastNextUrl || 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..1d4feb4db631e 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, + lastNextUrl: 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 476fb2e1fbebc..374a91e2b54b3 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 @@ -244,8 +244,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.lastNextUrl || state.nextUrl) && + hasInterceptionRouteInCurrentTree(state.tree) + ? state.lastNextUrl || 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..68068c85ec1b9 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 last next-url that was used previous to a dynamic navigation. + */ + lastNextUrl: 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..3ba737f4b193d --- /dev/null +++ b/packages/next/src/lib/generate-interception-routes-rewrites.test.ts @@ -0,0 +1,857 @@ +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.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(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(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( + 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) + }) + }) + + 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 + ) + + // 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) + + // 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(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(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) + }) + }) + + 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(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(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) + }) + }) + + 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(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) + }) + }) + + 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( + 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( + 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]) + + // 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]) + + // 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]) + + // Should match routes at /parallel-layout level + expect(headerRegex.test('/parallel-layout')).toBe(true) + }) + }) + + 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(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) + }) + }) + + 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]) + + // 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' + ) + }) + }) +}) diff --git a/packages/next/src/lib/generate-interception-routes-rewrites.ts b/packages/next/src/lib/generate-interception-routes-rewrites.ts index 3bc28eee427ab..aaf9245324211 100644 --- a/packages/next/src/lib/generate-interception-routes-rewrites.ts +++ b/packages/next/src/lib/generate-interception-routes-rewrites.ts @@ -2,22 +2,126 @@ 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[] = [] + + for (const segment of segments) { + 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}>.+?)`) + } else { + patterns.push(`(?<${prefixedKey}>[^/]+?)`) + } + } else { + // Static segment + patterns.push(escapeStringRegexp(segment)) + } + } + + const pattern = patterns.length > 0 ? `/${patterns.join('/')}` : '' + + // 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}(/[^/]+)?/?` +} -// 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, '_') +/** + * 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 + + 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) }) } @@ -32,30 +136,94 @@ export function generateInterceptionRoutesRewrites( const { interceptingRoute, interceptedRoute } = extractInterceptionRouteInformation(appPath) - const normalizedInterceptingRoute = `${ - interceptingRoute !== '/' ? toPathToRegexpPath(interceptingRoute) : '' - }/(.*)?` + // Detect which marker is being used + const marker = getInterceptionMarker(appPath) + + // The Next-Url header does not contain the base path, so just use the + // intercepting route. + 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. + 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. + const destination = getNamedRouteRegex(basePath + appPath, { + prefixRouteKeys: true, + reference: source.reference, + }) + + // 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, + header.reference + ) + } 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[] = [] + for (const segment of interceptingRoute.split('/').filter(Boolean)) { + const param = getSegmentParam(segment) + if (param) { + // Dynamic segment - use named capture group from header.reference + const key = header.reference[param.param] + if (!key) { + throw new InvariantError( + `No reference found for param: ${param.param} in reference: ${JSON.stringify(header.reference)}` + ) + } - const normalizedInterceptedRoute = toPathToRegexpPath(interceptedRoute) - const normalizedAppPath = toPathToRegexpPath(appPath) + // Check if this is a catchall (repeat) parameter + if (isCatchAll(param.type)) { + patterns.push(`(?<${key}>.+?)`) + } else { + patterns.push(`(?<${key}>[^/]+?)`) + } + } else { + // Static segment + patterns.push(escapeStringRegexp(segment)) + } + } - // 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) + // 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 = `/${patterns.join('/')}${hasCatchallSibling ? '(/.+)?' : ''}` + } else { + // For other markers, use the default behavior (match exact intercepting route) + // Strip ^ and $ anchors since matchHas() will add them automatically + headerRegex = 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..fc7c31ab9519e 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 + lastNextUrl: 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..4ad78d66181bb 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 @@ -218,6 +218,424 @@ describe('getNamedRouteRegex', () => { }) }) +describe('getNamedRouteRegex - Parameter Sanitization', () => { + it('should sanitize parameter names with hyphens', () => { + const regex = getNamedRouteRegex('/[foo-bar]/page', { + prefixRouteKeys: true, + }) + + // Hyphens should be removed from the key, but routeKeys maps sanitized → original + expect(regex.routeKeys).toEqual({ + nxtPfoobar: 'nxtPfoo-bar', + }) + + // The reference maps original → sanitized + expect(regex.reference).toEqual({ + 'foo-bar': 'nxtPfoobar', + }) + + // Named regex should use the sanitized name + expect(regex.namedRegex).toContain('(?') + }) + + it('should sanitize parameter names with underscores', () => { + const regex = getNamedRouteRegex('/[foo_id]/page', { + prefixRouteKeys: true, + }) + + // Underscores should be removed from parameter names + expect(regex.routeKeys).toEqual({ + nxtPfoo_id: 'nxtPfoo_id', + }) + + // Original key is preserved in reference + expect(regex.reference).toEqual({ + foo_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.routeKeys).toEqual({ + nxtPthisis_myroute: 'nxtPthis-is_my-route', + }) + + expect(regex.reference).toEqual({ + 'this-is_my-route': 'nxtPthisis_myroute', + }) + }) + + 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 + expect(Object.keys(regex.routeKeys)).toHaveLength(1) + expect(regex.routeKeys.nxtPid).toBe('nxtPid') + + // Named regex should contain a backreference for the second occurrence + expect(regex.namedRegex).toContain('\\k') + }) + + it('should handle duplicate parameters without backreferences', () => { + const regex = getNamedRouteRegex('/[id]/posts/[id]', { + prefixRouteKeys: true, + backreferenceDuplicateKeys: false, + }) + + // Should still have only one key + expect(Object.keys(regex.routeKeys)).toHaveLength(1) + + // But no backreference in the pattern + expect(regex.namedRegex).not.toContain('\\k<') + }) +}) + +describe('getNamedRouteRegex - Complex Paths', () => { + it('should handle paths with multiple dynamic segments', () => { + const regex = getNamedRouteRegex('/[org]/[repo]/[branch]/[...path]', { + prefixRouteKeys: true, + }) + + expect(regex.routeKeys).toEqual({ + nxtPorg: 'nxtPorg', + nxtPrepo: 'nxtPrepo', + nxtPbranch: 'nxtPbranch', + nxtPpath: 'nxtPpath', + }) + + expect(regex.groups).toEqual({ + org: { pos: 1, repeat: false, optional: false }, + repo: { pos: 2, repeat: false, optional: false }, + branch: { pos: 3, repeat: false, optional: false }, + path: { pos: 4, repeat: true, optional: false }, + }) + + // 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.namedRegex).toMatch(/\(\?:\/\)\?\$/) + + // 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]]' 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..c3ef855624661 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,14 @@ function getSafeKeyFromSegment({ pattern = `(?<${cleanedKey}>[^/]+?)` } - return optional - ? `(?:/${interceptionPrefix}${pattern})?` - : `/${interceptionPrefix}${pattern}` + return { + key, + pattern: optional + ? `(?:/${interceptionPrefix}${pattern})?` + : `/${interceptionPrefix}${pattern}`, + cleanedKey: cleanedKey, + repeat, + } } function getNamedParametrizedRoute( @@ -252,12 +264,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,25 +285,30 @@ function getNamedParametrizedRoute( if (hasInterceptionMarker && paramMatches && paramMatches[2]) { // If there's an interception marker, add it to the segments. - segments.push( - getSafeKeyFromSegment({ - getSafeRouteKey, - interceptionMarker: paramMatches[1], - segment: paramMatches[2], - routeKeys, - keyPrefix: prefixRouteKeys - ? NEXT_INTERCEPTION_MARKER_PREFIX - : undefined, - backreferenceDuplicateKeys, - }) + const { key, pattern, cleanedKey, repeat } = getSafeKeyFromSegment({ + getSafeRouteKey, + interceptionMarker: paramMatches[1], + segment: paramMatches[2], + routeKeys, + keyPrefix: prefixRouteKeys + ? NEXT_INTERCEPTION_MARKER_PREFIX + : undefined, + backreferenceDuplicateKeys, + }) + + segments.push(pattern) + inverseParts.push( + `/${paramMatches[1]}:${reference[key] ?? cleanedKey}${repeat ? '*' : ''}` ) + 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({ + const { key, pattern, cleanedKey, repeat } = getSafeKeyFromSegment({ getSafeRouteKey, segment: paramMatches[2], routeKeys, @@ -294,24 +317,31 @@ function getNamedParametrizedRoute( }) // 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 ? '*' : ''}`) + 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 +362,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 +375,8 @@ export function getNamedRouteRegex( ...getRouteRegex(normalizedRoute, options), namedRegex: `^${namedRegex}$`, routeKeys: result.routeKeys, + pathToRegexpPattern: result.pathToRegexpPattern, + reference: result.reference, } } @@ -375,7 +408,8 @@ export function getNamedMiddlewareRegex( false, false, false, - false + false, + undefined ) let catchAllGroupedRegex = catchAll ? '(?:(/.*)?)' : '' return {