diff --git a/packages/nextjs/src/config/manifest/createRouteManifest.ts b/packages/nextjs/src/config/manifest/createRouteManifest.ts index d37285983d31..487ab05c55d8 100644 --- a/packages/nextjs/src/config/manifest/createRouteManifest.ts +++ b/packages/nextjs/src/config/manifest/createRouteManifest.ts @@ -28,9 +28,10 @@ function isRouteGroup(name: string): boolean { return name.startsWith('(') && name.endsWith(')'); } -function normalizeRoutePath(routePath: string): string { +function normalizeRouteGroupPath(routePath: string): string { // Remove route group segments from the path - return routePath.replace(/\/\([^)]+\)/g, ''); + // Using positive lookahead with (?=[^)\/]*\)) to avoid polynomial matching + return routePath.replace(/\/\((?=[^)/]*\))[^)/]+\)/g, ''); } function getDynamicRouteSegment(name: string): string { @@ -140,7 +141,7 @@ function scanAppDirectory(dir: string, basePath: string = '', includeRouteGroups if (pageFile) { // Conditionally normalize the path based on includeRouteGroups option - const routePath = includeRouteGroups ? basePath || '/' : normalizeRoutePath(basePath || '/'); + const routePath = includeRouteGroups ? basePath || '/' : normalizeRouteGroupPath(basePath || '/'); const isDynamic = routePath.includes(':'); // Check if this page has generateStaticParams (ISR/SSG indicator) diff --git a/packages/nextjs/test/config/manifest/suites/route-groups/app/(api_internal)/api/page.tsx b/packages/nextjs/test/config/manifest/suites/route-groups/app/(api_internal)/api/page.tsx new file mode 100644 index 000000000000..39c826b4bf16 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/route-groups/app/(api_internal)/api/page.tsx @@ -0,0 +1 @@ +// API Internal Page diff --git a/packages/nextjs/test/config/manifest/suites/route-groups/app/(auth-v2)/login/page.tsx b/packages/nextjs/test/config/manifest/suites/route-groups/app/(auth-v2)/login/page.tsx new file mode 100644 index 000000000000..3776b7545439 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/route-groups/app/(auth-v2)/login/page.tsx @@ -0,0 +1 @@ +// Login V2 Page diff --git a/packages/nextjs/test/config/manifest/suites/route-groups/app/(v2.0.beta)/features/page.tsx b/packages/nextjs/test/config/manifest/suites/route-groups/app/(v2.0.beta)/features/page.tsx new file mode 100644 index 000000000000..66c18edfd787 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/route-groups/app/(v2.0.beta)/features/page.tsx @@ -0,0 +1 @@ +// Features Beta Page diff --git a/packages/nextjs/test/config/manifest/suites/route-groups/route-groups.test.ts b/packages/nextjs/test/config/manifest/suites/route-groups/route-groups.test.ts index c2d455361c4c..32ac315b3571 100644 --- a/packages/nextjs/test/config/manifest/suites/route-groups/route-groups.test.ts +++ b/packages/nextjs/test/config/manifest/suites/route-groups/route-groups.test.ts @@ -12,11 +12,14 @@ describe('route-groups', () => { expect(manifest).toEqual({ staticRoutes: [ { path: '/' }, + { path: '/api' }, { path: '/login' }, { path: '/signup' }, + { path: '/login' }, // from (auth-v2) { path: '/dashboard' }, { path: '/settings/profile' }, { path: '/public/about' }, + { path: '/features' }, ], dynamicRoutes: [ { @@ -28,6 +31,8 @@ describe('route-groups', () => { ], isrRoutes: [], }); + // Verify we have 9 static routes total (including duplicates from special chars) + expect(manifest.staticRoutes).toHaveLength(9); }); test('should handle dynamic routes within route groups', () => { @@ -37,6 +42,17 @@ describe('route-groups', () => { expect(regex.test('/dashboard/abc')).toBe(true); expect(regex.test('/dashboard/123/456')).toBe(false); }); + + test.each([ + { routeGroup: '(auth-v2)', strippedPath: '/login', description: 'hyphens' }, + { routeGroup: '(api_internal)', strippedPath: '/api', description: 'underscores' }, + { routeGroup: '(v2.0.beta)', strippedPath: '/features', description: 'dots' }, + ])('should strip route groups with $description', ({ routeGroup, strippedPath }) => { + // Verify the stripped path exists + expect(manifest.staticRoutes.find(route => route.path === strippedPath)).toBeDefined(); + // Verify the route group was stripped, not included + expect(manifest.staticRoutes.find(route => route.path.includes(routeGroup))).toBeUndefined(); + }); }); describe('includeRouteGroups: true', () => { @@ -46,11 +62,14 @@ describe('route-groups', () => { expect(manifest).toEqual({ staticRoutes: [ { path: '/' }, + { path: '/(api_internal)/api' }, { path: '/(auth)/login' }, { path: '/(auth)/signup' }, + { path: '/(auth-v2)/login' }, { path: '/(dashboard)/dashboard' }, { path: '/(dashboard)/settings/profile' }, { path: '/(marketing)/public/about' }, + { path: '/(v2.0.beta)/features' }, ], dynamicRoutes: [ { @@ -62,6 +81,7 @@ describe('route-groups', () => { ], isrRoutes: [], }); + expect(manifest.staticRoutes).toHaveLength(9); }); test('should handle dynamic routes within route groups with proper regex escaping', () => { @@ -92,5 +112,13 @@ describe('route-groups', () => { expect(authSignup).toBeDefined(); expect(marketingPublic).toBeDefined(); }); + + test.each([ + { fullPath: '/(auth-v2)/login', description: 'hyphens' }, + { fullPath: '/(api_internal)/api', description: 'underscores' }, + { fullPath: '/(v2.0.beta)/features', description: 'dots' }, + ])('should preserve route groups with $description when includeRouteGroups is true', ({ fullPath }) => { + expect(manifest.staticRoutes.find(route => route.path === fullPath)).toBeDefined(); + }); }); });