From 73e0637916c569ef1a6118048347b5f166876b06 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 9 Jul 2025 00:15:03 +0200 Subject: [PATCH 01/35] build manifest function --- .../src/config/manifest/build-manifest.ts | 199 ++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 packages/nextjs/src/config/manifest/build-manifest.ts diff --git a/packages/nextjs/src/config/manifest/build-manifest.ts b/packages/nextjs/src/config/manifest/build-manifest.ts new file mode 100644 index 000000000000..070abc5c3e9c --- /dev/null +++ b/packages/nextjs/src/config/manifest/build-manifest.ts @@ -0,0 +1,199 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +export type RouteInfo = { + path: string; + dynamic: boolean; + pattern?: string; + paramNames?: string[]; +}; + +export type RouteManifest = { + dynamic: RouteInfo[]; + static: RouteInfo[]; +}; + +export type CreateRouteManifestOptions = { + // For starters we only support app router + appDirPath?: string; +}; + +let manifestCache: RouteManifest | null = null; +let lastAppDirPath: string | null = null; + +function isPageFile(filename: string): boolean { + return filename === 'page.tsx' || filename === 'page.jsx' || filename === 'page.ts' || filename === 'page.js'; +} + +function isRouteGroup(name: string): boolean { + return name.startsWith('(') && name.endsWith(')'); +} + +function getDynamicRouteSegment(name: string): string { + if (name.startsWith('[[...') && name.endsWith(']]')) { + // Optional catchall: [[...param]] + const paramName = name.slice(5, -2); // Remove [[... and ]] + return `:${paramName}*?`; // Mark with ? as optional + } else if (name.startsWith('[...') && name.endsWith(']')) { + // Required catchall: [...param] + const paramName = name.slice(4, -1); // Remove [... and ] + return `:${paramName}*`; + } else { + // Regular dynamic: [param] + return `:${name.slice(1, -1)}`; + } +} + +function buildRegexForDynamicRoute(routePath: string): { pattern: string; paramNames: string[] } { + const segments = routePath.split('/').filter(Boolean); + const regexSegments: string[] = []; + const paramNames: string[] = []; + + for (const segment of segments) { + if (segment.startsWith(':')) { + const paramName = segment.substring(1); + + if (paramName.endsWith('*?')) { + // Optional catchall: matches zero or more segments + const cleanParamName = paramName.slice(0, -2); + paramNames.push(cleanParamName); + regexSegments.push('(.*)'); + } else if (paramName.endsWith('*')) { + // Required catchall: matches one or more segments + const cleanParamName = paramName.slice(0, -1); + paramNames.push(cleanParamName); + regexSegments.push('(.+)'); + } else { + // Regular dynamic segment + paramNames.push(paramName); + regexSegments.push('([^/]+)'); + } + } else { + // Static segment + regexSegments.push(segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); + } + } + + const pattern = `^/${regexSegments.join('/')}$`; + return { pattern, paramNames }; +} + +function scanAppDirectory(dir: string, basePath: string = ''): RouteInfo[] { + const routes: RouteInfo[] = []; + + try { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + const pageFile = getHighestPriorityPageFile(entries); + + if (pageFile) { + const routePath = basePath || '/'; + const isDynamic = routePath.includes(':'); + + if (isDynamic) { + const { pattern, paramNames } = buildRegexForDynamicRoute(routePath); + routes.push({ + path: routePath, + dynamic: true, + pattern, + paramNames, + }); + } else { + routes.push({ + path: routePath, + dynamic: false, + }); + } + } + + for (const entry of entries) { + if (entry.isDirectory()) { + const fullPath = path.join(dir, entry.name); + + if (isRouteGroup(entry.name)) { + // Route groups don't affect the URL, just scan them + const subRoutes = scanAppDirectory(fullPath, basePath); + routes.push(...subRoutes); + continue; + } + + const isDynamic = entry.name.startsWith('[') && entry.name.endsWith(']'); + let routeSegment: string; + + if (isDynamic) { + routeSegment = getDynamicRouteSegment(entry.name); + } else { + routeSegment = entry.name; + } + + const newBasePath = `${basePath}/${routeSegment}`; + const subRoutes = scanAppDirectory(fullPath, newBasePath); + routes.push(...subRoutes); + } + } + } catch (error) { + // eslint-disable-next-line no-console + console.warn('Error building route manifest:', error); + } + + return routes; +} + +function getHighestPriorityPageFile(entries: fs.Dirent[]): string | null { + // Next.js precedence order: .tsx > .ts > .jsx > .js + const pageFiles = entries.filter(entry => entry.isFile() && isPageFile(entry.name)).map(entry => entry.name); + + if (pageFiles.length === 0) return null; + + if (pageFiles.includes('page.tsx')) return 'page.tsx'; + if (pageFiles.includes('page.ts')) return 'page.ts'; + if (pageFiles.includes('page.jsx')) return 'page.jsx'; + if (pageFiles.includes('page.js')) return 'page.js'; + + return null; +} + +/** + * Returns a route manifest for the given app directory + */ +export function createRouteManifest(options?: CreateRouteManifestOptions): RouteManifest { + let targetDir: string | undefined; + + if (options?.appDirPath) { + targetDir = options.appDirPath; + } else { + const projectDir = process.cwd(); + const maybeAppDirPath = path.join(projectDir, 'app'); + const maybeSrcAppDirPath = path.join(projectDir, 'src', 'app'); + + if (fs.existsSync(maybeAppDirPath) && fs.lstatSync(maybeAppDirPath).isDirectory()) { + targetDir = maybeAppDirPath; + } else if (fs.existsSync(maybeSrcAppDirPath) && fs.lstatSync(maybeSrcAppDirPath).isDirectory()) { + targetDir = maybeSrcAppDirPath; + } + } + + if (!targetDir) { + return { + dynamic: [], + static: [], + }; + } + + // Check if we can use cached version + if (manifestCache && lastAppDirPath === targetDir) { + return manifestCache; + } + + const routes = scanAppDirectory(targetDir); + + const manifest: RouteManifest = { + dynamic: routes.filter(route => route.dynamic), + static: routes.filter(route => !route.dynamic), + }; + + // set cache + manifestCache = manifest; + lastAppDirPath = targetDir; + + return manifest; +} From 9b28d799004624ac73cd71d07aff5a02f1cd523a Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 9 Jul 2025 00:15:33 +0200 Subject: [PATCH 02/35] tests --- .../app/catchall/[[...path]]/page.tsx | 1 + .../manifest/suites/catchall/app/page.tsx | 1 + .../manifest/suites/catchall/catchall.test.ts | 32 +++++++ .../suites/dynamic/app/dynamic/[id]/page.tsx | 1 + .../manifest/suites/dynamic/app/page.tsx | 1 + .../suites/dynamic/app/static/nested/page.tsx | 1 + .../suites/dynamic/app/users/[id]/page.tsx | 1 + .../app/users/[id]/posts/[postId]/page.tsx | 1 + .../dynamic/app/users/[id]/settings/page.tsx | 1 + .../manifest/suites/dynamic/dynamic.test.ts | 92 +++++++++++++++++++ .../app/javascript/component.tsx | 1 + .../file-extensions/app/javascript/page.js | 1 + .../file-extensions/app/jsx-route/page.jsx | 1 + .../suites/file-extensions/app/layout.tsx | 1 + .../suites/file-extensions/app/mixed/page.jsx | 1 + .../suites/file-extensions/app/mixed/page.ts | 1 + .../suites/file-extensions/app/page.tsx | 1 + .../file-extensions/app/precedence/page.js | 1 + .../file-extensions/app/precedence/page.tsx | 1 + .../file-extensions/app/typescript/other.ts | 1 + .../file-extensions/app/typescript/page.ts | 1 + .../file-extensions/file-extensions.test.ts | 21 +++++ .../suites/route-groups/app/(auth)/layout.tsx | 1 + .../route-groups/app/(auth)/login/page.tsx | 1 + .../route-groups/app/(auth)/signup/page.tsx | 1 + .../app/(dashboard)/dashboard/[id]/page.tsx | 1 + .../app/(dashboard)/dashboard/page.tsx | 1 + .../route-groups/app/(dashboard)/layout.tsx | 1 + .../app/(dashboard)/settings/layout.tsx | 1 + .../app/(dashboard)/settings/profile/page.tsx | 1 + .../route-groups/app/(marketing)/layout.tsx | 1 + .../app/(marketing)/public/about/page.tsx | 1 + .../suites/route-groups/app/layout.tsx | 1 + .../manifest/suites/route-groups/app/page.tsx | 1 + .../suites/route-groups/route-groups.test.ts | 36 ++++++++ .../manifest/suites/static/app/page.tsx | 1 + .../suites/static/app/some/nested/page.tsx | 1 + .../manifest/suites/static/app/user/page.tsx | 1 + .../manifest/suites/static/app/users/page.tsx | 1 + .../manifest/suites/static/static.test.ts | 18 ++++ 40 files changed, 234 insertions(+) create mode 100644 packages/nextjs/test/config/manifest/suites/catchall/app/catchall/[[...path]]/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/catchall/app/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts create mode 100644 packages/nextjs/test/config/manifest/suites/dynamic/app/dynamic/[id]/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/dynamic/app/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/dynamic/app/static/nested/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/dynamic/app/users/[id]/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/dynamic/app/users/[id]/posts/[postId]/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/dynamic/app/users/[id]/settings/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/dynamic/dynamic.test.ts create mode 100644 packages/nextjs/test/config/manifest/suites/file-extensions/app/javascript/component.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/file-extensions/app/javascript/page.js create mode 100644 packages/nextjs/test/config/manifest/suites/file-extensions/app/jsx-route/page.jsx create mode 100644 packages/nextjs/test/config/manifest/suites/file-extensions/app/layout.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/file-extensions/app/mixed/page.jsx create mode 100644 packages/nextjs/test/config/manifest/suites/file-extensions/app/mixed/page.ts create mode 100644 packages/nextjs/test/config/manifest/suites/file-extensions/app/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/file-extensions/app/precedence/page.js create mode 100644 packages/nextjs/test/config/manifest/suites/file-extensions/app/precedence/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/file-extensions/app/typescript/other.ts create mode 100644 packages/nextjs/test/config/manifest/suites/file-extensions/app/typescript/page.ts create mode 100644 packages/nextjs/test/config/manifest/suites/file-extensions/file-extensions.test.ts create mode 100644 packages/nextjs/test/config/manifest/suites/route-groups/app/(auth)/layout.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/route-groups/app/(auth)/login/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/route-groups/app/(auth)/signup/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/route-groups/app/(dashboard)/dashboard/[id]/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/route-groups/app/(dashboard)/dashboard/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/route-groups/app/(dashboard)/layout.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/route-groups/app/(dashboard)/settings/layout.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/route-groups/app/(dashboard)/settings/profile/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/route-groups/app/(marketing)/layout.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/route-groups/app/(marketing)/public/about/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/route-groups/app/layout.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/route-groups/app/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/route-groups/route-groups.test.ts create mode 100644 packages/nextjs/test/config/manifest/suites/static/app/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/static/app/some/nested/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/static/app/user/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/static/app/users/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/static/static.test.ts diff --git a/packages/nextjs/test/config/manifest/suites/catchall/app/catchall/[[...path]]/page.tsx b/packages/nextjs/test/config/manifest/suites/catchall/app/catchall/[[...path]]/page.tsx new file mode 100644 index 000000000000..5d33b5d14573 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/catchall/app/catchall/[[...path]]/page.tsx @@ -0,0 +1 @@ +// beep diff --git a/packages/nextjs/test/config/manifest/suites/catchall/app/page.tsx b/packages/nextjs/test/config/manifest/suites/catchall/app/page.tsx new file mode 100644 index 000000000000..2145a5eea70d --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/catchall/app/page.tsx @@ -0,0 +1 @@ +// Ciao diff --git a/packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts b/packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts new file mode 100644 index 000000000000..aaa4a900f67f --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts @@ -0,0 +1,32 @@ +import path from 'path'; +import { describe, expect, test } from 'vitest'; +import { createRouteManifest } from '../../../../../src/config/manifest/build-manifest'; + +describe('catchall', () => { + const manifest = createRouteManifest({ appDirPath: path.join(__dirname, 'app') }); + + test('should generate a manifest with catchall route', () => { + expect(manifest).toEqual({ + dynamic: [ + { + path: '/catchall/:path*?', + dynamic: true, + pattern: '^/catchall/(.*)$', + paramNames: ['path'], + }, + ], + static: [{ path: '/', dynamic: false }], + }); + }); + + test('should generate correct pattern for catchall route', () => { + const regex = new RegExp(manifest.dynamic[0]?.pattern ?? ''); + expect(regex.test('/catchall/123')).toBe(true); + expect(regex.test('/catchall/abc')).toBe(true); + expect(regex.test('/catchall/123/456')).toBe(true); + expect(regex.test('/catchall/123/abc/789')).toBe(true); + expect(regex.test('/catchall/')).toBe(true); + expect(regex.test('/123/catchall/123')).toBe(false); + expect(regex.test('/')).toBe(false); + }); +}); diff --git a/packages/nextjs/test/config/manifest/suites/dynamic/app/dynamic/[id]/page.tsx b/packages/nextjs/test/config/manifest/suites/dynamic/app/dynamic/[id]/page.tsx new file mode 100644 index 000000000000..5d33b5d14573 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/dynamic/app/dynamic/[id]/page.tsx @@ -0,0 +1 @@ +// beep diff --git a/packages/nextjs/test/config/manifest/suites/dynamic/app/page.tsx b/packages/nextjs/test/config/manifest/suites/dynamic/app/page.tsx new file mode 100644 index 000000000000..2145a5eea70d --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/dynamic/app/page.tsx @@ -0,0 +1 @@ +// Ciao diff --git a/packages/nextjs/test/config/manifest/suites/dynamic/app/static/nested/page.tsx b/packages/nextjs/test/config/manifest/suites/dynamic/app/static/nested/page.tsx new file mode 100644 index 000000000000..c3a94a1cb9e7 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/dynamic/app/static/nested/page.tsx @@ -0,0 +1 @@ +// Hola diff --git a/packages/nextjs/test/config/manifest/suites/dynamic/app/users/[id]/page.tsx b/packages/nextjs/test/config/manifest/suites/dynamic/app/users/[id]/page.tsx new file mode 100644 index 000000000000..262ed4b5bade --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/dynamic/app/users/[id]/page.tsx @@ -0,0 +1 @@ +// User profile page diff --git a/packages/nextjs/test/config/manifest/suites/dynamic/app/users/[id]/posts/[postId]/page.tsx b/packages/nextjs/test/config/manifest/suites/dynamic/app/users/[id]/posts/[postId]/page.tsx new file mode 100644 index 000000000000..1b8e79363a7f --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/dynamic/app/users/[id]/posts/[postId]/page.tsx @@ -0,0 +1 @@ +// Post detail page diff --git a/packages/nextjs/test/config/manifest/suites/dynamic/app/users/[id]/settings/page.tsx b/packages/nextjs/test/config/manifest/suites/dynamic/app/users/[id]/settings/page.tsx new file mode 100644 index 000000000000..2a09cffc75c4 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/dynamic/app/users/[id]/settings/page.tsx @@ -0,0 +1 @@ +// User settings page diff --git a/packages/nextjs/test/config/manifest/suites/dynamic/dynamic.test.ts b/packages/nextjs/test/config/manifest/suites/dynamic/dynamic.test.ts new file mode 100644 index 000000000000..267f5aa374f1 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/dynamic/dynamic.test.ts @@ -0,0 +1,92 @@ +import path from 'path'; +import { describe, expect, test } from 'vitest'; +import { createRouteManifest } from '../../../../../src/config/manifest/build-manifest'; + +describe('dynamic', () => { + const manifest = createRouteManifest({ appDirPath: path.join(__dirname, 'app') }); + + test('should generate a comprehensive dynamic manifest', () => { + expect(manifest).toEqual({ + dynamic: [ + { + path: '/dynamic/:id', + dynamic: true, + pattern: '^/dynamic/([^/]+)$', + paramNames: ['id'], + }, + { + path: '/users/:id', + dynamic: true, + pattern: '^/users/([^/]+)$', + paramNames: ['id'], + }, + { + path: '/users/:id/posts/:postId', + dynamic: true, + pattern: '^/users/([^/]+)/posts/([^/]+)$', + paramNames: ['id', 'postId'], + }, + { + path: '/users/:id/settings', + dynamic: true, + pattern: '^/users/([^/]+)/settings$', + paramNames: ['id'], + }, + ], + static: [ + { path: '/', dynamic: false }, + { path: '/static/nested', dynamic: false }, + ], + }); + }); + + test('should generate correct pattern for single dynamic route', () => { + const singleDynamic = manifest.dynamic.find(route => route.path === '/dynamic/:id'); + const regex = new RegExp(singleDynamic?.pattern ?? ''); + expect(regex.test('/dynamic/123')).toBe(true); + expect(regex.test('/dynamic/abc')).toBe(true); + expect(regex.test('/dynamic/123/456')).toBe(false); + expect(regex.test('/dynamic123/123')).toBe(false); + expect(regex.test('/')).toBe(false); + }); + + test('should generate correct pattern for mixed static-dynamic route', () => { + const mixedRoute = manifest.dynamic.find(route => route.path === '/users/:id/settings'); + const regex = new RegExp(mixedRoute?.pattern ?? ''); + + expect(regex.test('/users/123/settings')).toBe(true); + expect(regex.test('/users/john-doe/settings')).toBe(true); + expect(regex.test('/users/123/settings/extra')).toBe(false); + expect(regex.test('/users/123')).toBe(false); + expect(regex.test('/settings')).toBe(false); + }); + + test('should generate correct pattern for multiple dynamic segments', () => { + const multiDynamic = manifest.dynamic.find(route => route.path === '/users/:id/posts/:postId'); + const regex = new RegExp(multiDynamic?.pattern ?? ''); + + expect(regex.test('/users/123/posts/456')).toBe(true); + expect(regex.test('/users/john/posts/my-post')).toBe(true); + expect(regex.test('/users/123/posts/456/comments')).toBe(false); + expect(regex.test('/users/123/posts')).toBe(false); + expect(regex.test('/users/123')).toBe(false); + + const match = '/users/123/posts/456'.match(regex); + expect(match).toBeTruthy(); + expect(match?.[1]).toBe('123'); + expect(match?.[2]).toBe('456'); + }); + + test('should handle special characters in dynamic segments', () => { + // Test that dynamic segments with special characters work properly + const userSettingsRoute = manifest.dynamic.find(route => route.path === '/users/:id/settings'); + expect(userSettingsRoute).toBeDefined(); + expect(userSettingsRoute?.pattern).toBeDefined(); + + const regex = new RegExp(userSettingsRoute!.pattern!); + expect(regex.test('/users/user-with-dashes/settings')).toBe(true); + expect(regex.test('/users/user_with_underscores/settings')).toBe(true); + expect(regex.test('/users/123/settings')).toBe(true); + expect(regex.test('/users/123/settings/extra')).toBe(false); + }); +}); diff --git a/packages/nextjs/test/config/manifest/suites/file-extensions/app/javascript/component.tsx b/packages/nextjs/test/config/manifest/suites/file-extensions/app/javascript/component.tsx new file mode 100644 index 000000000000..71f2fabe4ab9 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/file-extensions/app/javascript/component.tsx @@ -0,0 +1 @@ +// Component file - should be ignored diff --git a/packages/nextjs/test/config/manifest/suites/file-extensions/app/javascript/page.js b/packages/nextjs/test/config/manifest/suites/file-extensions/app/javascript/page.js new file mode 100644 index 000000000000..648c2fc1a572 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/file-extensions/app/javascript/page.js @@ -0,0 +1 @@ +// JavaScript page diff --git a/packages/nextjs/test/config/manifest/suites/file-extensions/app/jsx-route/page.jsx b/packages/nextjs/test/config/manifest/suites/file-extensions/app/jsx-route/page.jsx new file mode 100644 index 000000000000..de9dad9da3f1 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/file-extensions/app/jsx-route/page.jsx @@ -0,0 +1 @@ +// JSX page diff --git a/packages/nextjs/test/config/manifest/suites/file-extensions/app/layout.tsx b/packages/nextjs/test/config/manifest/suites/file-extensions/app/layout.tsx new file mode 100644 index 000000000000..126ada0403af --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/file-extensions/app/layout.tsx @@ -0,0 +1 @@ +// Layout file - should be ignored diff --git a/packages/nextjs/test/config/manifest/suites/file-extensions/app/mixed/page.jsx b/packages/nextjs/test/config/manifest/suites/file-extensions/app/mixed/page.jsx new file mode 100644 index 000000000000..de9dad9da3f1 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/file-extensions/app/mixed/page.jsx @@ -0,0 +1 @@ +// JSX page diff --git a/packages/nextjs/test/config/manifest/suites/file-extensions/app/mixed/page.ts b/packages/nextjs/test/config/manifest/suites/file-extensions/app/mixed/page.ts new file mode 100644 index 000000000000..9d0c3f668b0f --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/file-extensions/app/mixed/page.ts @@ -0,0 +1 @@ +// TypeScript page diff --git a/packages/nextjs/test/config/manifest/suites/file-extensions/app/page.tsx b/packages/nextjs/test/config/manifest/suites/file-extensions/app/page.tsx new file mode 100644 index 000000000000..7c6102bf4455 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/file-extensions/app/page.tsx @@ -0,0 +1 @@ +// Root page - TypeScript JSX diff --git a/packages/nextjs/test/config/manifest/suites/file-extensions/app/precedence/page.js b/packages/nextjs/test/config/manifest/suites/file-extensions/app/precedence/page.js new file mode 100644 index 000000000000..c88b431881a8 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/file-extensions/app/precedence/page.js @@ -0,0 +1 @@ +// JavaScript page - should be ignored if tsx exists diff --git a/packages/nextjs/test/config/manifest/suites/file-extensions/app/precedence/page.tsx b/packages/nextjs/test/config/manifest/suites/file-extensions/app/precedence/page.tsx new file mode 100644 index 000000000000..b94cece5634c --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/file-extensions/app/precedence/page.tsx @@ -0,0 +1 @@ +// TypeScript JSX page - should take precedence diff --git a/packages/nextjs/test/config/manifest/suites/file-extensions/app/typescript/other.ts b/packages/nextjs/test/config/manifest/suites/file-extensions/app/typescript/other.ts new file mode 100644 index 000000000000..1838a98702b5 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/file-extensions/app/typescript/other.ts @@ -0,0 +1 @@ +// Other TypeScript file - should be ignored diff --git a/packages/nextjs/test/config/manifest/suites/file-extensions/app/typescript/page.ts b/packages/nextjs/test/config/manifest/suites/file-extensions/app/typescript/page.ts new file mode 100644 index 000000000000..9d0c3f668b0f --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/file-extensions/app/typescript/page.ts @@ -0,0 +1 @@ +// TypeScript page diff --git a/packages/nextjs/test/config/manifest/suites/file-extensions/file-extensions.test.ts b/packages/nextjs/test/config/manifest/suites/file-extensions/file-extensions.test.ts new file mode 100644 index 000000000000..b6a7e6a2b728 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/file-extensions/file-extensions.test.ts @@ -0,0 +1,21 @@ +import path from 'path'; +import { describe, expect, test } from 'vitest'; +import { createRouteManifest } from '../../../../../src/config/manifest/build-manifest'; + +describe('file-extensions', () => { + const manifest = createRouteManifest({ appDirPath: path.join(__dirname, 'app') }); + + test('should detect page files with all supported extensions', () => { + expect(manifest).toEqual({ + dynamic: [], + static: [ + { path: '/', dynamic: false }, + { path: '/javascript', dynamic: false }, + { path: '/jsx-route', dynamic: false }, + { path: '/mixed', dynamic: false }, + { path: '/precedence', dynamic: false }, + { path: '/typescript', dynamic: false }, + ], + }); + }); +}); diff --git a/packages/nextjs/test/config/manifest/suites/route-groups/app/(auth)/layout.tsx b/packages/nextjs/test/config/manifest/suites/route-groups/app/(auth)/layout.tsx new file mode 100644 index 000000000000..c946d3fcf92f --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/route-groups/app/(auth)/layout.tsx @@ -0,0 +1 @@ +// Auth layout diff --git a/packages/nextjs/test/config/manifest/suites/route-groups/app/(auth)/login/page.tsx b/packages/nextjs/test/config/manifest/suites/route-groups/app/(auth)/login/page.tsx new file mode 100644 index 000000000000..ca3839e2b57a --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/route-groups/app/(auth)/login/page.tsx @@ -0,0 +1 @@ +// Login page diff --git a/packages/nextjs/test/config/manifest/suites/route-groups/app/(auth)/signup/page.tsx b/packages/nextjs/test/config/manifest/suites/route-groups/app/(auth)/signup/page.tsx new file mode 100644 index 000000000000..9283a04ddf23 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/route-groups/app/(auth)/signup/page.tsx @@ -0,0 +1 @@ +// Signup page diff --git a/packages/nextjs/test/config/manifest/suites/route-groups/app/(dashboard)/dashboard/[id]/page.tsx b/packages/nextjs/test/config/manifest/suites/route-groups/app/(dashboard)/dashboard/[id]/page.tsx new file mode 100644 index 000000000000..f5c50b6ae225 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/route-groups/app/(dashboard)/dashboard/[id]/page.tsx @@ -0,0 +1 @@ +// Dynamic dashboard page diff --git a/packages/nextjs/test/config/manifest/suites/route-groups/app/(dashboard)/dashboard/page.tsx b/packages/nextjs/test/config/manifest/suites/route-groups/app/(dashboard)/dashboard/page.tsx new file mode 100644 index 000000000000..76e06b75c3d1 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/route-groups/app/(dashboard)/dashboard/page.tsx @@ -0,0 +1 @@ +// Dashboard page diff --git a/packages/nextjs/test/config/manifest/suites/route-groups/app/(dashboard)/layout.tsx b/packages/nextjs/test/config/manifest/suites/route-groups/app/(dashboard)/layout.tsx new file mode 100644 index 000000000000..0277c5a9bfce --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/route-groups/app/(dashboard)/layout.tsx @@ -0,0 +1 @@ +// Dashboard layout diff --git a/packages/nextjs/test/config/manifest/suites/route-groups/app/(dashboard)/settings/layout.tsx b/packages/nextjs/test/config/manifest/suites/route-groups/app/(dashboard)/settings/layout.tsx new file mode 100644 index 000000000000..80acdce1ca66 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/route-groups/app/(dashboard)/settings/layout.tsx @@ -0,0 +1 @@ +// Settings layout diff --git a/packages/nextjs/test/config/manifest/suites/route-groups/app/(dashboard)/settings/profile/page.tsx b/packages/nextjs/test/config/manifest/suites/route-groups/app/(dashboard)/settings/profile/page.tsx new file mode 100644 index 000000000000..f715804e06c7 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/route-groups/app/(dashboard)/settings/profile/page.tsx @@ -0,0 +1 @@ +// Settings profile page diff --git a/packages/nextjs/test/config/manifest/suites/route-groups/app/(marketing)/layout.tsx b/packages/nextjs/test/config/manifest/suites/route-groups/app/(marketing)/layout.tsx new file mode 100644 index 000000000000..3242dbd8f393 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/route-groups/app/(marketing)/layout.tsx @@ -0,0 +1 @@ +// Marketing layout diff --git a/packages/nextjs/test/config/manifest/suites/route-groups/app/(marketing)/public/about/page.tsx b/packages/nextjs/test/config/manifest/suites/route-groups/app/(marketing)/public/about/page.tsx new file mode 100644 index 000000000000..8088810f2e5a --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/route-groups/app/(marketing)/public/about/page.tsx @@ -0,0 +1 @@ +// About page diff --git a/packages/nextjs/test/config/manifest/suites/route-groups/app/layout.tsx b/packages/nextjs/test/config/manifest/suites/route-groups/app/layout.tsx new file mode 100644 index 000000000000..0490aba2e801 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/route-groups/app/layout.tsx @@ -0,0 +1 @@ +// Root layout diff --git a/packages/nextjs/test/config/manifest/suites/route-groups/app/page.tsx b/packages/nextjs/test/config/manifest/suites/route-groups/app/page.tsx new file mode 100644 index 000000000000..7a8d1d44737d --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/route-groups/app/page.tsx @@ -0,0 +1 @@ +// Root 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 new file mode 100644 index 000000000000..9a882992b8a1 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/route-groups/route-groups.test.ts @@ -0,0 +1,36 @@ +import path from 'path'; +import { describe, expect, test } from 'vitest'; +import { createRouteManifest } from '../../../../../src/config/manifest/build-manifest'; + +describe('route-groups', () => { + const manifest = createRouteManifest({ appDirPath: path.join(__dirname, 'app') }); + + test('should generate a manifest with route groups', () => { + expect(manifest).toEqual({ + dynamic: [ + { + path: '/dashboard/:id', + dynamic: true, + pattern: '^/dashboard/([^/]+)$', + paramNames: ['id'], + }, + ], + static: [ + { path: '/', dynamic: false }, + { path: '/login', dynamic: false }, + { path: '/signup', dynamic: false }, + { path: '/dashboard', dynamic: false }, + { path: '/settings/profile', dynamic: false }, + { path: '/public/about', dynamic: false }, + ], + }); + }); + + test('should handle dynamic routes within route groups', () => { + const dynamicRoute = manifest.dynamic.find(route => route.path.includes('/dashboard/:id')); + const regex = new RegExp(dynamicRoute?.pattern ?? ''); + expect(regex.test('/dashboard/123')).toBe(true); + expect(regex.test('/dashboard/abc')).toBe(true); + expect(regex.test('/dashboard/123/456')).toBe(false); + }); +}); diff --git a/packages/nextjs/test/config/manifest/suites/static/app/page.tsx b/packages/nextjs/test/config/manifest/suites/static/app/page.tsx new file mode 100644 index 000000000000..2145a5eea70d --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/static/app/page.tsx @@ -0,0 +1 @@ +// Ciao diff --git a/packages/nextjs/test/config/manifest/suites/static/app/some/nested/page.tsx b/packages/nextjs/test/config/manifest/suites/static/app/some/nested/page.tsx new file mode 100644 index 000000000000..c3a94a1cb9e7 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/static/app/some/nested/page.tsx @@ -0,0 +1 @@ +// Hola diff --git a/packages/nextjs/test/config/manifest/suites/static/app/user/page.tsx b/packages/nextjs/test/config/manifest/suites/static/app/user/page.tsx new file mode 100644 index 000000000000..5d33b5d14573 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/static/app/user/page.tsx @@ -0,0 +1 @@ +// beep diff --git a/packages/nextjs/test/config/manifest/suites/static/app/users/page.tsx b/packages/nextjs/test/config/manifest/suites/static/app/users/page.tsx new file mode 100644 index 000000000000..6723592cc451 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/static/app/users/page.tsx @@ -0,0 +1 @@ +// boop diff --git a/packages/nextjs/test/config/manifest/suites/static/static.test.ts b/packages/nextjs/test/config/manifest/suites/static/static.test.ts new file mode 100644 index 000000000000..72b97018bed8 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/static/static.test.ts @@ -0,0 +1,18 @@ +import path from 'path'; +import { describe, expect, test } from 'vitest'; +import { createRouteManifest } from '../../../../../src/config/manifest/build-manifest'; + +describe('simple', () => { + test('should generate a static manifest', () => { + const manifest = createRouteManifest({ appDirPath: path.join(__dirname, 'app') }); + expect(manifest).toEqual({ + dynamic: [], + static: [ + { path: '/', dynamic: false }, + { path: '/some/nested', dynamic: false }, + { path: '/user', dynamic: false }, + { path: '/users', dynamic: false }, + ], + }); + }); +}); From 657dd550b6ec5028cc0d97998ca29924be3a67b5 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 9 Jul 2025 00:21:35 +0200 Subject: [PATCH 03/35] rename file --- .../{build-manifest.ts => buildManifest.ts} | 0 .../fixtures/static copy/static.test.ts | 18 ++++++++++++++++++ .../manifest/suites/catchall/catchall.test.ts | 2 +- .../manifest/suites/dynamic/dynamic.test.ts | 2 +- .../file-extensions/file-extensions.test.ts | 2 +- .../suites/route-groups/route-groups.test.ts | 2 +- .../manifest/suites/static/static.test.ts | 4 ++-- 7 files changed, 24 insertions(+), 6 deletions(-) rename packages/nextjs/src/config/manifest/{build-manifest.ts => buildManifest.ts} (100%) create mode 100644 packages/nextjs/test/config/manifest/fixtures/static copy/static.test.ts diff --git a/packages/nextjs/src/config/manifest/build-manifest.ts b/packages/nextjs/src/config/manifest/buildManifest.ts similarity index 100% rename from packages/nextjs/src/config/manifest/build-manifest.ts rename to packages/nextjs/src/config/manifest/buildManifest.ts diff --git a/packages/nextjs/test/config/manifest/fixtures/static copy/static.test.ts b/packages/nextjs/test/config/manifest/fixtures/static copy/static.test.ts new file mode 100644 index 000000000000..07399f317736 --- /dev/null +++ b/packages/nextjs/test/config/manifest/fixtures/static copy/static.test.ts @@ -0,0 +1,18 @@ +import path from 'path'; +import { describe, expect, test } from 'vitest'; +import { createRouteManifest } from '../../../../../src/config/manifest/buildManifest'; + +describe('simple', () => { + test('should generate a static manifest', () => { + const manifest = createRouteManifest({ appDirPath: path.join(__dirname, 'app') }); + expect(manifest).toEqual({ + dynamic: [], + static: [ + { path: '/', dynamic: false }, + { path: '/some/nested', dynamic: false }, + { path: '/user', dynamic: false }, + { path: '/users', dynamic: false }, + ], + }); + }); +}); diff --git a/packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts b/packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts index aaa4a900f67f..0504e8176988 100644 --- a/packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts +++ b/packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts @@ -1,6 +1,6 @@ import path from 'path'; import { describe, expect, test } from 'vitest'; -import { createRouteManifest } from '../../../../../src/config/manifest/build-manifest'; +import { createRouteManifest } from '../../../../../src/config/manifest/buildManifest'; describe('catchall', () => { const manifest = createRouteManifest({ appDirPath: path.join(__dirname, 'app') }); diff --git a/packages/nextjs/test/config/manifest/suites/dynamic/dynamic.test.ts b/packages/nextjs/test/config/manifest/suites/dynamic/dynamic.test.ts index 267f5aa374f1..bf92a9d2bcbe 100644 --- a/packages/nextjs/test/config/manifest/suites/dynamic/dynamic.test.ts +++ b/packages/nextjs/test/config/manifest/suites/dynamic/dynamic.test.ts @@ -1,6 +1,6 @@ import path from 'path'; import { describe, expect, test } from 'vitest'; -import { createRouteManifest } from '../../../../../src/config/manifest/build-manifest'; +import { createRouteManifest } from '../../../../../src/config/manifest/buildManifest'; describe('dynamic', () => { const manifest = createRouteManifest({ appDirPath: path.join(__dirname, 'app') }); diff --git a/packages/nextjs/test/config/manifest/suites/file-extensions/file-extensions.test.ts b/packages/nextjs/test/config/manifest/suites/file-extensions/file-extensions.test.ts index b6a7e6a2b728..f2bef75e8784 100644 --- a/packages/nextjs/test/config/manifest/suites/file-extensions/file-extensions.test.ts +++ b/packages/nextjs/test/config/manifest/suites/file-extensions/file-extensions.test.ts @@ -1,6 +1,6 @@ import path from 'path'; import { describe, expect, test } from 'vitest'; -import { createRouteManifest } from '../../../../../src/config/manifest/build-manifest'; +import { createRouteManifest } from '../../../../../src/config/manifest/buildManifest'; describe('file-extensions', () => { const manifest = createRouteManifest({ appDirPath: path.join(__dirname, 'app') }); 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 9a882992b8a1..e033bff2a8bc 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 @@ -1,6 +1,6 @@ import path from 'path'; import { describe, expect, test } from 'vitest'; -import { createRouteManifest } from '../../../../../src/config/manifest/build-manifest'; +import { createRouteManifest } from '../../../../../src/config/manifest/buildManifest'; describe('route-groups', () => { const manifest = createRouteManifest({ appDirPath: path.join(__dirname, 'app') }); diff --git a/packages/nextjs/test/config/manifest/suites/static/static.test.ts b/packages/nextjs/test/config/manifest/suites/static/static.test.ts index 72b97018bed8..7f612e3ba86b 100644 --- a/packages/nextjs/test/config/manifest/suites/static/static.test.ts +++ b/packages/nextjs/test/config/manifest/suites/static/static.test.ts @@ -1,8 +1,8 @@ import path from 'path'; import { describe, expect, test } from 'vitest'; -import { createRouteManifest } from '../../../../../src/config/manifest/build-manifest'; +import { createRouteManifest } from '../../../../../src/config/manifest/buildManifest'; -describe('simple', () => { +describe('static', () => { test('should generate a static manifest', () => { const manifest = createRouteManifest({ appDirPath: path.join(__dirname, 'app') }); expect(manifest).toEqual({ From a82b977e8576acd8a2f444d1a3f555bb5d97f394 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 9 Jul 2025 00:30:15 +0200 Subject: [PATCH 04/35] simplify page check --- .../nextjs/src/config/manifest/buildManifest.ts | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/packages/nextjs/src/config/manifest/buildManifest.ts b/packages/nextjs/src/config/manifest/buildManifest.ts index 070abc5c3e9c..30186d522cb8 100644 --- a/packages/nextjs/src/config/manifest/buildManifest.ts +++ b/packages/nextjs/src/config/manifest/buildManifest.ts @@ -83,7 +83,7 @@ function scanAppDirectory(dir: string, basePath: string = ''): RouteInfo[] { try { const entries = fs.readdirSync(dir, { withFileTypes: true }); - const pageFile = getHighestPriorityPageFile(entries); + const pageFile = entries.some(entry => isPageFile(entry.name)); if (pageFile) { const routePath = basePath || '/'; @@ -138,20 +138,6 @@ function scanAppDirectory(dir: string, basePath: string = ''): RouteInfo[] { return routes; } -function getHighestPriorityPageFile(entries: fs.Dirent[]): string | null { - // Next.js precedence order: .tsx > .ts > .jsx > .js - const pageFiles = entries.filter(entry => entry.isFile() && isPageFile(entry.name)).map(entry => entry.name); - - if (pageFiles.length === 0) return null; - - if (pageFiles.includes('page.tsx')) return 'page.tsx'; - if (pageFiles.includes('page.ts')) return 'page.ts'; - if (pageFiles.includes('page.jsx')) return 'page.jsx'; - if (pageFiles.includes('page.js')) return 'page.js'; - - return null; -} - /** * Returns a route manifest for the given app directory */ From 7d6e198450065231fad5344bf5a82a6512ac17c0 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 9 Jul 2025 10:29:32 +0200 Subject: [PATCH 05/35] que --- .../fixtures/static copy/static.test.ts | 18 ------------------ 1 file changed, 18 deletions(-) delete mode 100644 packages/nextjs/test/config/manifest/fixtures/static copy/static.test.ts diff --git a/packages/nextjs/test/config/manifest/fixtures/static copy/static.test.ts b/packages/nextjs/test/config/manifest/fixtures/static copy/static.test.ts deleted file mode 100644 index 07399f317736..000000000000 --- a/packages/nextjs/test/config/manifest/fixtures/static copy/static.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import path from 'path'; -import { describe, expect, test } from 'vitest'; -import { createRouteManifest } from '../../../../../src/config/manifest/buildManifest'; - -describe('simple', () => { - test('should generate a static manifest', () => { - const manifest = createRouteManifest({ appDirPath: path.join(__dirname, 'app') }); - expect(manifest).toEqual({ - dynamic: [], - static: [ - { path: '/', dynamic: false }, - { path: '/some/nested', dynamic: false }, - { path: '/user', dynamic: false }, - { path: '/users', dynamic: false }, - ], - }); - }); -}); From 71b1121bdb025088ca82be8116103b4653f4f0b4 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 9 Jul 2025 11:11:58 +0200 Subject: [PATCH 06/35] fix handling optional catchall routes --- .../nextjs/src/config/manifest/buildManifest.ts | 15 +++++++++++++-- .../manifest/suites/catchall/catchall.test.ts | 3 ++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/nextjs/src/config/manifest/buildManifest.ts b/packages/nextjs/src/config/manifest/buildManifest.ts index 30186d522cb8..9c309bc3098e 100644 --- a/packages/nextjs/src/config/manifest/buildManifest.ts +++ b/packages/nextjs/src/config/manifest/buildManifest.ts @@ -48,6 +48,7 @@ function buildRegexForDynamicRoute(routePath: string): { pattern: string; paramN const segments = routePath.split('/').filter(Boolean); const regexSegments: string[] = []; const paramNames: string[] = []; + let hasOptionalCatchall = false; for (const segment of segments) { if (segment.startsWith(':')) { @@ -57,7 +58,8 @@ function buildRegexForDynamicRoute(routePath: string): { pattern: string; paramN // Optional catchall: matches zero or more segments const cleanParamName = paramName.slice(0, -2); paramNames.push(cleanParamName); - regexSegments.push('(.*)'); + // Handling this special case in pattern construction below + hasOptionalCatchall = true; } else if (paramName.endsWith('*')) { // Required catchall: matches one or more segments const cleanParamName = paramName.slice(0, -1); @@ -74,7 +76,16 @@ function buildRegexForDynamicRoute(routePath: string): { pattern: string; paramN } } - const pattern = `^/${regexSegments.join('/')}$`; + let pattern: string; + if (hasOptionalCatchall) { + // For optional catchall, make the trailing slash and segments optional + // This allows matching both /catchall and /catchall/anything + const staticParts = regexSegments.join('/'); + pattern = `^/${staticParts}(?:/(.*))?$`; + } else { + pattern = `^/${regexSegments.join('/')}$`; + } + return { pattern, paramNames }; } diff --git a/packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts b/packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts index 0504e8176988..2709d41dca12 100644 --- a/packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts +++ b/packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts @@ -11,7 +11,7 @@ describe('catchall', () => { { path: '/catchall/:path*?', dynamic: true, - pattern: '^/catchall/(.*)$', + pattern: '^/catchall(?:/(.*))?$', paramNames: ['path'], }, ], @@ -26,6 +26,7 @@ describe('catchall', () => { expect(regex.test('/catchall/123/456')).toBe(true); expect(regex.test('/catchall/123/abc/789')).toBe(true); expect(regex.test('/catchall/')).toBe(true); + expect(regex.test('/catchall')).toBe(true); expect(regex.test('/123/catchall/123')).toBe(false); expect(regex.test('/')).toBe(false); }); From 3114f5c01088f429c246ec0adabd72556224fdf3 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 9 Jul 2025 12:40:49 +0200 Subject: [PATCH 07/35] update if else block Co-authored-by: Brice Friha <37577669+bricefriha@users.noreply.github.com> --- packages/nextjs/src/config/manifest/buildManifest.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/nextjs/src/config/manifest/buildManifest.ts b/packages/nextjs/src/config/manifest/buildManifest.ts index 9c309bc3098e..6de12d1bfd6b 100644 --- a/packages/nextjs/src/config/manifest/buildManifest.ts +++ b/packages/nextjs/src/config/manifest/buildManifest.ts @@ -38,10 +38,9 @@ function getDynamicRouteSegment(name: string): string { // Required catchall: [...param] const paramName = name.slice(4, -1); // Remove [... and ] return `:${paramName}*`; - } else { - // Regular dynamic: [param] - return `:${name.slice(1, -1)}`; - } + } + // Regular dynamic: [param] + return `:${name.slice(1, -1)}`; } function buildRegexForDynamicRoute(routePath: string): { pattern: string; paramNames: string[] } { From 75305f5180dcbbe00ddbad004fba4fc119161189 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 9 Jul 2025 12:43:48 +0200 Subject: [PATCH 08/35] .. --- packages/nextjs/src/config/manifest/buildManifest.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/nextjs/src/config/manifest/buildManifest.ts b/packages/nextjs/src/config/manifest/buildManifest.ts index 6de12d1bfd6b..e00f50585ae1 100644 --- a/packages/nextjs/src/config/manifest/buildManifest.ts +++ b/packages/nextjs/src/config/manifest/buildManifest.ts @@ -38,9 +38,9 @@ function getDynamicRouteSegment(name: string): string { // Required catchall: [...param] const paramName = name.slice(4, -1); // Remove [... and ] return `:${paramName}*`; - } - // Regular dynamic: [param] - return `:${name.slice(1, -1)}`; + } + // Regular dynamic: [param] + return `:${name.slice(1, -1)}`; } function buildRegexForDynamicRoute(routePath: string): { pattern: string; paramNames: string[] } { From 4ad310f5e2ead2b5fe83d004bcb2bb0ae5ec6216 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 9 Jul 2025 12:51:47 +0200 Subject: [PATCH 09/35] inject manifest --- packages/nextjs/src/config/types.ts | 7 +++++++ packages/nextjs/src/config/webpack.ts | 6 +++++- packages/nextjs/src/config/withSentryConfig.ts | 14 +++++++++++++- 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/packages/nextjs/src/config/types.ts b/packages/nextjs/src/config/types.ts index bf9531a9ddd6..52ba1e57acf5 100644 --- a/packages/nextjs/src/config/types.ts +++ b/packages/nextjs/src/config/types.ts @@ -467,6 +467,13 @@ export type SentryBuildOptions = { */ suppressOnRouterTransitionStartWarning?: boolean; + /** + * Enables manifest injection. + * + * Defaults to `true`. + */ + enableManifest?: boolean; + /** * Contains a set of experimental flags that might change in future releases. These flags enable * features that are still in development and may be modified, renamed, or removed without notice. diff --git a/packages/nextjs/src/config/webpack.ts b/packages/nextjs/src/config/webpack.ts index bcfbbd2e151f..defcee1f7132 100644 --- a/packages/nextjs/src/config/webpack.ts +++ b/packages/nextjs/src/config/webpack.ts @@ -7,6 +7,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { sync as resolveSync } from 'resolve'; import type { VercelCronsConfig } from '../common/types'; +import type { RouteManifest } from './manifest/buildManifest'; // Note: If you need to import a type from Webpack, do it in `types.ts` and export it from there. Otherwise, our // circular dependency check thinks this file is importing from itself. See https://github.com/pahen/madge/issues/306. import type { @@ -43,6 +44,7 @@ export function constructWebpackConfigFunction( userNextConfig: NextConfigObject = {}, userSentryOptions: SentryBuildOptions = {}, releaseName: string | undefined, + routeManifest: RouteManifest | undefined, ): WebpackConfigFunction { // Will be called by nextjs and passed its default webpack configuration and context data about the build (whether // we're building server or client, whether we're in dev, what version of webpack we're using, etc). Note that @@ -88,7 +90,7 @@ export function constructWebpackConfigFunction( const newConfig = setUpModuleRules(rawNewConfig); // Add a loader which will inject code that sets global values - addValueInjectionLoader(newConfig, userNextConfig, userSentryOptions, buildContext, releaseName); + addValueInjectionLoader(newConfig, userNextConfig, userSentryOptions, buildContext, releaseName, routeManifest); addOtelWarningIgnoreRule(newConfig); @@ -686,6 +688,7 @@ function addValueInjectionLoader( userSentryOptions: SentryBuildOptions, buildContext: BuildContext, releaseName: string | undefined, + routeManifest: RouteManifest | undefined, ): void { const assetPrefix = userNextConfig.assetPrefix || userNextConfig.basePath || ''; @@ -727,6 +730,7 @@ function addValueInjectionLoader( _sentryExperimentalThirdPartyOriginStackFrames: userSentryOptions._experimental?.thirdPartyOriginStackFrames ? 'true' : undefined, + _sentryRouteManifest: JSON.stringify(routeManifest), }; if (buildContext.isServer) { diff --git a/packages/nextjs/src/config/withSentryConfig.ts b/packages/nextjs/src/config/withSentryConfig.ts index 84bd0a01cb5e..a957071b5d3a 100644 --- a/packages/nextjs/src/config/withSentryConfig.ts +++ b/packages/nextjs/src/config/withSentryConfig.ts @@ -5,6 +5,8 @@ import { getSentryRelease } from '@sentry/node'; import * as childProcess from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; +import type { RouteManifest } from './manifest/buildManifest'; +import { createRouteManifest } from './manifest/buildManifest'; import type { ExportedNextConfig as NextConfig, NextConfigFunction, @@ -141,6 +143,11 @@ function getFinalConfigObject( } } + let routeManifest: RouteManifest | undefined; + if (userSentryOptions.enableManifest !== false) { + routeManifest = createRouteManifest(); + } + setUpBuildTimeVariables(incomingUserNextConfigObject, userSentryOptions, releaseName); const nextJsVersion = getNextjsVersion(); @@ -300,7 +307,12 @@ function getFinalConfigObject( ], }, }), - webpack: constructWebpackConfigFunction(incomingUserNextConfigObject, userSentryOptions, releaseName), + webpack: constructWebpackConfigFunction( + incomingUserNextConfigObject, + userSentryOptions, + releaseName, + routeManifest, + ), }; } From e1056202520eb99d6a1827b4dd03164bd6866d90 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 9 Jul 2025 16:58:34 +0200 Subject: [PATCH 10/35] update interface + rename func --- ...uildManifest.ts => createRouteManifest.ts} | 29 ++++--------- packages/nextjs/src/config/manifest/types.ts | 27 ++++++++++++ .../manifest/suites/catchall/catchall.test.ts | 12 +++--- .../manifest/suites/dynamic/dynamic.test.ts | 42 ++++++++----------- .../file-extensions/file-extensions.test.ts | 17 ++++---- .../suites/route-groups/route-groups.test.ts | 25 +++++------ .../manifest/suites/static/static.test.ts | 10 +---- 7 files changed, 79 insertions(+), 83 deletions(-) rename packages/nextjs/src/config/manifest/{buildManifest.ts => createRouteManifest.ts} (89%) create mode 100644 packages/nextjs/src/config/manifest/types.ts diff --git a/packages/nextjs/src/config/manifest/buildManifest.ts b/packages/nextjs/src/config/manifest/createRouteManifest.ts similarity index 89% rename from packages/nextjs/src/config/manifest/buildManifest.ts rename to packages/nextjs/src/config/manifest/createRouteManifest.ts index e00f50585ae1..12c76f43e39b 100644 --- a/packages/nextjs/src/config/manifest/buildManifest.ts +++ b/packages/nextjs/src/config/manifest/createRouteManifest.ts @@ -1,17 +1,6 @@ import * as fs from 'fs'; import * as path from 'path'; - -export type RouteInfo = { - path: string; - dynamic: boolean; - pattern?: string; - paramNames?: string[]; -}; - -export type RouteManifest = { - dynamic: RouteInfo[]; - static: RouteInfo[]; -}; +import type { RouteInfo, RouteManifest } from './types'; export type CreateRouteManifestOptions = { // For starters we only support app router @@ -43,7 +32,7 @@ function getDynamicRouteSegment(name: string): string { return `:${name.slice(1, -1)}`; } -function buildRegexForDynamicRoute(routePath: string): { pattern: string; paramNames: string[] } { +function buildRegexForDynamicRoute(routePath: string): { regex: string; paramNames: string[] } { const segments = routePath.split('/').filter(Boolean); const regexSegments: string[] = []; const paramNames: string[] = []; @@ -85,7 +74,7 @@ function buildRegexForDynamicRoute(routePath: string): { pattern: string; paramN pattern = `^/${regexSegments.join('/')}$`; } - return { pattern, paramNames }; + return { regex: pattern, paramNames }; } function scanAppDirectory(dir: string, basePath: string = ''): RouteInfo[] { @@ -100,17 +89,15 @@ function scanAppDirectory(dir: string, basePath: string = ''): RouteInfo[] { const isDynamic = routePath.includes(':'); if (isDynamic) { - const { pattern, paramNames } = buildRegexForDynamicRoute(routePath); + const { regex, paramNames } = buildRegexForDynamicRoute(routePath); routes.push({ path: routePath, - dynamic: true, - pattern, + regex, paramNames, }); } else { routes.push({ path: routePath, - dynamic: false, }); } } @@ -170,8 +157,7 @@ export function createRouteManifest(options?: CreateRouteManifestOptions): Route if (!targetDir) { return { - dynamic: [], - static: [], + routes: [], }; } @@ -183,8 +169,7 @@ export function createRouteManifest(options?: CreateRouteManifestOptions): Route const routes = scanAppDirectory(targetDir); const manifest: RouteManifest = { - dynamic: routes.filter(route => route.dynamic), - static: routes.filter(route => !route.dynamic), + routes, }; // set cache diff --git a/packages/nextjs/src/config/manifest/types.ts b/packages/nextjs/src/config/manifest/types.ts new file mode 100644 index 000000000000..a15d2098aebe --- /dev/null +++ b/packages/nextjs/src/config/manifest/types.ts @@ -0,0 +1,27 @@ +/** + * Information about a single route in the manifest + */ +export type RouteInfo = { + /** + * The parameterised route path, e.g. "/users/[id]" + */ + path: string; + /** + * (Optional) The regex pattern for dynamic routes + */ + regex?: string; + /** + * (Optional) The names of dynamic parameters in the route + */ + paramNames?: string[]; +}; + +/** + * The manifest containing all routes discovered in the app + */ +export type RouteManifest = { + /** + * List of all routes (static and dynamic) + */ + routes: RouteInfo[]; +}; diff --git a/packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts b/packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts index 2709d41dca12..435157b07da7 100644 --- a/packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts +++ b/packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts @@ -1,26 +1,26 @@ import path from 'path'; import { describe, expect, test } from 'vitest'; -import { createRouteManifest } from '../../../../../src/config/manifest/buildManifest'; +import { createRouteManifest } from '../../../../../src/config/manifest/createRouteManifest'; describe('catchall', () => { const manifest = createRouteManifest({ appDirPath: path.join(__dirname, 'app') }); test('should generate a manifest with catchall route', () => { expect(manifest).toEqual({ - dynamic: [ + routes: [ + { path: '/' }, { path: '/catchall/:path*?', - dynamic: true, - pattern: '^/catchall(?:/(.*))?$', + regex: '^/catchall(?:/(.*))?$', paramNames: ['path'], }, ], - static: [{ path: '/', dynamic: false }], }); }); test('should generate correct pattern for catchall route', () => { - const regex = new RegExp(manifest.dynamic[0]?.pattern ?? ''); + const catchallRoute = manifest.routes.find(route => route.path === '/catchall/:path*?'); + const regex = new RegExp(catchallRoute?.regex ?? ''); expect(regex.test('/catchall/123')).toBe(true); expect(regex.test('/catchall/abc')).toBe(true); expect(regex.test('/catchall/123/456')).toBe(true); diff --git a/packages/nextjs/test/config/manifest/suites/dynamic/dynamic.test.ts b/packages/nextjs/test/config/manifest/suites/dynamic/dynamic.test.ts index bf92a9d2bcbe..400f8cc84821 100644 --- a/packages/nextjs/test/config/manifest/suites/dynamic/dynamic.test.ts +++ b/packages/nextjs/test/config/manifest/suites/dynamic/dynamic.test.ts @@ -1,48 +1,42 @@ import path from 'path'; import { describe, expect, test } from 'vitest'; -import { createRouteManifest } from '../../../../../src/config/manifest/buildManifest'; +import { createRouteManifest } from '../../../../../src/config/manifest/createRouteManifest'; describe('dynamic', () => { const manifest = createRouteManifest({ appDirPath: path.join(__dirname, 'app') }); - test('should generate a comprehensive dynamic manifest', () => { + test('should generate a dynamic manifest', () => { expect(manifest).toEqual({ - dynamic: [ + routes: [ + { path: '/' }, { path: '/dynamic/:id', - dynamic: true, - pattern: '^/dynamic/([^/]+)$', + regex: '^/dynamic/([^/]+)$', paramNames: ['id'], }, + { path: '/static/nested' }, { path: '/users/:id', - dynamic: true, - pattern: '^/users/([^/]+)$', + regex: '^/users/([^/]+)$', paramNames: ['id'], }, { path: '/users/:id/posts/:postId', - dynamic: true, - pattern: '^/users/([^/]+)/posts/([^/]+)$', + regex: '^/users/([^/]+)/posts/([^/]+)$', paramNames: ['id', 'postId'], }, { path: '/users/:id/settings', - dynamic: true, - pattern: '^/users/([^/]+)/settings$', + regex: '^/users/([^/]+)/settings$', paramNames: ['id'], }, ], - static: [ - { path: '/', dynamic: false }, - { path: '/static/nested', dynamic: false }, - ], }); }); test('should generate correct pattern for single dynamic route', () => { - const singleDynamic = manifest.dynamic.find(route => route.path === '/dynamic/:id'); - const regex = new RegExp(singleDynamic?.pattern ?? ''); + const singleDynamic = manifest.routes.find(route => route.path === '/dynamic/:id'); + const regex = new RegExp(singleDynamic?.regex ?? ''); expect(regex.test('/dynamic/123')).toBe(true); expect(regex.test('/dynamic/abc')).toBe(true); expect(regex.test('/dynamic/123/456')).toBe(false); @@ -51,8 +45,8 @@ describe('dynamic', () => { }); test('should generate correct pattern for mixed static-dynamic route', () => { - const mixedRoute = manifest.dynamic.find(route => route.path === '/users/:id/settings'); - const regex = new RegExp(mixedRoute?.pattern ?? ''); + const mixedRoute = manifest.routes.find(route => route.path === '/users/:id/settings'); + const regex = new RegExp(mixedRoute?.regex ?? ''); expect(regex.test('/users/123/settings')).toBe(true); expect(regex.test('/users/john-doe/settings')).toBe(true); @@ -62,8 +56,8 @@ describe('dynamic', () => { }); test('should generate correct pattern for multiple dynamic segments', () => { - const multiDynamic = manifest.dynamic.find(route => route.path === '/users/:id/posts/:postId'); - const regex = new RegExp(multiDynamic?.pattern ?? ''); + const multiDynamic = manifest.routes.find(route => route.path === '/users/:id/posts/:postId'); + const regex = new RegExp(multiDynamic?.regex ?? ''); expect(regex.test('/users/123/posts/456')).toBe(true); expect(regex.test('/users/john/posts/my-post')).toBe(true); @@ -79,11 +73,11 @@ describe('dynamic', () => { test('should handle special characters in dynamic segments', () => { // Test that dynamic segments with special characters work properly - const userSettingsRoute = manifest.dynamic.find(route => route.path === '/users/:id/settings'); + const userSettingsRoute = manifest.routes.find(route => route.path === '/users/:id/settings'); expect(userSettingsRoute).toBeDefined(); - expect(userSettingsRoute?.pattern).toBeDefined(); + expect(userSettingsRoute?.regex).toBeDefined(); - const regex = new RegExp(userSettingsRoute!.pattern!); + const regex = new RegExp(userSettingsRoute!.regex!); expect(regex.test('/users/user-with-dashes/settings')).toBe(true); expect(regex.test('/users/user_with_underscores/settings')).toBe(true); expect(regex.test('/users/123/settings')).toBe(true); diff --git a/packages/nextjs/test/config/manifest/suites/file-extensions/file-extensions.test.ts b/packages/nextjs/test/config/manifest/suites/file-extensions/file-extensions.test.ts index f2bef75e8784..b646e7fe994d 100644 --- a/packages/nextjs/test/config/manifest/suites/file-extensions/file-extensions.test.ts +++ b/packages/nextjs/test/config/manifest/suites/file-extensions/file-extensions.test.ts @@ -1,20 +1,19 @@ import path from 'path'; import { describe, expect, test } from 'vitest'; -import { createRouteManifest } from '../../../../../src/config/manifest/buildManifest'; +import { createRouteManifest } from '../../../../../src/config/manifest/createRouteManifest'; describe('file-extensions', () => { const manifest = createRouteManifest({ appDirPath: path.join(__dirname, 'app') }); test('should detect page files with all supported extensions', () => { expect(manifest).toEqual({ - dynamic: [], - static: [ - { path: '/', dynamic: false }, - { path: '/javascript', dynamic: false }, - { path: '/jsx-route', dynamic: false }, - { path: '/mixed', dynamic: false }, - { path: '/precedence', dynamic: false }, - { path: '/typescript', dynamic: false }, + routes: [ + { path: '/' }, + { path: '/javascript' }, + { path: '/jsx-route' }, + { path: '/mixed' }, + { path: '/precedence' }, + { path: '/typescript' }, ], }); }); 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 e033bff2a8bc..7caa9621d342 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 @@ -1,34 +1,31 @@ import path from 'path'; import { describe, expect, test } from 'vitest'; -import { createRouteManifest } from '../../../../../src/config/manifest/buildManifest'; +import { createRouteManifest } from '../../../../../src/config/manifest/createRouteManifest'; describe('route-groups', () => { const manifest = createRouteManifest({ appDirPath: path.join(__dirname, 'app') }); test('should generate a manifest with route groups', () => { expect(manifest).toEqual({ - dynamic: [ + routes: [ + { path: '/' }, + { path: '/login' }, + { path: '/signup' }, + { path: '/dashboard' }, { path: '/dashboard/:id', - dynamic: true, - pattern: '^/dashboard/([^/]+)$', + regex: '^/dashboard/([^/]+)$', paramNames: ['id'], }, - ], - static: [ - { path: '/', dynamic: false }, - { path: '/login', dynamic: false }, - { path: '/signup', dynamic: false }, - { path: '/dashboard', dynamic: false }, - { path: '/settings/profile', dynamic: false }, - { path: '/public/about', dynamic: false }, + { path: '/settings/profile' }, + { path: '/public/about' }, ], }); }); test('should handle dynamic routes within route groups', () => { - const dynamicRoute = manifest.dynamic.find(route => route.path.includes('/dashboard/:id')); - const regex = new RegExp(dynamicRoute?.pattern ?? ''); + const dynamicRoute = manifest.routes.find(route => route.path.includes('/dashboard/:id')); + const regex = new RegExp(dynamicRoute?.regex ?? ''); expect(regex.test('/dashboard/123')).toBe(true); expect(regex.test('/dashboard/abc')).toBe(true); expect(regex.test('/dashboard/123/456')).toBe(false); diff --git a/packages/nextjs/test/config/manifest/suites/static/static.test.ts b/packages/nextjs/test/config/manifest/suites/static/static.test.ts index 7f612e3ba86b..ca927457ad5d 100644 --- a/packages/nextjs/test/config/manifest/suites/static/static.test.ts +++ b/packages/nextjs/test/config/manifest/suites/static/static.test.ts @@ -1,18 +1,12 @@ import path from 'path'; import { describe, expect, test } from 'vitest'; -import { createRouteManifest } from '../../../../../src/config/manifest/buildManifest'; +import { createRouteManifest } from '../../../../../src/config/manifest/createRouteManifest'; describe('static', () => { test('should generate a static manifest', () => { const manifest = createRouteManifest({ appDirPath: path.join(__dirname, 'app') }); expect(manifest).toEqual({ - dynamic: [], - static: [ - { path: '/', dynamic: false }, - { path: '/some/nested', dynamic: false }, - { path: '/user', dynamic: false }, - { path: '/users', dynamic: false }, - ], + routes: [{ path: '/' }, { path: '/some/nested' }, { path: '/user' }, { path: '/users' }], }); }); }); From f9430fe48e2f888b73ddf293ad3eb47780d2fd79 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Wed, 9 Jul 2025 18:10:47 +0200 Subject: [PATCH 11/35] rename option --- packages/nextjs/src/config/types.ts | 6 +++--- packages/nextjs/src/config/webpack.ts | 2 +- packages/nextjs/src/config/withSentryConfig.ts | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/nextjs/src/config/types.ts b/packages/nextjs/src/config/types.ts index 52ba1e57acf5..ce893341ca76 100644 --- a/packages/nextjs/src/config/types.ts +++ b/packages/nextjs/src/config/types.ts @@ -468,11 +468,11 @@ export type SentryBuildOptions = { suppressOnRouterTransitionStartWarning?: boolean; /** - * Enables manifest injection. + * Disables manifest injection. * - * Defaults to `true`. + * Defaults to `false`. */ - enableManifest?: boolean; + disableManifestInjection?: boolean; /** * Contains a set of experimental flags that might change in future releases. These flags enable diff --git a/packages/nextjs/src/config/webpack.ts b/packages/nextjs/src/config/webpack.ts index 0531ae0c2839..77db5120cda3 100644 --- a/packages/nextjs/src/config/webpack.ts +++ b/packages/nextjs/src/config/webpack.ts @@ -7,7 +7,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { sync as resolveSync } from 'resolve'; import type { VercelCronsConfig } from '../common/types'; -import type { RouteManifest } from './manifest/buildManifest'; +import type { RouteManifest } from './manifest/types'; // Note: If you need to import a type from Webpack, do it in `types.ts` and export it from there. Otherwise, our // circular dependency check thinks this file is importing from itself. See https://github.com/pahen/madge/issues/306. import type { diff --git a/packages/nextjs/src/config/withSentryConfig.ts b/packages/nextjs/src/config/withSentryConfig.ts index a957071b5d3a..4e231a3227d6 100644 --- a/packages/nextjs/src/config/withSentryConfig.ts +++ b/packages/nextjs/src/config/withSentryConfig.ts @@ -5,8 +5,8 @@ import { getSentryRelease } from '@sentry/node'; import * as childProcess from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; -import type { RouteManifest } from './manifest/buildManifest'; -import { createRouteManifest } from './manifest/buildManifest'; +import { createRouteManifest } from './manifest/createRouteManifest'; +import type { RouteManifest } from './manifest/types'; import type { ExportedNextConfig as NextConfig, NextConfigFunction, @@ -144,7 +144,7 @@ function getFinalConfigObject( } let routeManifest: RouteManifest | undefined; - if (userSentryOptions.enableManifest !== false) { + if (!userSentryOptions.disableManifestInjection) { routeManifest = createRouteManifest(); } From 4c0d0084e9f0246f6d5dfcaff9a794e1857e84b4 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Thu, 10 Jul 2025 17:20:16 +0200 Subject: [PATCH 12/35] add valueinjection to turbopack --- .../turbopack/constructTurbopackConfig.ts | 77 ++++ packages/nextjs/src/config/turbopack/index.ts | 1 + packages/nextjs/src/config/types.ts | 36 ++ .../nextjs/src/config/withSentryConfig.ts | 24 +- .../constructTurbopackConfig.test.ts | 420 ++++++++++++++++++ 5 files changed, 551 insertions(+), 7 deletions(-) create mode 100644 packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts create mode 100644 packages/nextjs/src/config/turbopack/index.ts create mode 100644 packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts diff --git a/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts new file mode 100644 index 000000000000..faa65a79c8ca --- /dev/null +++ b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts @@ -0,0 +1,77 @@ +import * as path from 'path'; +import type { RouteManifest } from '../manifest/types'; +import type { + NextConfigObject, + SentryBuildOptions, + TurbopackOptions, + TurbopackRuleConfigItemOrShortcut, +} from '../types'; + +/** + * Construct a Turbopack config object from a Next.js config object and a Turbopack options object. + * + * @param nextConfig - The Next.js config object. + * @param turbopackOptions - The Turbopack options object. + * @returns The Turbopack config object. + */ +export function constructTurbopackConfig({ + userNextConfig, + manifest, +}: { + userNextConfig: NextConfigObject; + userSentryOptions: SentryBuildOptions; + manifest?: RouteManifest; +}): TurbopackOptions { + const newConfig: TurbopackOptions = { + ...userNextConfig.turbopack, + }; + + if (manifest) { + newConfig.rules = safelyAddTurbopackRule(newConfig.rules, { + matcher: '**/instrumentation-client.*', + rule: { + loaders: [ + { + loader: path.resolve(__dirname, '../loaders/valueInjectionLoader.js'), + options: { + values: { + _sentryRouteManifest: JSON.stringify(manifest), + }, + }, + }, + ], + }, + }); + } + + return newConfig; +} + +/** + * Safely add a Turbopack rule to the existing rules. + * + * @param existingRules - The existing rules. + * @param matcher - The matcher for the rule. + * @param rule - The rule to add. + * @returns The updated rules object. + */ +export function safelyAddTurbopackRule( + existingRules: TurbopackOptions['rules'], + { matcher, rule }: { matcher: string; rule: TurbopackRuleConfigItemOrShortcut }, +): TurbopackOptions['rules'] { + if (!existingRules) { + return { + [matcher]: rule, + }; + } + + // If the rule already exists, we don't want to mess with it. + if (existingRules[matcher]) { + return existingRules; + } + + return { + ...existingRules, + [matcher]: rule, + }; +} diff --git a/packages/nextjs/src/config/turbopack/index.ts b/packages/nextjs/src/config/turbopack/index.ts new file mode 100644 index 000000000000..06fc8bf09293 --- /dev/null +++ b/packages/nextjs/src/config/turbopack/index.ts @@ -0,0 +1 @@ +export * from './constructTurbopackConfig'; diff --git a/packages/nextjs/src/config/types.ts b/packages/nextjs/src/config/types.ts index ce893341ca76..1b026e5ac488 100644 --- a/packages/nextjs/src/config/types.ts +++ b/packages/nextjs/src/config/types.ts @@ -51,6 +51,7 @@ export type NextConfigObject = { // https://nextjs.org/docs/pages/api-reference/next-config-js/env env?: Record; serverExternalPackages?: string[]; // next >= v15.0.0 + turbopack?: TurbopackOptions; }; export type SentryBuildOptions = { @@ -596,3 +597,38 @@ export type EnhancedGlobal = typeof GLOBAL_OBJ & { SENTRY_RELEASE?: { id: string }; SENTRY_RELEASES?: { [key: string]: { id: string } }; }; + +type JSONValue = string | number | boolean | JSONValue[] | { [k: string]: JSONValue }; + +type TurbopackLoaderItem = + | string + | { + loader: string; + // At the moment, Turbopack options must be JSON-serializable, so restrict values. + options: Record; + }; + +type TurbopackRuleCondition = { + path: string | RegExp; +}; + +export type TurbopackRuleConfigItemOrShortcut = TurbopackLoaderItem[] | TurbopackRuleConfigItem; + +type TurbopackRuleConfigItemOptions = { + loaders: TurbopackLoaderItem[]; + as?: string; +}; + +type TurbopackRuleConfigItem = + | TurbopackRuleConfigItemOptions + | { [condition: string]: TurbopackRuleConfigItem } + | false; + +export interface TurbopackOptions { + resolveAlias?: Record>; + resolveExtensions?: string[]; + rules?: Record; + conditions?: Record; + moduleIds?: 'named' | 'deterministic'; + root?: string; +} diff --git a/packages/nextjs/src/config/withSentryConfig.ts b/packages/nextjs/src/config/withSentryConfig.ts index 4e231a3227d6..11a7b3ce8a9b 100644 --- a/packages/nextjs/src/config/withSentryConfig.ts +++ b/packages/nextjs/src/config/withSentryConfig.ts @@ -7,6 +7,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { createRouteManifest } from './manifest/createRouteManifest'; import type { RouteManifest } from './manifest/types'; +import { constructTurbopackConfig } from './turbopack'; import type { ExportedNextConfig as NextConfig, NextConfigFunction, @@ -251,6 +252,8 @@ function getFinalConfigObject( } let nextMajor: number | undefined; + const isTurbopack = process.env.TURBOPACK; + let isTurbopackSupported = false; if (nextJsVersion) { const { major, minor, patch, prerelease } = parseSemver(nextJsVersion); nextMajor = major; @@ -262,6 +265,7 @@ function getFinalConfigObject( (major === 15 && minor > 3) || (major === 15 && minor === 3 && patch === 0 && prerelease === undefined) || (major === 15 && minor === 3 && patch > 0)); + isTurbopackSupported = isSupportedVersion; const isSupportedCanary = major !== undefined && minor !== undefined && @@ -274,7 +278,7 @@ function getFinalConfigObject( parseInt(prerelease.split('.')[1] || '', 10) >= 28; const supportsClientInstrumentation = isSupportedCanary || isSupportedVersion; - if (!supportsClientInstrumentation && process.env.TURBOPACK) { + if (!supportsClientInstrumentation && isTurbopack) { if (process.env.NODE_ENV === 'development') { // eslint-disable-next-line no-console console.warn( @@ -307,12 +311,18 @@ function getFinalConfigObject( ], }, }), - webpack: constructWebpackConfigFunction( - incomingUserNextConfigObject, - userSentryOptions, - releaseName, - routeManifest, - ), + + webpack: !isTurbopack + ? constructWebpackConfigFunction(incomingUserNextConfigObject, userSentryOptions, releaseName, routeManifest) + : undefined, + turbopack: + isTurbopackSupported && isTurbopack + ? constructTurbopackConfig({ + userNextConfig: incomingUserNextConfigObject, + userSentryOptions, + manifest: routeManifest, + }) + : undefined, }; } diff --git a/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts b/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts new file mode 100644 index 000000000000..ddb079da7bd2 --- /dev/null +++ b/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts @@ -0,0 +1,420 @@ +import * as path from 'path'; +import { describe, expect, it, vi } from 'vitest'; +import type { RouteManifest } from '../../../src/config/manifest/types'; +import { + constructTurbopackConfig, + safelyAddTurbopackRule, +} from '../../../src/config/turbopack/constructTurbopackConfig'; +import type { NextConfigObject, SentryBuildOptions } from '../../../src/config/types'; + +// Mock path.resolve to return a predictable loader path +vi.mock('path', async () => { + const actual = await vi.importActual('path'); + return { + ...actual, + resolve: vi.fn().mockReturnValue('/mocked/path/to/valueInjectionLoader.js'), + }; +}); + +describe('constructTurbopackConfig', () => { + const mockRouteManifest: RouteManifest = { + routes: [ + { path: '/users', regex: '/users' }, + { path: '/users/[id]', regex: '/users/([^/]+)', paramNames: ['id'] }, + { path: '/api/health', regex: '/api/health' }, + ], + }; + + const mockSentryOptions: SentryBuildOptions = { + org: 'test-org', + project: 'test-project', + }; + + describe('without existing turbopack config', () => { + it('should create a basic turbopack config when no manifest is provided', () => { + const userNextConfig: NextConfigObject = {}; + + const result = constructTurbopackConfig({ + userNextConfig, + userSentryOptions: mockSentryOptions, + }); + + expect(result).toEqual({}); + }); + + it('should create turbopack config with instrumentation rule when manifest is provided', () => { + const userNextConfig: NextConfigObject = {}; + + const result = constructTurbopackConfig({ + userNextConfig, + userSentryOptions: mockSentryOptions, + manifest: mockRouteManifest, + }); + + expect(result).toEqual({ + rules: { + '**/instrumentation-client.*': { + loaders: [ + { + loader: '/mocked/path/to/valueInjectionLoader.js', + options: { + values: { + _sentryRouteManifest: JSON.stringify(mockRouteManifest), + }, + }, + }, + ], + }, + }, + }); + }); + + it('should call path.resolve with correct arguments', () => { + const userNextConfig: NextConfigObject = {}; + const pathResolveSpy = vi.spyOn(path, 'resolve'); + + constructTurbopackConfig({ + userNextConfig, + userSentryOptions: mockSentryOptions, + manifest: mockRouteManifest, + }); + + expect(pathResolveSpy).toHaveBeenCalledWith(expect.any(String), 'loaders/valueInjectionLoader.js'); + }); + }); + + describe('with existing turbopack config', () => { + it('should preserve existing turbopack config when no manifest is provided', () => { + const userNextConfig: NextConfigObject = { + turbopack: { + resolveAlias: { + '@': './src', + }, + rules: { + '*.test.js': ['jest-loader'], + }, + }, + }; + + const result = constructTurbopackConfig({ + userNextConfig, + userSentryOptions: mockSentryOptions, + }); + + expect(result).toEqual({ + resolveAlias: { + '@': './src', + }, + rules: { + '*.test.js': ['jest-loader'], + }, + }); + }); + + it('should merge manifest rule with existing turbopack config', () => { + const userNextConfig: NextConfigObject = { + turbopack: { + resolveAlias: { + '@': './src', + }, + rules: { + '*.test.js': ['jest-loader'], + }, + }, + }; + + const result = constructTurbopackConfig({ + userNextConfig, + userSentryOptions: mockSentryOptions, + manifest: mockRouteManifest, + }); + + expect(result).toEqual({ + resolveAlias: { + '@': './src', + }, + rules: { + '*.test.js': ['jest-loader'], + '**/instrumentation-client.*': { + loaders: [ + { + loader: '/mocked/path/to/valueInjectionLoader.js', + options: { + values: { + _sentryRouteManifest: JSON.stringify(mockRouteManifest), + }, + }, + }, + ], + }, + }, + }); + }); + + it('should not override existing instrumentation rule', () => { + const existingRule = { + loaders: [ + { + loader: '/existing/loader.js', + options: { custom: 'value' }, + }, + ], + }; + + const userNextConfig: NextConfigObject = { + turbopack: { + rules: { + '**/instrumentation-client.*': existingRule, + }, + }, + }; + + const result = constructTurbopackConfig({ + userNextConfig, + userSentryOptions: mockSentryOptions, + manifest: mockRouteManifest, + }); + + expect(result).toEqual({ + rules: { + '**/instrumentation-client.*': existingRule, + }, + }); + }); + }); + + describe('with edge cases', () => { + it('should handle empty route manifest', () => { + const userNextConfig: NextConfigObject = {}; + const emptyManifest: RouteManifest = { routes: [] }; + + const result = constructTurbopackConfig({ + userNextConfig, + userSentryOptions: mockSentryOptions, + manifest: emptyManifest, + }); + + expect(result).toEqual({ + rules: { + '**/instrumentation-client.*': { + loaders: [ + { + loader: '/mocked/path/to/valueInjectionLoader.js', + options: { + values: { + _sentryRouteManifest: JSON.stringify(emptyManifest), + }, + }, + }, + ], + }, + }, + }); + }); + + it('should handle complex route manifest', () => { + const userNextConfig: NextConfigObject = {}; + const complexManifest: RouteManifest = { + routes: [ + { path: '/users/[id]/posts/[postId]', regex: '/users/([^/]+)/posts/([^/]+)', paramNames: ['id', 'postId'] }, + { path: '/api/[...params]', regex: '/api/(.+)', paramNames: ['params'] }, + ], + }; + + const result = constructTurbopackConfig({ + userNextConfig, + userSentryOptions: mockSentryOptions, + manifest: complexManifest, + }); + + expect(result).toEqual({ + rules: { + '**/instrumentation-client.*': { + loaders: [ + { + loader: '/mocked/path/to/valueInjectionLoader.js', + options: { + values: { + _sentryRouteManifest: JSON.stringify(complexManifest), + }, + }, + }, + ], + }, + }, + }); + }); + }); +}); + +describe('safelyAddTurbopackRule', () => { + const mockRule = { + loaders: [ + { + loader: '/test/loader.js', + options: { test: 'value' }, + }, + ], + }; + + describe('with undefined/null existingRules', () => { + it('should create new rules object when existingRules is undefined', () => { + const result = safelyAddTurbopackRule(undefined, { + matcher: '*.test.js', + rule: mockRule, + }); + + expect(result).toEqual({ + '*.test.js': mockRule, + }); + }); + + it('should create new rules object when existingRules is null', () => { + const result = safelyAddTurbopackRule(null as any, { + matcher: '*.test.js', + rule: mockRule, + }); + + expect(result).toEqual({ + '*.test.js': mockRule, + }); + }); + }); + + describe('with existing rules', () => { + it('should add new rule to existing rules object', () => { + const existingRules = { + '*.css': ['css-loader'], + '*.scss': ['sass-loader'], + }; + + const result = safelyAddTurbopackRule(existingRules, { + matcher: '*.test.js', + rule: mockRule, + }); + + expect(result).toEqual({ + '*.css': ['css-loader'], + '*.scss': ['sass-loader'], + '*.test.js': mockRule, + }); + }); + + it('should not override existing rule with same matcher', () => { + const existingRule = { + loaders: [ + { + loader: '/existing/loader.js', + options: { existing: 'option' }, + }, + ], + }; + + const existingRules = { + '*.css': ['css-loader'], + '*.test.js': existingRule, + }; + + const result = safelyAddTurbopackRule(existingRules, { + matcher: '*.test.js', + rule: mockRule, + }); + + expect(result).toEqual({ + '*.css': ['css-loader'], + '*.test.js': existingRule, + }); + }); + + it('should handle empty rules object', () => { + const existingRules = {}; + + const result = safelyAddTurbopackRule(existingRules, { + matcher: '*.test.js', + rule: mockRule, + }); + + expect(result).toEqual({ + '*.test.js': mockRule, + }); + }); + }); + + describe('with different rule formats', () => { + it('should handle string array rule (shortcut format)', () => { + const existingRules = { + '*.css': ['css-loader'], + }; + + const result = safelyAddTurbopackRule(existingRules, { + matcher: '*.test.js', + rule: ['jest-loader', 'babel-loader'], + }); + + expect(result).toEqual({ + '*.css': ['css-loader'], + '*.test.js': ['jest-loader', 'babel-loader'], + }); + }); + + it('should handle complex rule with conditions', () => { + const existingRules = { + '*.css': ['css-loader'], + }; + + const complexRule = { + loaders: [ + { + loader: '/test/loader.js', + options: { test: 'value' }, + }, + ], + as: 'javascript/auto', + }; + + const result = safelyAddTurbopackRule(existingRules, { + matcher: '*.test.js', + rule: complexRule, + }); + + expect(result).toEqual({ + '*.css': ['css-loader'], + '*.test.js': complexRule, + }); + }); + + it('should handle disabled rule (false)', () => { + const existingRules = { + '*.css': ['css-loader'], + }; + + const result = safelyAddTurbopackRule(existingRules, { + matcher: '*.test.js', + rule: false, + }); + + expect(result).toEqual({ + '*.css': ['css-loader'], + '*.test.js': false, + }); + }); + }); + + describe('immutable', () => { + it('should not mutate original existingRules object', () => { + const existingRules = { + '*.css': ['css-loader'], + }; + + const result = safelyAddTurbopackRule(existingRules, { + matcher: '*.test.js', + rule: mockRule, + }); + + expect(result).toEqual({ + '*.css': ['css-loader'], + '*.test.js': mockRule, + }); + }); + }); +}); From de44fe06d0d9afe3b8ec7778cf9f4531f042d126 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Thu, 10 Jul 2025 17:24:31 +0200 Subject: [PATCH 13/35] merge stuff --- .../config/turbopack/constructTurbopackConfig.ts | 8 ++++---- packages/nextjs/src/config/withSentryConfig.ts | 2 +- .../turbopack/constructTurbopackConfig.test.ts | 14 +++++++------- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts index faa65a79c8ca..9d78c43c957b 100644 --- a/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts +++ b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts @@ -16,17 +16,17 @@ import type { */ export function constructTurbopackConfig({ userNextConfig, - manifest, + routeManifest, }: { userNextConfig: NextConfigObject; userSentryOptions: SentryBuildOptions; - manifest?: RouteManifest; + routeManifest?: RouteManifest; }): TurbopackOptions { const newConfig: TurbopackOptions = { ...userNextConfig.turbopack, }; - if (manifest) { + if (routeManifest) { newConfig.rules = safelyAddTurbopackRule(newConfig.rules, { matcher: '**/instrumentation-client.*', rule: { @@ -35,7 +35,7 @@ export function constructTurbopackConfig({ loader: path.resolve(__dirname, '../loaders/valueInjectionLoader.js'), options: { values: { - _sentryRouteManifest: JSON.stringify(manifest), + _sentryRouteManifest: JSON.stringify(routeManifest), }, }, }, diff --git a/packages/nextjs/src/config/withSentryConfig.ts b/packages/nextjs/src/config/withSentryConfig.ts index 11a7b3ce8a9b..7eb8e8874ab9 100644 --- a/packages/nextjs/src/config/withSentryConfig.ts +++ b/packages/nextjs/src/config/withSentryConfig.ts @@ -320,7 +320,7 @@ function getFinalConfigObject( ? constructTurbopackConfig({ userNextConfig: incomingUserNextConfigObject, userSentryOptions, - manifest: routeManifest, + routeManifest, }) : undefined, }; diff --git a/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts b/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts index ddb079da7bd2..b5f5898f3645 100644 --- a/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts +++ b/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts @@ -48,7 +48,7 @@ describe('constructTurbopackConfig', () => { const result = constructTurbopackConfig({ userNextConfig, userSentryOptions: mockSentryOptions, - manifest: mockRouteManifest, + routeManifest: mockRouteManifest, }); expect(result).toEqual({ @@ -76,10 +76,10 @@ describe('constructTurbopackConfig', () => { constructTurbopackConfig({ userNextConfig, userSentryOptions: mockSentryOptions, - manifest: mockRouteManifest, + routeManifest: mockRouteManifest, }); - expect(pathResolveSpy).toHaveBeenCalledWith(expect.any(String), 'loaders/valueInjectionLoader.js'); + expect(pathResolveSpy).toHaveBeenCalledWith(expect.any(String), '../loaders/valueInjectionLoader.js'); }); }); @@ -126,7 +126,7 @@ describe('constructTurbopackConfig', () => { const result = constructTurbopackConfig({ userNextConfig, userSentryOptions: mockSentryOptions, - manifest: mockRouteManifest, + routeManifest: mockRouteManifest, }); expect(result).toEqual({ @@ -172,7 +172,7 @@ describe('constructTurbopackConfig', () => { const result = constructTurbopackConfig({ userNextConfig, userSentryOptions: mockSentryOptions, - manifest: mockRouteManifest, + routeManifest: mockRouteManifest, }); expect(result).toEqual({ @@ -191,7 +191,7 @@ describe('constructTurbopackConfig', () => { const result = constructTurbopackConfig({ userNextConfig, userSentryOptions: mockSentryOptions, - manifest: emptyManifest, + routeManifest: emptyManifest, }); expect(result).toEqual({ @@ -224,7 +224,7 @@ describe('constructTurbopackConfig', () => { const result = constructTurbopackConfig({ userNextConfig, userSentryOptions: mockSentryOptions, - manifest: complexManifest, + routeManifest: complexManifest, }); expect(result).toEqual({ From 6071f76a5f1156e2871c058ff74d24cc13ead5d5 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Fri, 11 Jul 2025 11:05:04 +0200 Subject: [PATCH 14/35] split routes again --- .../config/manifest/createRouteManifest.ts | 56 +++++++++++-------- packages/nextjs/src/config/manifest/types.ts | 9 ++- .../manifest/suites/catchall/catchall.test.ts | 6 +- .../dynamic/app/dynamic/static/page.tsx | 1 + .../manifest/suites/dynamic/dynamic.test.ts | 14 ++--- .../file-extensions/file-extensions.test.ts | 3 +- .../suites/route-groups/route-groups.test.ts | 10 ++-- .../manifest/suites/static/static.test.ts | 3 +- 8 files changed, 60 insertions(+), 42 deletions(-) create mode 100644 packages/nextjs/test/config/manifest/suites/dynamic/app/dynamic/static/page.tsx diff --git a/packages/nextjs/src/config/manifest/createRouteManifest.ts b/packages/nextjs/src/config/manifest/createRouteManifest.ts index 12c76f43e39b..3723fbe77ce2 100644 --- a/packages/nextjs/src/config/manifest/createRouteManifest.ts +++ b/packages/nextjs/src/config/manifest/createRouteManifest.ts @@ -18,6 +18,11 @@ function isRouteGroup(name: string): boolean { return name.startsWith('(') && name.endsWith(')'); } +function normalizeRoutePath(routePath: string): string { + // Remove route group segments from the path + return routePath.replace(/\/\([^)]+\)/g, ''); +} + function getDynamicRouteSegment(name: string): string { if (name.startsWith('[[...') && name.endsWith(']]')) { // Optional catchall: [[...param]] @@ -77,27 +82,32 @@ function buildRegexForDynamicRoute(routePath: string): { regex: string; paramNam return { regex: pattern, paramNames }; } -function scanAppDirectory(dir: string, basePath: string = ''): RouteInfo[] { - const routes: RouteInfo[] = []; +function scanAppDirectory( + dir: string, + basePath: string = '', +): { dynamicRoutes: RouteInfo[]; staticRoutes: RouteInfo[] } { + const dynamicRoutes: RouteInfo[] = []; + const staticRoutes: RouteInfo[] = []; try { const entries = fs.readdirSync(dir, { withFileTypes: true }); const pageFile = entries.some(entry => isPageFile(entry.name)); if (pageFile) { - const routePath = basePath || '/'; - const isDynamic = routePath.includes(':'); + // Normalize the path by removing route groups when creating the final route + const normalizedRoutePath = normalizeRoutePath(basePath || '/'); + const isDynamic = normalizedRoutePath.includes(':'); if (isDynamic) { - const { regex, paramNames } = buildRegexForDynamicRoute(routePath); - routes.push({ - path: routePath, + const { regex, paramNames } = buildRegexForDynamicRoute(normalizedRoutePath); + dynamicRoutes.push({ + path: normalizedRoutePath, regex, paramNames, }); } else { - routes.push({ - path: routePath, + staticRoutes.push({ + path: normalizedRoutePath, }); } } @@ -105,18 +115,14 @@ function scanAppDirectory(dir: string, basePath: string = ''): RouteInfo[] { for (const entry of entries) { if (entry.isDirectory()) { const fullPath = path.join(dir, entry.name); - - if (isRouteGroup(entry.name)) { - // Route groups don't affect the URL, just scan them - const subRoutes = scanAppDirectory(fullPath, basePath); - routes.push(...subRoutes); - continue; - } + let routeSegment: string; const isDynamic = entry.name.startsWith('[') && entry.name.endsWith(']'); - let routeSegment: string; + const isRouteGroupDir = isRouteGroup(entry.name); - if (isDynamic) { + if (isRouteGroupDir) { + routeSegment = entry.name; + } else if (isDynamic) { routeSegment = getDynamicRouteSegment(entry.name); } else { routeSegment = entry.name; @@ -124,7 +130,9 @@ function scanAppDirectory(dir: string, basePath: string = ''): RouteInfo[] { const newBasePath = `${basePath}/${routeSegment}`; const subRoutes = scanAppDirectory(fullPath, newBasePath); - routes.push(...subRoutes); + + dynamicRoutes.push(...subRoutes.dynamicRoutes); + staticRoutes.push(...subRoutes.staticRoutes); } } } catch (error) { @@ -132,7 +140,7 @@ function scanAppDirectory(dir: string, basePath: string = ''): RouteInfo[] { console.warn('Error building route manifest:', error); } - return routes; + return { dynamicRoutes, staticRoutes }; } /** @@ -157,7 +165,8 @@ export function createRouteManifest(options?: CreateRouteManifestOptions): Route if (!targetDir) { return { - routes: [], + dynamicRoutes: [], + staticRoutes: [], }; } @@ -166,10 +175,11 @@ export function createRouteManifest(options?: CreateRouteManifestOptions): Route return manifestCache; } - const routes = scanAppDirectory(targetDir); + const { dynamicRoutes, staticRoutes } = scanAppDirectory(targetDir); const manifest: RouteManifest = { - routes, + dynamicRoutes, + staticRoutes, }; // set cache diff --git a/packages/nextjs/src/config/manifest/types.ts b/packages/nextjs/src/config/manifest/types.ts index a15d2098aebe..e3a26adfce2f 100644 --- a/packages/nextjs/src/config/manifest/types.ts +++ b/packages/nextjs/src/config/manifest/types.ts @@ -21,7 +21,12 @@ export type RouteInfo = { */ export type RouteManifest = { /** - * List of all routes (static and dynamic) + * List of all dynamic routes */ - routes: RouteInfo[]; + dynamicRoutes: RouteInfo[]; + + /** + * List of all static routes + */ + staticRoutes: RouteInfo[]; }; diff --git a/packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts b/packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts index 435157b07da7..b1c417970ba4 100644 --- a/packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts +++ b/packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts @@ -7,8 +7,8 @@ describe('catchall', () => { test('should generate a manifest with catchall route', () => { expect(manifest).toEqual({ - routes: [ - { path: '/' }, + staticRoutes: [{ path: '/' }], + dynamicRoutes: [ { path: '/catchall/:path*?', regex: '^/catchall(?:/(.*))?$', @@ -19,7 +19,7 @@ describe('catchall', () => { }); test('should generate correct pattern for catchall route', () => { - const catchallRoute = manifest.routes.find(route => route.path === '/catchall/:path*?'); + const catchallRoute = manifest.dynamicRoutes.find(route => route.path === '/catchall/:path*?'); const regex = new RegExp(catchallRoute?.regex ?? ''); expect(regex.test('/catchall/123')).toBe(true); expect(regex.test('/catchall/abc')).toBe(true); diff --git a/packages/nextjs/test/config/manifest/suites/dynamic/app/dynamic/static/page.tsx b/packages/nextjs/test/config/manifest/suites/dynamic/app/dynamic/static/page.tsx new file mode 100644 index 000000000000..f0ba5f3c3b70 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/dynamic/app/dynamic/static/page.tsx @@ -0,0 +1 @@ +// Static diff --git a/packages/nextjs/test/config/manifest/suites/dynamic/dynamic.test.ts b/packages/nextjs/test/config/manifest/suites/dynamic/dynamic.test.ts index 400f8cc84821..fdcae299d7cf 100644 --- a/packages/nextjs/test/config/manifest/suites/dynamic/dynamic.test.ts +++ b/packages/nextjs/test/config/manifest/suites/dynamic/dynamic.test.ts @@ -7,14 +7,13 @@ describe('dynamic', () => { test('should generate a dynamic manifest', () => { expect(manifest).toEqual({ - routes: [ - { path: '/' }, + staticRoutes: [{ path: '/' }, { path: '/dynamic/static' }, { path: '/static/nested' }], + dynamicRoutes: [ { path: '/dynamic/:id', regex: '^/dynamic/([^/]+)$', paramNames: ['id'], }, - { path: '/static/nested' }, { path: '/users/:id', regex: '^/users/([^/]+)$', @@ -35,7 +34,7 @@ describe('dynamic', () => { }); test('should generate correct pattern for single dynamic route', () => { - const singleDynamic = manifest.routes.find(route => route.path === '/dynamic/:id'); + const singleDynamic = manifest.dynamicRoutes.find(route => route.path === '/dynamic/:id'); const regex = new RegExp(singleDynamic?.regex ?? ''); expect(regex.test('/dynamic/123')).toBe(true); expect(regex.test('/dynamic/abc')).toBe(true); @@ -45,7 +44,7 @@ describe('dynamic', () => { }); test('should generate correct pattern for mixed static-dynamic route', () => { - const mixedRoute = manifest.routes.find(route => route.path === '/users/:id/settings'); + const mixedRoute = manifest.dynamicRoutes.find(route => route.path === '/users/:id/settings'); const regex = new RegExp(mixedRoute?.regex ?? ''); expect(regex.test('/users/123/settings')).toBe(true); @@ -56,7 +55,7 @@ describe('dynamic', () => { }); test('should generate correct pattern for multiple dynamic segments', () => { - const multiDynamic = manifest.routes.find(route => route.path === '/users/:id/posts/:postId'); + const multiDynamic = manifest.dynamicRoutes.find(route => route.path === '/users/:id/posts/:postId'); const regex = new RegExp(multiDynamic?.regex ?? ''); expect(regex.test('/users/123/posts/456')).toBe(true); @@ -72,8 +71,7 @@ describe('dynamic', () => { }); test('should handle special characters in dynamic segments', () => { - // Test that dynamic segments with special characters work properly - const userSettingsRoute = manifest.routes.find(route => route.path === '/users/:id/settings'); + const userSettingsRoute = manifest.dynamicRoutes.find(route => route.path === '/users/:id/settings'); expect(userSettingsRoute).toBeDefined(); expect(userSettingsRoute?.regex).toBeDefined(); diff --git a/packages/nextjs/test/config/manifest/suites/file-extensions/file-extensions.test.ts b/packages/nextjs/test/config/manifest/suites/file-extensions/file-extensions.test.ts index b646e7fe994d..2c898b1e8e96 100644 --- a/packages/nextjs/test/config/manifest/suites/file-extensions/file-extensions.test.ts +++ b/packages/nextjs/test/config/manifest/suites/file-extensions/file-extensions.test.ts @@ -7,7 +7,7 @@ describe('file-extensions', () => { test('should detect page files with all supported extensions', () => { expect(manifest).toEqual({ - routes: [ + staticRoutes: [ { path: '/' }, { path: '/javascript' }, { path: '/jsx-route' }, @@ -15,6 +15,7 @@ describe('file-extensions', () => { { path: '/precedence' }, { path: '/typescript' }, ], + dynamicRoutes: [], }); }); }); 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 7caa9621d342..74f12514469e 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 @@ -7,24 +7,26 @@ describe('route-groups', () => { test('should generate a manifest with route groups', () => { expect(manifest).toEqual({ - routes: [ + staticRoutes: [ { path: '/' }, { path: '/login' }, { path: '/signup' }, { path: '/dashboard' }, + { path: '/settings/profile' }, + { path: '/public/about' }, + ], + dynamicRoutes: [ { path: '/dashboard/:id', regex: '^/dashboard/([^/]+)$', paramNames: ['id'], }, - { path: '/settings/profile' }, - { path: '/public/about' }, ], }); }); test('should handle dynamic routes within route groups', () => { - const dynamicRoute = manifest.routes.find(route => route.path.includes('/dashboard/:id')); + const dynamicRoute = manifest.dynamicRoutes.find(route => route.path.includes('/dashboard/:id')); const regex = new RegExp(dynamicRoute?.regex ?? ''); expect(regex.test('/dashboard/123')).toBe(true); expect(regex.test('/dashboard/abc')).toBe(true); diff --git a/packages/nextjs/test/config/manifest/suites/static/static.test.ts b/packages/nextjs/test/config/manifest/suites/static/static.test.ts index ca927457ad5d..a6f03f49b6fe 100644 --- a/packages/nextjs/test/config/manifest/suites/static/static.test.ts +++ b/packages/nextjs/test/config/manifest/suites/static/static.test.ts @@ -6,7 +6,8 @@ describe('static', () => { test('should generate a static manifest', () => { const manifest = createRouteManifest({ appDirPath: path.join(__dirname, 'app') }); expect(manifest).toEqual({ - routes: [{ path: '/' }, { path: '/some/nested' }, { path: '/user' }, { path: '/users' }], + staticRoutes: [{ path: '/' }, { path: '/some/nested' }, { path: '/user' }, { path: '/users' }], + dynamicRoutes: [], }); }); }); From e416a2c89afe78ecba33932980020d71d9407a75 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Fri, 11 Jul 2025 11:34:55 +0200 Subject: [PATCH 15/35] add option for route-groups --- .../config/manifest/createRouteManifest.ts | 36 ++++-- .../suites/route-groups/route-groups.test.ts | 107 ++++++++++++++---- 2 files changed, 106 insertions(+), 37 deletions(-) diff --git a/packages/nextjs/src/config/manifest/createRouteManifest.ts b/packages/nextjs/src/config/manifest/createRouteManifest.ts index 3723fbe77ce2..4df71b389c8b 100644 --- a/packages/nextjs/src/config/manifest/createRouteManifest.ts +++ b/packages/nextjs/src/config/manifest/createRouteManifest.ts @@ -5,10 +5,16 @@ import type { RouteInfo, RouteManifest } from './types'; export type CreateRouteManifestOptions = { // For starters we only support app router appDirPath?: string; + /** + * Whether to include route groups (e.g., (auth-layout)) in the final route paths. + * By default, route groups are stripped from paths following Next.js convention. + */ + includeRouteGroups?: boolean; }; let manifestCache: RouteManifest | null = null; let lastAppDirPath: string | null = null; +let lastIncludeRouteGroups: boolean | undefined = undefined; function isPageFile(filename: string): boolean { return filename === 'page.tsx' || filename === 'page.jsx' || filename === 'page.ts' || filename === 'page.js'; @@ -64,7 +70,7 @@ function buildRegexForDynamicRoute(routePath: string): { regex: string; paramNam regexSegments.push('([^/]+)'); } } else { - // Static segment + // Static segment - escape regex special characters including route group parentheses regexSegments.push(segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); } } @@ -85,6 +91,7 @@ function buildRegexForDynamicRoute(routePath: string): { regex: string; paramNam function scanAppDirectory( dir: string, basePath: string = '', + includeRouteGroups: boolean = false, ): { dynamicRoutes: RouteInfo[]; staticRoutes: RouteInfo[] } { const dynamicRoutes: RouteInfo[] = []; const staticRoutes: RouteInfo[] = []; @@ -94,20 +101,20 @@ function scanAppDirectory( const pageFile = entries.some(entry => isPageFile(entry.name)); if (pageFile) { - // Normalize the path by removing route groups when creating the final route - const normalizedRoutePath = normalizeRoutePath(basePath || '/'); - const isDynamic = normalizedRoutePath.includes(':'); + // Conditionally normalize the path based on includeRouteGroups option + const routePath = includeRouteGroups ? basePath || '/' : normalizeRoutePath(basePath || '/'); + const isDynamic = routePath.includes(':'); if (isDynamic) { - const { regex, paramNames } = buildRegexForDynamicRoute(normalizedRoutePath); + const { regex, paramNames } = buildRegexForDynamicRoute(routePath); dynamicRoutes.push({ - path: normalizedRoutePath, + path: routePath, regex, paramNames, }); } else { staticRoutes.push({ - path: normalizedRoutePath, + path: routePath, }); } } @@ -121,15 +128,19 @@ function scanAppDirectory( const isRouteGroupDir = isRouteGroup(entry.name); if (isRouteGroupDir) { - routeSegment = entry.name; + if (includeRouteGroups) { + routeSegment = entry.name; + } else { + routeSegment = ''; + } } else if (isDynamic) { routeSegment = getDynamicRouteSegment(entry.name); } else { routeSegment = entry.name; } - const newBasePath = `${basePath}/${routeSegment}`; - const subRoutes = scanAppDirectory(fullPath, newBasePath); + const newBasePath = routeSegment ? `${basePath}/${routeSegment}` : basePath; + const subRoutes = scanAppDirectory(fullPath, newBasePath, includeRouteGroups); dynamicRoutes.push(...subRoutes.dynamicRoutes); staticRoutes.push(...subRoutes.staticRoutes); @@ -171,11 +182,11 @@ export function createRouteManifest(options?: CreateRouteManifestOptions): Route } // Check if we can use cached version - if (manifestCache && lastAppDirPath === targetDir) { + if (manifestCache && lastAppDirPath === targetDir && lastIncludeRouteGroups === options?.includeRouteGroups) { return manifestCache; } - const { dynamicRoutes, staticRoutes } = scanAppDirectory(targetDir); + const { dynamicRoutes, staticRoutes } = scanAppDirectory(targetDir, '', options?.includeRouteGroups); const manifest: RouteManifest = { dynamicRoutes, @@ -185,6 +196,7 @@ export function createRouteManifest(options?: CreateRouteManifestOptions): Route // set cache manifestCache = manifest; lastAppDirPath = targetDir; + lastIncludeRouteGroups = options?.includeRouteGroups; return manifest; } 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 74f12514469e..36ac9077df7e 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 @@ -3,33 +3,90 @@ import { describe, expect, test } from 'vitest'; import { createRouteManifest } from '../../../../../src/config/manifest/createRouteManifest'; describe('route-groups', () => { - const manifest = createRouteManifest({ appDirPath: path.join(__dirname, 'app') }); - - test('should generate a manifest with route groups', () => { - expect(manifest).toEqual({ - staticRoutes: [ - { path: '/' }, - { path: '/login' }, - { path: '/signup' }, - { path: '/dashboard' }, - { path: '/settings/profile' }, - { path: '/public/about' }, - ], - dynamicRoutes: [ - { - path: '/dashboard/:id', - regex: '^/dashboard/([^/]+)$', - paramNames: ['id'], - }, - ], + const appDirPath = path.join(__dirname, 'app'); + + describe('default behavior (route groups stripped)', () => { + const manifest = createRouteManifest({ appDirPath }); + + test('should generate a manifest with route groups stripped', () => { + expect(manifest).toEqual({ + staticRoutes: [ + { path: '/' }, + { path: '/login' }, + { path: '/signup' }, + { path: '/dashboard' }, + { path: '/settings/profile' }, + { path: '/public/about' }, + ], + dynamicRoutes: [ + { + path: '/dashboard/:id', + regex: '^/dashboard/([^/]+)$', + paramNames: ['id'], + }, + ], + }); + }); + + test('should handle dynamic routes within route groups', () => { + const dynamicRoute = manifest.dynamicRoutes.find(route => route.path.includes('/dashboard/:id')); + const regex = new RegExp(dynamicRoute?.regex ?? ''); + expect(regex.test('/dashboard/123')).toBe(true); + expect(regex.test('/dashboard/abc')).toBe(true); + expect(regex.test('/dashboard/123/456')).toBe(false); }); }); - test('should handle dynamic routes within route groups', () => { - const dynamicRoute = manifest.dynamicRoutes.find(route => route.path.includes('/dashboard/:id')); - const regex = new RegExp(dynamicRoute?.regex ?? ''); - expect(regex.test('/dashboard/123')).toBe(true); - expect(regex.test('/dashboard/abc')).toBe(true); - expect(regex.test('/dashboard/123/456')).toBe(false); + describe('includeRouteGroups: true', () => { + const manifest = createRouteManifest({ appDirPath, includeRouteGroups: true }); + + test('should generate a manifest with route groups included', () => { + expect(manifest).toEqual({ + staticRoutes: [ + { path: '/' }, + { path: '/(auth)/login' }, + { path: '/(auth)/signup' }, + { path: '/(dashboard)/dashboard' }, + { path: '/(dashboard)/settings/profile' }, + { path: '/(marketing)/public/about' }, + ], + dynamicRoutes: [ + { + path: '/(dashboard)/dashboard/:id', + regex: '^/\\(dashboard\\)/dashboard/([^/]+)$', + paramNames: ['id'], + }, + ], + }); + }); + + test('should handle dynamic routes within route groups with proper regex escaping', () => { + const dynamicRoute = manifest.dynamicRoutes.find(route => route.path.includes('/(dashboard)/dashboard/:id')); + const regex = new RegExp(dynamicRoute?.regex ?? ''); + expect(regex.test('/(dashboard)/dashboard/123')).toBe(true); + expect(regex.test('/(dashboard)/dashboard/abc')).toBe(true); + expect(regex.test('/(dashboard)/dashboard/123/456')).toBe(false); + expect(regex.test('/dashboard/123')).toBe(false); // Should not match without route group + }); + + test('should properly extract parameter names from dynamic routes with route groups', () => { + const dynamicRoute = manifest.dynamicRoutes.find(route => route.path.includes('/(dashboard)/dashboard/:id')); + expect(dynamicRoute?.paramNames).toEqual(['id']); + }); + + test('should handle nested static routes within route groups', () => { + const nestedStaticRoute = manifest.staticRoutes.find(route => route.path === '/(dashboard)/settings/profile'); + expect(nestedStaticRoute).toBeDefined(); + }); + + test('should handle multiple route groups correctly', () => { + const authLogin = manifest.staticRoutes.find(route => route.path === '/(auth)/login'); + const authSignup = manifest.staticRoutes.find(route => route.path === '/(auth)/signup'); + const marketingPublic = manifest.staticRoutes.find(route => route.path === '/(marketing)/public/about'); + + expect(authLogin).toBeDefined(); + expect(authSignup).toBeDefined(); + expect(marketingPublic).toBeDefined(); + }); }); }); From 109b8d0947ebc9ddbd47fc507396d7369fcb4e12 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Fri, 11 Jul 2025 15:10:01 +0200 Subject: [PATCH 16/35] whoop whoop --- .../appRouterRoutingInstrumentation.ts | 32 +- .../src/client/routing/parameterization.ts | 38 ++ .../test/client/parameterization.test.ts | 399 ++++++++++++++++++ 3 files changed, 459 insertions(+), 10 deletions(-) create mode 100644 packages/nextjs/src/client/routing/parameterization.ts create mode 100644 packages/nextjs/test/client/parameterization.test.ts diff --git a/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts b/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts index a793a73a4488..0e886d4d038c 100644 --- a/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts +++ b/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts @@ -7,6 +7,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, } from '@sentry/core'; import { startBrowserTracingNavigationSpan, startBrowserTracingPageLoadSpan, WINDOW } from '@sentry/react'; +import { maybeParameterizeRoute } from './parameterization'; export const INCOMPLETE_APP_ROUTER_INSTRUMENTATION_TRANSACTION_NAME = 'incomplete-app-router-transaction'; @@ -34,15 +35,16 @@ const currentRouterPatchingNavigationSpanRef: NavigationSpanRef = { current: und /** Instruments the Next.js app router for pageloads. */ export function appRouterInstrumentPageLoad(client: Client): void { + const parameterizedPathname = maybeParameterizeRoute(WINDOW.location.pathname); const origin = browserPerformanceTimeOrigin(); startBrowserTracingPageLoadSpan(client, { - name: WINDOW.location.pathname, + name: parameterizedPathname ?? WINDOW.location.pathname, // pageload should always start at timeOrigin (and needs to be in s, not ms) startTime: origin ? origin / 1000 : undefined, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'pageload', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.nextjs.app_router_instrumentation', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: parameterizedPathname ? 'route' : 'url', }, }); } @@ -85,7 +87,8 @@ const GLOBAL_OBJ_WITH_NEXT_ROUTER = GLOBAL_OBJ as typeof GLOBAL_OBJ & { /** Instruments the Next.js app router for navigation. */ export function appRouterInstrumentNavigation(client: Client): void { routerTransitionHandler = (href, navigationType) => { - const pathname = new URL(href, WINDOW.location.href).pathname; + const parameterizedPathname = maybeParameterizeRoute(href); + const pathname = parameterizedPathname ?? new URL(href, WINDOW.location.href).pathname; if (navigationRoutingMode === 'router-patch') { navigationRoutingMode = 'transition-start-hook'; @@ -104,7 +107,7 @@ export function appRouterInstrumentNavigation(client: Client): void { attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.nextjs.app_router_instrumentation', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: parameterizedPathname ? 'route' : 'url', 'navigation.type': `router.${navigationType}`, }, }); @@ -112,15 +115,19 @@ export function appRouterInstrumentNavigation(client: Client): void { }; WINDOW.addEventListener('popstate', () => { + const parameterizedPathname = maybeParameterizeRoute(WINDOW.location.pathname); if (currentRouterPatchingNavigationSpanRef.current?.isRecording()) { - currentRouterPatchingNavigationSpanRef.current.updateName(WINDOW.location.pathname); - currentRouterPatchingNavigationSpanRef.current.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'url'); + currentRouterPatchingNavigationSpanRef.current.updateName(parameterizedPathname ?? WINDOW.location.pathname); + currentRouterPatchingNavigationSpanRef.current.setAttribute( + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + parameterizedPathname ? 'route' : 'url', + ); } else { currentRouterPatchingNavigationSpanRef.current = startBrowserTracingNavigationSpan(client, { - name: WINDOW.location.pathname, + name: parameterizedPathname ?? WINDOW.location.pathname, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.nextjs.app_router_instrumentation', - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: parameterizedPathname ? 'route' : 'url', 'navigation.type': 'browser.popstate', }, }); @@ -209,9 +216,14 @@ function patchRouter(client: Client, router: NextRouter, currentNavigationSpanRe transactionAttributes['navigation.type'] = 'router.forward'; } + const parameterizedPathname = maybeParameterizeRoute(transactionName); + currentNavigationSpanRef.current = startBrowserTracingNavigationSpan(client, { - name: transactionName, - attributes: transactionAttributes, + name: parameterizedPathname ?? transactionName, + attributes: { + ...transactionAttributes, + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: parameterizedPathname ? 'route' : 'url', + }, }); return target.apply(thisArg, argArray); diff --git a/packages/nextjs/src/client/routing/parameterization.ts b/packages/nextjs/src/client/routing/parameterization.ts new file mode 100644 index 000000000000..ad3f9a7a4dea --- /dev/null +++ b/packages/nextjs/src/client/routing/parameterization.ts @@ -0,0 +1,38 @@ +import { GLOBAL_OBJ } from '@sentry/core'; +import type { RouteManifest } from '../../config/manifest/types'; + +const globalWithInjectedManifest = GLOBAL_OBJ as typeof GLOBAL_OBJ & { + _sentryRouteManifest: RouteManifest | undefined; +}; + +export const maybeParameterizeRoute = (route: string): string | undefined => { + const manifest = globalWithInjectedManifest._sentryRouteManifest; + + if (!manifest) { + return undefined; + } + + // Static path: no parameterization needed + if (manifest.staticRoutes.some(r => r.path === route)) { + return undefined; + } + + // Dynamic path: find the route pattern that matches the concrete route + for (const dynamicRoute of manifest.dynamicRoutes) { + if (dynamicRoute.regex) { + try { + // eslint-disable-next-line @sentry-internal/sdk/no-regexp-constructor -- regex patterns are from build-time route manifest, not user input + const regex = new RegExp(dynamicRoute.regex); + if (regex.test(route)) { + return dynamicRoute.path; + } + } catch (error) { + // Just skip this route in case of invalid regex + continue; + } + } + } + + // We should never end up here + return undefined; +}; diff --git a/packages/nextjs/test/client/parameterization.test.ts b/packages/nextjs/test/client/parameterization.test.ts new file mode 100644 index 000000000000..cada8f328666 --- /dev/null +++ b/packages/nextjs/test/client/parameterization.test.ts @@ -0,0 +1,399 @@ +import { GLOBAL_OBJ } from '@sentry/core'; +import { afterEach, describe, expect, it } from 'vitest'; +import { maybeParameterizeRoute } from '../../src/client/routing/parameterization'; +import type { RouteManifest } from '../../src/config/manifest/types'; + +const globalWithInjectedManifest = GLOBAL_OBJ as typeof GLOBAL_OBJ & { + _sentryRouteManifest: RouteManifest | undefined; +}; + +describe('maybeParameterizeRoute', () => { + const originalManifest = globalWithInjectedManifest._sentryRouteManifest; + + afterEach(() => { + globalWithInjectedManifest._sentryRouteManifest = originalManifest; + }); + + describe('when no manifest is available', () => { + it('should return undefined', () => { + globalWithInjectedManifest._sentryRouteManifest = undefined; + + expect(maybeParameterizeRoute('/users/123')).toBeUndefined(); + expect(maybeParameterizeRoute('/posts/456/comments')).toBeUndefined(); + expect(maybeParameterizeRoute('/')).toBeUndefined(); + }); + }); + + describe('when manifest has static routes', () => { + it('should return undefined for static routes', () => { + const manifest: RouteManifest = { + staticRoutes: [{ path: '/' }, { path: '/some/nested' }, { path: '/user' }, { path: '/users' }], + dynamicRoutes: [], + }; + globalWithInjectedManifest._sentryRouteManifest = manifest; + + expect(maybeParameterizeRoute('/')).toBeUndefined(); + expect(maybeParameterizeRoute('/some/nested')).toBeUndefined(); + expect(maybeParameterizeRoute('/user')).toBeUndefined(); + expect(maybeParameterizeRoute('/users')).toBeUndefined(); + }); + }); + + describe('when manifest has dynamic routes', () => { + it('should return parameterized routes for matching dynamic routes', () => { + const manifest: RouteManifest = { + staticRoutes: [{ path: '/' }, { path: '/dynamic/static' }, { path: '/static/nested' }], + dynamicRoutes: [ + { + path: '/dynamic/:id', + regex: '^/dynamic/([^/]+)$', + paramNames: ['id'], + }, + { + path: '/users/:id', + regex: '^/users/([^/]+)$', + paramNames: ['id'], + }, + { + path: '/users/:id/posts/:postId', + regex: '^/users/([^/]+)/posts/([^/]+)$', + paramNames: ['id', 'postId'], + }, + { + path: '/users/:id/settings', + regex: '^/users/([^/]+)/settings$', + paramNames: ['id'], + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = manifest; + + expect(maybeParameterizeRoute('/dynamic/123')).toBe('/dynamic/:id'); + expect(maybeParameterizeRoute('/dynamic/abc')).toBe('/dynamic/:id'); + expect(maybeParameterizeRoute('/users/123')).toBe('/users/:id'); + expect(maybeParameterizeRoute('/users/john-doe')).toBe('/users/:id'); + expect(maybeParameterizeRoute('/users/123/posts/456')).toBe('/users/:id/posts/:postId'); + expect(maybeParameterizeRoute('/users/john/posts/my-post')).toBe('/users/:id/posts/:postId'); + expect(maybeParameterizeRoute('/users/123/settings')).toBe('/users/:id/settings'); + expect(maybeParameterizeRoute('/users/john-doe/settings')).toBe('/users/:id/settings'); + }); + + it('should return undefined for static routes even when dynamic routes exist', () => { + const manifest: RouteManifest = { + staticRoutes: [{ path: '/' }, { path: '/dynamic/static' }, { path: '/static/nested' }], + dynamicRoutes: [ + { + path: '/dynamic/:id', + regex: '^/dynamic/([^/]+)$', + paramNames: ['id'], + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = manifest; + + expect(maybeParameterizeRoute('/')).toBeUndefined(); + expect(maybeParameterizeRoute('/dynamic/static')).toBeUndefined(); + expect(maybeParameterizeRoute('/static/nested')).toBeUndefined(); + }); + + it('should handle catchall routes', () => { + const manifest: RouteManifest = { + staticRoutes: [{ path: '/' }], + dynamicRoutes: [ + { + path: '/catchall/:path*?', + regex: '^/catchall(?:/(.*))?$', + paramNames: ['path'], + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = manifest; + + expect(maybeParameterizeRoute('/catchall/123')).toBe('/catchall/:path*?'); + expect(maybeParameterizeRoute('/catchall/abc')).toBe('/catchall/:path*?'); + expect(maybeParameterizeRoute('/catchall/123/456')).toBe('/catchall/:path*?'); + expect(maybeParameterizeRoute('/catchall/123/abc/789')).toBe('/catchall/:path*?'); + expect(maybeParameterizeRoute('/catchall/')).toBe('/catchall/:path*?'); + expect(maybeParameterizeRoute('/catchall')).toBe('/catchall/:path*?'); + }); + + it('should handle route groups when included', () => { + const manifest: RouteManifest = { + staticRoutes: [ + { path: '/' }, + { path: '/(auth)/login' }, + { path: '/(auth)/signup' }, + { path: '/(dashboard)/dashboard' }, + { path: '/(dashboard)/settings/profile' }, + { path: '/(marketing)/public/about' }, + ], + dynamicRoutes: [ + { + path: '/(dashboard)/dashboard/:id', + regex: '^/\\(dashboard\\)/dashboard/([^/]+)$', + paramNames: ['id'], + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = manifest; + + expect(maybeParameterizeRoute('/(auth)/login')).toBeUndefined(); + expect(maybeParameterizeRoute('/(auth)/signup')).toBeUndefined(); + expect(maybeParameterizeRoute('/(dashboard)/dashboard')).toBeUndefined(); + expect(maybeParameterizeRoute('/(dashboard)/settings/profile')).toBeUndefined(); + expect(maybeParameterizeRoute('/(marketing)/public/about')).toBeUndefined(); + expect(maybeParameterizeRoute('/(dashboard)/dashboard/123')).toBe('/(dashboard)/dashboard/:id'); + }); + + it('should handle route groups when stripped (default behavior)', () => { + const manifest: RouteManifest = { + staticRoutes: [ + { path: '/' }, + { path: '/login' }, + { path: '/signup' }, + { path: '/dashboard' }, + { path: '/settings/profile' }, + { path: '/public/about' }, + ], + dynamicRoutes: [ + { + path: '/dashboard/:id', + regex: '^/dashboard/([^/]+)$', + paramNames: ['id'], + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = manifest; + + expect(maybeParameterizeRoute('/login')).toBeUndefined(); + expect(maybeParameterizeRoute('/signup')).toBeUndefined(); + expect(maybeParameterizeRoute('/dashboard')).toBeUndefined(); + expect(maybeParameterizeRoute('/settings/profile')).toBeUndefined(); + expect(maybeParameterizeRoute('/public/about')).toBeUndefined(); + expect(maybeParameterizeRoute('/dashboard/123')).toBe('/dashboard/:id'); + }); + + it('should handle routes with special characters', () => { + const manifest: RouteManifest = { + staticRoutes: [], + dynamicRoutes: [ + { + path: '/users/:id/settings', + regex: '^/users/([^/]+)/settings$', + paramNames: ['id'], + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = manifest; + + expect(maybeParameterizeRoute('/users/user-with-dashes/settings')).toBe('/users/:id/settings'); + expect(maybeParameterizeRoute('/users/user_with_underscores/settings')).toBe('/users/:id/settings'); + expect(maybeParameterizeRoute('/users/123/settings')).toBe('/users/:id/settings'); + }); + + it('should return the first matching dynamic route', () => { + const manifest: RouteManifest = { + staticRoutes: [], + dynamicRoutes: [ + { + path: '/:slug', + regex: '^/([^/]+)$', + paramNames: ['slug'], + }, + { + path: '/users/:id', + regex: '^/users/([^/]+)$', + paramNames: ['id'], + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = manifest; + + expect(maybeParameterizeRoute('/users/123')).toBe('/users/:id'); + expect(maybeParameterizeRoute('/about')).toBe('/:slug'); + }); + + it('should return undefined for dynamic routes without regex', () => { + const manifest: RouteManifest = { + staticRoutes: [], + dynamicRoutes: [ + { + path: '/users/:id', + paramNames: ['id'], + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = manifest; + + expect(maybeParameterizeRoute('/users/123')).toBeUndefined(); + }); + + it('should handle invalid regex patterns gracefully', () => { + const manifest: RouteManifest = { + staticRoutes: [], + dynamicRoutes: [ + { + path: '/users/:id', + regex: '[invalid-regex', + paramNames: ['id'], + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = manifest; + + expect(maybeParameterizeRoute('/users/123')).toBeUndefined(); + }); + }); + + describe('when route does not match any pattern', () => { + it('should return undefined for unknown routes', () => { + const manifest: RouteManifest = { + staticRoutes: [{ path: '/about' }], + dynamicRoutes: [ + { + path: '/users/:id', + regex: '^/users/([^/]+)$', + paramNames: ['id'], + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = manifest; + + expect(maybeParameterizeRoute('/unknown')).toBeUndefined(); + expect(maybeParameterizeRoute('/posts/123')).toBeUndefined(); + expect(maybeParameterizeRoute('/users/123/extra')).toBeUndefined(); + }); + }); + + describe('edge cases', () => { + it('should handle empty route', () => { + const manifest: RouteManifest = { + staticRoutes: [], + dynamicRoutes: [], + }; + globalWithInjectedManifest._sentryRouteManifest = manifest; + + expect(maybeParameterizeRoute('')).toBeUndefined(); + }); + + it('should handle root route', () => { + const manifest: RouteManifest = { + staticRoutes: [{ path: '/' }], + dynamicRoutes: [], + }; + globalWithInjectedManifest._sentryRouteManifest = manifest; + + expect(maybeParameterizeRoute('/')).toBeUndefined(); + }); + + it('should handle complex nested dynamic routes', () => { + const manifest: RouteManifest = { + staticRoutes: [], + dynamicRoutes: [ + { + path: '/api/v1/users/:id/posts/:postId/comments/:commentId', + regex: '^/api/v1/users/([^/]+)/posts/([^/]+)/comments/([^/]+)$', + paramNames: ['id', 'postId', 'commentId'], + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = manifest; + + expect(maybeParameterizeRoute('/api/v1/users/123/posts/456/comments/789')).toBe( + '/api/v1/users/:id/posts/:postId/comments/:commentId', + ); + }); + }); + + describe('realistic Next.js App Router patterns', () => { + it.each([ + ['/', undefined], + ['/some/nested', undefined], + ['/user', undefined], + ['/users', undefined], + ['/dynamic/static', undefined], + ['/static/nested', undefined], + ['/login', undefined], + ['/signup', undefined], + ['/dashboard', undefined], + ['/settings/profile', undefined], + ['/public/about', undefined], + + ['/dynamic/123', '/dynamic/:id'], + ['/dynamic/abc', '/dynamic/:id'], + ['/users/123', '/users/:id'], + ['/users/john-doe', '/users/:id'], + ['/users/123/posts/456', '/users/:id/posts/:postId'], + ['/users/john/posts/my-post', '/users/:id/posts/:postId'], + ['/users/123/settings', '/users/:id/settings'], + ['/users/user-with-dashes/settings', '/users/:id/settings'], + ['/dashboard/123', '/dashboard/:id'], + + ['/catchall/123', '/catchall/:path*?'], + ['/catchall/abc', '/catchall/:path*?'], + ['/catchall/123/456', '/catchall/:path*?'], + ['/catchall/123/abc/789', '/catchall/:path*?'], + ['/catchall/', '/catchall/:path*?'], + ['/catchall', '/catchall/:path*?'], + + ['/unknown-route', undefined], + ['/api/unknown', undefined], + ['/posts/123', undefined], + ])('should handle route "%s" and return %s', (inputRoute, expectedRoute) => { + const manifest: RouteManifest = { + staticRoutes: [ + { path: '/' }, + { path: '/some/nested' }, + { path: '/user' }, + { path: '/users' }, + { path: '/dynamic/static' }, + { path: '/static/nested' }, + { path: '/login' }, + { path: '/signup' }, + { path: '/dashboard' }, + { path: '/settings/profile' }, + { path: '/public/about' }, + ], + dynamicRoutes: [ + { + path: '/dynamic/:id', + regex: '^/dynamic/([^/]+)$', + paramNames: ['id'], + }, + { + path: '/users/:id', + regex: '^/users/([^/]+)$', + paramNames: ['id'], + }, + { + path: '/users/:id/posts/:postId', + regex: '^/users/([^/]+)/posts/([^/]+)$', + paramNames: ['id', 'postId'], + }, + { + path: '/users/:id/settings', + regex: '^/users/([^/]+)/settings$', + paramNames: ['id'], + }, + { + path: '/dashboard/:id', + regex: '^/dashboard/([^/]+)$', + paramNames: ['id'], + }, + { + path: '/catchall/:path*?', + regex: '^/catchall(?:/(.*))?$', + paramNames: ['path'], + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = manifest; + + if (expectedRoute === undefined) { + expect(maybeParameterizeRoute(inputRoute)).toBeUndefined(); + } else { + expect(maybeParameterizeRoute(inputRoute)).toBe(expectedRoute); + } + }); + }); +}); From b9417bc3dfc82b1921b0d2a1e5e7f9239fa689fd Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Sun, 13 Jul 2025 15:06:13 +0200 Subject: [PATCH 17/35] add parse and checks --- .../src/client/routing/parameterization.ts | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/nextjs/src/client/routing/parameterization.ts b/packages/nextjs/src/client/routing/parameterization.ts index ad3f9a7a4dea..f5aa32d098bf 100644 --- a/packages/nextjs/src/client/routing/parameterization.ts +++ b/packages/nextjs/src/client/routing/parameterization.ts @@ -6,9 +6,26 @@ const globalWithInjectedManifest = GLOBAL_OBJ as typeof GLOBAL_OBJ & { }; export const maybeParameterizeRoute = (route: string): string | undefined => { - const manifest = globalWithInjectedManifest._sentryRouteManifest; + if ( + !globalWithInjectedManifest._sentryRouteManifest || + typeof globalWithInjectedManifest._sentryRouteManifest !== 'string' + ) { + return undefined; + } - if (!manifest) { + let manifest: RouteManifest = { + staticRoutes: [], + dynamicRoutes: [], + }; + + // Shallow check if the manifest is actually what we expect it to be + try { + manifest = JSON.parse(globalWithInjectedManifest._sentryRouteManifest); + if (!Array.isArray(manifest.staticRoutes) || !Array.isArray(manifest.dynamicRoutes)) { + return undefined; + } + } catch (error) { + // Something went wrong while parsing the manifest, so we'll fallback to no parameterization return undefined; } From d194ebc7d7aa85fd7d8072d838a5f1a75ef97fcc Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Sun, 13 Jul 2025 15:06:48 +0200 Subject: [PATCH 18/35] add tests for nextjs-13 --- .../parameterized/[one]/beep/[two]/page.tsx | 3 + .../app/parameterized/[one]/beep/page.tsx | 3 + .../app/parameterized/[one]/page.tsx | 3 + .../app/parameterized/static/page.tsx | 7 + .../tests/client/parameterized-routes.test.ts | 189 ++++++++++++++++++ 5 files changed, 205 insertions(+) create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-13/app/parameterized/[one]/beep/[two]/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-13/app/parameterized/[one]/beep/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-13/app/parameterized/[one]/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-13/app/parameterized/static/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-13/tests/client/parameterized-routes.test.ts diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/app/parameterized/[one]/beep/[two]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-13/app/parameterized/[one]/beep/[two]/page.tsx new file mode 100644 index 000000000000..f34461c2bb07 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/app/parameterized/[one]/beep/[two]/page.tsx @@ -0,0 +1,3 @@ +export default function ParameterizedPage() { + return
Dynamic page two
; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/app/parameterized/[one]/beep/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-13/app/parameterized/[one]/beep/page.tsx new file mode 100644 index 000000000000..a7d9164c8c03 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/app/parameterized/[one]/beep/page.tsx @@ -0,0 +1,3 @@ +export default function BeepPage() { + return
Beep
; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/app/parameterized/[one]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-13/app/parameterized/[one]/page.tsx new file mode 100644 index 000000000000..9fa617a22381 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/app/parameterized/[one]/page.tsx @@ -0,0 +1,3 @@ +export default function ParameterizedPage() { + return
Dynamic page one
; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/app/parameterized/static/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-13/app/parameterized/static/page.tsx new file mode 100644 index 000000000000..080e09fe6df2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/app/parameterized/static/page.tsx @@ -0,0 +1,7 @@ +export default function StaticPage() { + return ( +
+ Static page +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-13/tests/client/parameterized-routes.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-13/tests/client/parameterized-routes.test.ts new file mode 100644 index 000000000000..b53cda3ac968 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-13/tests/client/parameterized-routes.test.ts @@ -0,0 +1,189 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('should create a parameterized transaction when the `app` directory is used', async ({ page }) => { + const transactionPromise = waitForTransaction('nextjs-13', async transactionEvent => { + return ( + transactionEvent.transaction === '/parameterized/:one' && transactionEvent.contexts?.trace?.op === 'pageload' + ); + }); + + await page.goto(`/parameterized/cappuccino`); + + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + breadcrumbs: expect.arrayContaining([ + { + category: 'navigation', + data: { from: '/parameterized/cappuccino', to: '/parameterized/cappuccino' }, + timestamp: expect.any(Number), + }, + ]), + contexts: { + react: { version: expect.any(String) }, + trace: { + data: { + 'sentry.op': 'pageload', + 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', + 'sentry.source': 'route', + }, + op: 'pageload', + origin: 'auto.pageload.nextjs.app_router_instrumentation', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }, + }, + environment: 'qa', + request: { + headers: expect.any(Object), + url: expect.stringMatching(/\/parameterized\/cappuccino$/), + }, + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: '/parameterized/:one', + transaction_info: { source: 'route' }, + type: 'transaction', + }); +}); + +test('should create a static transaction when the `app` directory is used and the route is not parameterized', async ({ + page, +}) => { + const transactionPromise = waitForTransaction('nextjs-13', async transactionEvent => { + return ( + transactionEvent.transaction === '/parameterized/static' && transactionEvent.contexts?.trace?.op === 'pageload' + ); + }); + + await page.goto(`/parameterized/static`); + + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + breadcrumbs: expect.arrayContaining([ + { + category: 'navigation', + data: { from: '/parameterized/static', to: '/parameterized/static' }, + timestamp: expect.any(Number), + }, + ]), + contexts: { + react: { version: expect.any(String) }, + trace: { + data: { + 'sentry.op': 'pageload', + 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', + 'sentry.source': 'url', + }, + op: 'pageload', + origin: 'auto.pageload.nextjs.app_router_instrumentation', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }, + }, + environment: 'qa', + request: { + headers: expect.any(Object), + url: expect.stringMatching(/\/parameterized\/static$/), + }, + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: '/parameterized/static', + transaction_info: { source: 'url' }, + type: 'transaction', + }); +}); + +test('should create a partially parameterized transaction when the `app` directory is used', async ({ page }) => { + const transactionPromise = waitForTransaction('nextjs-13', async transactionEvent => { + return ( + transactionEvent.transaction === '/parameterized/:one/beep' && transactionEvent.contexts?.trace?.op === 'pageload' + ); + }); + + await page.goto(`/parameterized/cappuccino/beep`); + + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + breadcrumbs: expect.arrayContaining([ + { + category: 'navigation', + data: { from: '/parameterized/cappuccino/beep', to: '/parameterized/cappuccino/beep' }, + timestamp: expect.any(Number), + }, + ]), + contexts: { + react: { version: expect.any(String) }, + trace: { + data: { + 'sentry.op': 'pageload', + 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', + 'sentry.source': 'route', + }, + op: 'pageload', + origin: 'auto.pageload.nextjs.app_router_instrumentation', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }, + }, + environment: 'qa', + request: { + headers: expect.any(Object), + url: expect.stringMatching(/\/parameterized\/cappuccino\/beep$/), + }, + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: '/parameterized/:one/beep', + transaction_info: { source: 'route' }, + type: 'transaction', + }); +}); + +test('should create a nested parameterized transaction when the `app` directory is used', async ({ page }) => { + const transactionPromise = waitForTransaction('nextjs-13', async transactionEvent => { + return ( + transactionEvent.transaction === '/parameterized/:one/beep/:two' && + transactionEvent.contexts?.trace?.op === 'pageload' + ); + }); + + await page.goto(`/parameterized/cappuccino/beep/espresso`); + + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + breadcrumbs: expect.arrayContaining([ + { + category: 'navigation', + data: { from: '/parameterized/cappuccino/beep/espresso', to: '/parameterized/cappuccino/beep/espresso' }, + timestamp: expect.any(Number), + }, + ]), + contexts: { + react: { version: expect.any(String) }, + trace: { + data: { + 'sentry.op': 'pageload', + 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', + 'sentry.source': 'route', + }, + op: 'pageload', + origin: 'auto.pageload.nextjs.app_router_instrumentation', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }, + }, + environment: 'qa', + request: { + headers: expect.any(Object), + url: expect.stringMatching(/\/parameterized\/cappuccino\/beep\/espresso$/), + }, + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: '/parameterized/:one/beep/:two', + transaction_info: { source: 'route' }, + type: 'transaction', + }); +}); From 5a2294ad19fde4768e30fc031f6e67af860a9889 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Sun, 13 Jul 2025 15:16:26 +0200 Subject: [PATCH 19/35] add tests for nextjs-14 --- .../parameterized/[one]/beep/[two]/page.tsx | 3 + .../app/parameterized/[one]/beep/page.tsx | 3 + .../app/parameterized/[one]/page.tsx | 3 + .../app/parameterized/static/page.tsx | 7 + .../tests/parameterized-routes.test.ts | 189 ++++++++++++++++++ 5 files changed, 205 insertions(+) create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-14/app/parameterized/[one]/beep/[two]/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-14/app/parameterized/[one]/beep/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-14/app/parameterized/[one]/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-14/app/parameterized/static/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-14/tests/parameterized-routes.test.ts diff --git a/dev-packages/e2e-tests/test-applications/nextjs-14/app/parameterized/[one]/beep/[two]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-14/app/parameterized/[one]/beep/[two]/page.tsx new file mode 100644 index 000000000000..f34461c2bb07 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-14/app/parameterized/[one]/beep/[two]/page.tsx @@ -0,0 +1,3 @@ +export default function ParameterizedPage() { + return
Dynamic page two
; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-14/app/parameterized/[one]/beep/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-14/app/parameterized/[one]/beep/page.tsx new file mode 100644 index 000000000000..a7d9164c8c03 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-14/app/parameterized/[one]/beep/page.tsx @@ -0,0 +1,3 @@ +export default function BeepPage() { + return
Beep
; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-14/app/parameterized/[one]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-14/app/parameterized/[one]/page.tsx new file mode 100644 index 000000000000..9fa617a22381 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-14/app/parameterized/[one]/page.tsx @@ -0,0 +1,3 @@ +export default function ParameterizedPage() { + return
Dynamic page one
; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-14/app/parameterized/static/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-14/app/parameterized/static/page.tsx new file mode 100644 index 000000000000..080e09fe6df2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-14/app/parameterized/static/page.tsx @@ -0,0 +1,7 @@ +export default function StaticPage() { + return ( +
+ Static page +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-14/tests/parameterized-routes.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-14/tests/parameterized-routes.test.ts new file mode 100644 index 000000000000..2a5e2910050a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-14/tests/parameterized-routes.test.ts @@ -0,0 +1,189 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('should create a parameterized transaction when the `app` directory is used', async ({ page }) => { + const transactionPromise = waitForTransaction('nextjs-14', async transactionEvent => { + return ( + transactionEvent.transaction === '/parameterized/:one' && transactionEvent.contexts?.trace?.op === 'pageload' + ); + }); + + await page.goto(`/parameterized/cappuccino`); + + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + breadcrumbs: expect.arrayContaining([ + { + category: 'navigation', + data: { from: '/parameterized/cappuccino', to: '/parameterized/cappuccino' }, + timestamp: expect.any(Number), + }, + ]), + contexts: { + react: { version: expect.any(String) }, + trace: { + data: { + 'sentry.op': 'pageload', + 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', + 'sentry.source': 'route', + }, + op: 'pageload', + origin: 'auto.pageload.nextjs.app_router_instrumentation', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }, + }, + environment: 'qa', + request: { + headers: expect.any(Object), + url: expect.stringMatching(/\/parameterized\/cappuccino$/), + }, + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: '/parameterized/:one', + transaction_info: { source: 'route' }, + type: 'transaction', + }); +}); + +test('should create a static transaction when the `app` directory is used and the route is not parameterized', async ({ + page, +}) => { + const transactionPromise = waitForTransaction('nextjs-14', async transactionEvent => { + return ( + transactionEvent.transaction === '/parameterized/static' && transactionEvent.contexts?.trace?.op === 'pageload' + ); + }); + + await page.goto(`/parameterized/static`); + + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + breadcrumbs: expect.arrayContaining([ + { + category: 'navigation', + data: { from: '/parameterized/static', to: '/parameterized/static' }, + timestamp: expect.any(Number), + }, + ]), + contexts: { + react: { version: expect.any(String) }, + trace: { + data: { + 'sentry.op': 'pageload', + 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', + 'sentry.source': 'url', + }, + op: 'pageload', + origin: 'auto.pageload.nextjs.app_router_instrumentation', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }, + }, + environment: 'qa', + request: { + headers: expect.any(Object), + url: expect.stringMatching(/\/parameterized\/static$/), + }, + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: '/parameterized/static', + transaction_info: { source: 'url' }, + type: 'transaction', + }); +}); + +test('should create a partially parameterized transaction when the `app` directory is used', async ({ page }) => { + const transactionPromise = waitForTransaction('nextjs-14', async transactionEvent => { + return ( + transactionEvent.transaction === '/parameterized/:one/beep' && transactionEvent.contexts?.trace?.op === 'pageload' + ); + }); + + await page.goto(`/parameterized/cappuccino/beep`); + + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + breadcrumbs: expect.arrayContaining([ + { + category: 'navigation', + data: { from: '/parameterized/cappuccino/beep', to: '/parameterized/cappuccino/beep' }, + timestamp: expect.any(Number), + }, + ]), + contexts: { + react: { version: expect.any(String) }, + trace: { + data: { + 'sentry.op': 'pageload', + 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', + 'sentry.source': 'route', + }, + op: 'pageload', + origin: 'auto.pageload.nextjs.app_router_instrumentation', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }, + }, + environment: 'qa', + request: { + headers: expect.any(Object), + url: expect.stringMatching(/\/parameterized\/cappuccino\/beep$/), + }, + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: '/parameterized/:one/beep', + transaction_info: { source: 'route' }, + type: 'transaction', + }); +}); + +test('should create a nested parameterized transaction when the `app` directory is used', async ({ page }) => { + const transactionPromise = waitForTransaction('nextjs-14', async transactionEvent => { + return ( + transactionEvent.transaction === '/parameterized/:one/beep/:two' && + transactionEvent.contexts?.trace?.op === 'pageload' + ); + }); + + await page.goto(`/parameterized/cappuccino/beep/espresso`); + + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + breadcrumbs: expect.arrayContaining([ + { + category: 'navigation', + data: { from: '/parameterized/cappuccino/beep/espresso', to: '/parameterized/cappuccino/beep/espresso' }, + timestamp: expect.any(Number), + }, + ]), + contexts: { + react: { version: expect.any(String) }, + trace: { + data: { + 'sentry.op': 'pageload', + 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', + 'sentry.source': 'route', + }, + op: 'pageload', + origin: 'auto.pageload.nextjs.app_router_instrumentation', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }, + }, + environment: 'qa', + request: { + headers: expect.any(Object), + url: expect.stringMatching(/\/parameterized\/cappuccino\/beep\/espresso$/), + }, + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: '/parameterized/:one/beep/:two', + transaction_info: { source: 'route' }, + type: 'transaction', + }); +}); From e3220d97c281c77384a718bb97a0acef09e1d0c5 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Sun, 13 Jul 2025 15:20:11 +0200 Subject: [PATCH 20/35] test test test nextjs-15 --- .../parameterized/[one]/beep/[two]/page.tsx | 3 + .../app/parameterized/[one]/beep/page.tsx | 3 + .../app/parameterized/[one]/page.tsx | 3 + .../app/parameterized/static/page.tsx | 7 + .../tests/parameterized-routes.test.ts | 189 ++++++++++++++++++ 5 files changed, 205 insertions(+) create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15/app/parameterized/[one]/beep/[two]/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15/app/parameterized/[one]/beep/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15/app/parameterized/[one]/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15/app/parameterized/static/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15/tests/parameterized-routes.test.ts diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/app/parameterized/[one]/beep/[two]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-15/app/parameterized/[one]/beep/[two]/page.tsx new file mode 100644 index 000000000000..f34461c2bb07 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/app/parameterized/[one]/beep/[two]/page.tsx @@ -0,0 +1,3 @@ +export default function ParameterizedPage() { + return
Dynamic page two
; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/app/parameterized/[one]/beep/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-15/app/parameterized/[one]/beep/page.tsx new file mode 100644 index 000000000000..a7d9164c8c03 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/app/parameterized/[one]/beep/page.tsx @@ -0,0 +1,3 @@ +export default function BeepPage() { + return
Beep
; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/app/parameterized/[one]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-15/app/parameterized/[one]/page.tsx new file mode 100644 index 000000000000..9fa617a22381 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/app/parameterized/[one]/page.tsx @@ -0,0 +1,3 @@ +export default function ParameterizedPage() { + return
Dynamic page one
; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/app/parameterized/static/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-15/app/parameterized/static/page.tsx new file mode 100644 index 000000000000..080e09fe6df2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/app/parameterized/static/page.tsx @@ -0,0 +1,7 @@ +export default function StaticPage() { + return ( +
+ Static page +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/parameterized-routes.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/parameterized-routes.test.ts new file mode 100644 index 000000000000..b172ac70814c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/parameterized-routes.test.ts @@ -0,0 +1,189 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('should create a parameterized transaction when the `app` directory is used', async ({ page }) => { + const transactionPromise = waitForTransaction('nextjs-15', async transactionEvent => { + return ( + transactionEvent.transaction === '/parameterized/:one' && transactionEvent.contexts?.trace?.op === 'pageload' + ); + }); + + await page.goto(`/parameterized/cappuccino`); + + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + breadcrumbs: expect.arrayContaining([ + { + category: 'navigation', + data: { from: '/parameterized/cappuccino', to: '/parameterized/cappuccino' }, + timestamp: expect.any(Number), + }, + ]), + contexts: { + react: { version: expect.any(String) }, + trace: { + data: { + 'sentry.op': 'pageload', + 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', + 'sentry.source': 'route', + }, + op: 'pageload', + origin: 'auto.pageload.nextjs.app_router_instrumentation', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }, + }, + environment: 'qa', + request: { + headers: expect.any(Object), + url: expect.stringMatching(/\/parameterized\/cappuccino$/), + }, + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: '/parameterized/:one', + transaction_info: { source: 'route' }, + type: 'transaction', + }); +}); + +test('should create a static transaction when the `app` directory is used and the route is not parameterized', async ({ + page, +}) => { + const transactionPromise = waitForTransaction('nextjs-15', async transactionEvent => { + return ( + transactionEvent.transaction === '/parameterized/static' && transactionEvent.contexts?.trace?.op === 'pageload' + ); + }); + + await page.goto(`/parameterized/static`); + + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + breadcrumbs: expect.arrayContaining([ + { + category: 'navigation', + data: { from: '/parameterized/static', to: '/parameterized/static' }, + timestamp: expect.any(Number), + }, + ]), + contexts: { + react: { version: expect.any(String) }, + trace: { + data: { + 'sentry.op': 'pageload', + 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', + 'sentry.source': 'url', + }, + op: 'pageload', + origin: 'auto.pageload.nextjs.app_router_instrumentation', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }, + }, + environment: 'qa', + request: { + headers: expect.any(Object), + url: expect.stringMatching(/\/parameterized\/static$/), + }, + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: '/parameterized/static', + transaction_info: { source: 'url' }, + type: 'transaction', + }); +}); + +test('should create a partially parameterized transaction when the `app` directory is used', async ({ page }) => { + const transactionPromise = waitForTransaction('nextjs-15', async transactionEvent => { + return ( + transactionEvent.transaction === '/parameterized/:one/beep' && transactionEvent.contexts?.trace?.op === 'pageload' + ); + }); + + await page.goto(`/parameterized/cappuccino/beep`); + + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + breadcrumbs: expect.arrayContaining([ + { + category: 'navigation', + data: { from: '/parameterized/cappuccino/beep', to: '/parameterized/cappuccino/beep' }, + timestamp: expect.any(Number), + }, + ]), + contexts: { + react: { version: expect.any(String) }, + trace: { + data: { + 'sentry.op': 'pageload', + 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', + 'sentry.source': 'route', + }, + op: 'pageload', + origin: 'auto.pageload.nextjs.app_router_instrumentation', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }, + }, + environment: 'qa', + request: { + headers: expect.any(Object), + url: expect.stringMatching(/\/parameterized\/cappuccino\/beep$/), + }, + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: '/parameterized/:one/beep', + transaction_info: { source: 'route' }, + type: 'transaction', + }); +}); + +test('should create a nested parameterized transaction when the `app` directory is used', async ({ page }) => { + const transactionPromise = waitForTransaction('nextjs-15', async transactionEvent => { + return ( + transactionEvent.transaction === '/parameterized/:one/beep/:two' && + transactionEvent.contexts?.trace?.op === 'pageload' + ); + }); + + await page.goto(`/parameterized/cappuccino/beep/espresso`); + + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + breadcrumbs: expect.arrayContaining([ + { + category: 'navigation', + data: { from: '/parameterized/cappuccino/beep/espresso', to: '/parameterized/cappuccino/beep/espresso' }, + timestamp: expect.any(Number), + }, + ]), + contexts: { + react: { version: expect.any(String) }, + trace: { + data: { + 'sentry.op': 'pageload', + 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', + 'sentry.source': 'route', + }, + op: 'pageload', + origin: 'auto.pageload.nextjs.app_router_instrumentation', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }, + }, + environment: 'qa', + request: { + headers: expect.any(Object), + url: expect.stringMatching(/\/parameterized\/cappuccino\/beep\/espresso$/), + }, + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: '/parameterized/:one/beep/:two', + transaction_info: { source: 'route' }, + type: 'transaction', + }); +}); From 29e7235f4ae23898d196ce99142a18288b97c434 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Sun, 13 Jul 2025 15:44:48 +0200 Subject: [PATCH 21/35] update turbo tests --- .../parameterized/[one]/beep/[two]/page.tsx | 3 + .../app/parameterized/[one]/beep/page.tsx | 3 + .../app/parameterized/[one]/page.tsx | 3 + .../app/parameterized/static/page.tsx | 7 + .../nextjs-turbo/next.config.js | 6 +- .../nextjs-turbo/package.json | 2 +- .../app-router/parameterized-routes.test.ts | 189 ++++++++++++++++++ 7 files changed, 207 insertions(+), 6 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-turbo/app/parameterized/[one]/beep/[two]/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-turbo/app/parameterized/[one]/beep/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-turbo/app/parameterized/[one]/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-turbo/app/parameterized/static/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-turbo/tests/app-router/parameterized-routes.test.ts diff --git a/dev-packages/e2e-tests/test-applications/nextjs-turbo/app/parameterized/[one]/beep/[two]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-turbo/app/parameterized/[one]/beep/[two]/page.tsx new file mode 100644 index 000000000000..f34461c2bb07 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-turbo/app/parameterized/[one]/beep/[two]/page.tsx @@ -0,0 +1,3 @@ +export default function ParameterizedPage() { + return
Dynamic page two
; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-turbo/app/parameterized/[one]/beep/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-turbo/app/parameterized/[one]/beep/page.tsx new file mode 100644 index 000000000000..a7d9164c8c03 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-turbo/app/parameterized/[one]/beep/page.tsx @@ -0,0 +1,3 @@ +export default function BeepPage() { + return
Beep
; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-turbo/app/parameterized/[one]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-turbo/app/parameterized/[one]/page.tsx new file mode 100644 index 000000000000..9fa617a22381 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-turbo/app/parameterized/[one]/page.tsx @@ -0,0 +1,3 @@ +export default function ParameterizedPage() { + return
Dynamic page one
; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-turbo/app/parameterized/static/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-turbo/app/parameterized/static/page.tsx new file mode 100644 index 000000000000..080e09fe6df2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-turbo/app/parameterized/static/page.tsx @@ -0,0 +1,7 @@ +export default function StaticPage() { + return ( +
+ Static page +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-turbo/next.config.js b/dev-packages/e2e-tests/test-applications/nextjs-turbo/next.config.js index a0d2b254bc42..55a7b9361b3a 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-turbo/next.config.js +++ b/dev-packages/e2e-tests/test-applications/nextjs-turbo/next.config.js @@ -1,11 +1,7 @@ const { withSentryConfig } = require('@sentry/nextjs'); /** @type {import('next').NextConfig} */ -const nextConfig = { - experimental: { - turbo: {}, // Enables Turbopack for builds - }, -}; +const nextConfig = {}; module.exports = withSentryConfig(nextConfig, { silent: true, diff --git a/dev-packages/e2e-tests/test-applications/nextjs-turbo/package.json b/dev-packages/e2e-tests/test-applications/nextjs-turbo/package.json index 76d544bb823a..9102de60706b 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-turbo/package.json +++ b/dev-packages/e2e-tests/test-applications/nextjs-turbo/package.json @@ -17,7 +17,7 @@ "@types/node": "^18.19.1", "@types/react": "18.0.26", "@types/react-dom": "18.0.9", - "next": "15.3.0-canary.40", + "next": "^15.3.5", "react": "rc", "react-dom": "rc", "typescript": "~5.0.0" diff --git a/dev-packages/e2e-tests/test-applications/nextjs-turbo/tests/app-router/parameterized-routes.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-turbo/tests/app-router/parameterized-routes.test.ts new file mode 100644 index 000000000000..0a2f1dfc0c28 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-turbo/tests/app-router/parameterized-routes.test.ts @@ -0,0 +1,189 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('should create a parameterized transaction when the `app` directory is used', async ({ page }) => { + const transactionPromise = waitForTransaction('nextjs-turbo', async transactionEvent => { + return ( + transactionEvent.transaction === '/parameterized/:one' && transactionEvent.contexts?.trace?.op === 'pageload' + ); + }); + + await page.goto(`/parameterized/cappuccino`); + + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + breadcrumbs: expect.arrayContaining([ + { + category: 'navigation', + data: { from: '/parameterized/cappuccino', to: '/parameterized/cappuccino' }, + timestamp: expect.any(Number), + }, + ]), + contexts: { + react: { version: expect.any(String) }, + trace: { + data: { + 'sentry.op': 'pageload', + 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', + 'sentry.source': 'route', + }, + op: 'pageload', + origin: 'auto.pageload.nextjs.app_router_instrumentation', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }, + }, + environment: 'qa', + request: { + headers: expect.any(Object), + url: expect.stringMatching(/\/parameterized\/cappuccino$/), + }, + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: '/parameterized/:one', + transaction_info: { source: 'route' }, + type: 'transaction', + }); +}); + +test('should create a static transaction when the `app` directory is used and the route is not parameterized', async ({ + page, +}) => { + const transactionPromise = waitForTransaction('nextjs-turbo', async transactionEvent => { + return ( + transactionEvent.transaction === '/parameterized/static' && transactionEvent.contexts?.trace?.op === 'pageload' + ); + }); + + await page.goto(`/parameterized/static`); + + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + breadcrumbs: expect.arrayContaining([ + { + category: 'navigation', + data: { from: '/parameterized/static', to: '/parameterized/static' }, + timestamp: expect.any(Number), + }, + ]), + contexts: { + react: { version: expect.any(String) }, + trace: { + data: { + 'sentry.op': 'pageload', + 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', + 'sentry.source': 'url', + }, + op: 'pageload', + origin: 'auto.pageload.nextjs.app_router_instrumentation', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }, + }, + environment: 'qa', + request: { + headers: expect.any(Object), + url: expect.stringMatching(/\/parameterized\/static$/), + }, + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: '/parameterized/static', + transaction_info: { source: 'url' }, + type: 'transaction', + }); +}); + +test('should create a partially parameterized transaction when the `app` directory is used', async ({ page }) => { + const transactionPromise = waitForTransaction('nextjs-turbo', async transactionEvent => { + return ( + transactionEvent.transaction === '/parameterized/:one/beep' && transactionEvent.contexts?.trace?.op === 'pageload' + ); + }); + + await page.goto(`/parameterized/cappuccino/beep`); + + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + breadcrumbs: expect.arrayContaining([ + { + category: 'navigation', + data: { from: '/parameterized/cappuccino/beep', to: '/parameterized/cappuccino/beep' }, + timestamp: expect.any(Number), + }, + ]), + contexts: { + react: { version: expect.any(String) }, + trace: { + data: { + 'sentry.op': 'pageload', + 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', + 'sentry.source': 'route', + }, + op: 'pageload', + origin: 'auto.pageload.nextjs.app_router_instrumentation', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }, + }, + environment: 'qa', + request: { + headers: expect.any(Object), + url: expect.stringMatching(/\/parameterized\/cappuccino\/beep$/), + }, + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: '/parameterized/:one/beep', + transaction_info: { source: 'route' }, + type: 'transaction', + }); +}); + +test('should create a nested parameterized transaction when the `app` directory is used', async ({ page }) => { + const transactionPromise = waitForTransaction('nextjs-turbo', async transactionEvent => { + return ( + transactionEvent.transaction === '/parameterized/:one/beep/:two' && + transactionEvent.contexts?.trace?.op === 'pageload' + ); + }); + + await page.goto(`/parameterized/cappuccino/beep/espresso`); + + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + breadcrumbs: expect.arrayContaining([ + { + category: 'navigation', + data: { from: '/parameterized/cappuccino/beep/espresso', to: '/parameterized/cappuccino/beep/espresso' }, + timestamp: expect.any(Number), + }, + ]), + contexts: { + react: { version: expect.any(String) }, + trace: { + data: { + 'sentry.op': 'pageload', + 'sentry.origin': 'auto.pageload.nextjs.app_router_instrumentation', + 'sentry.source': 'route', + }, + op: 'pageload', + origin: 'auto.pageload.nextjs.app_router_instrumentation', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }, + }, + environment: 'qa', + request: { + headers: expect.any(Object), + url: expect.stringMatching(/\/parameterized\/cappuccino\/beep\/espresso$/), + }, + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + transaction: '/parameterized/:one/beep/:two', + transaction_info: { source: 'route' }, + type: 'transaction', + }); +}); From 060a3bd53dda02ad88b692fbde092828ef487cfa Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Sun, 13 Jul 2025 15:57:20 +0200 Subject: [PATCH 22/35] actually use stringified manifest --- .../test/client/parameterization.test.ts | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/packages/nextjs/test/client/parameterization.test.ts b/packages/nextjs/test/client/parameterization.test.ts index cada8f328666..57e5b4cfe134 100644 --- a/packages/nextjs/test/client/parameterization.test.ts +++ b/packages/nextjs/test/client/parameterization.test.ts @@ -4,7 +4,7 @@ import { maybeParameterizeRoute } from '../../src/client/routing/parameterizatio import type { RouteManifest } from '../../src/config/manifest/types'; const globalWithInjectedManifest = GLOBAL_OBJ as typeof GLOBAL_OBJ & { - _sentryRouteManifest: RouteManifest | undefined; + _sentryRouteManifest: string | undefined; }; describe('maybeParameterizeRoute', () => { @@ -30,7 +30,7 @@ describe('maybeParameterizeRoute', () => { staticRoutes: [{ path: '/' }, { path: '/some/nested' }, { path: '/user' }, { path: '/users' }], dynamicRoutes: [], }; - globalWithInjectedManifest._sentryRouteManifest = manifest; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); expect(maybeParameterizeRoute('/')).toBeUndefined(); expect(maybeParameterizeRoute('/some/nested')).toBeUndefined(); @@ -66,7 +66,7 @@ describe('maybeParameterizeRoute', () => { }, ], }; - globalWithInjectedManifest._sentryRouteManifest = manifest; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); expect(maybeParameterizeRoute('/dynamic/123')).toBe('/dynamic/:id'); expect(maybeParameterizeRoute('/dynamic/abc')).toBe('/dynamic/:id'); @@ -89,7 +89,7 @@ describe('maybeParameterizeRoute', () => { }, ], }; - globalWithInjectedManifest._sentryRouteManifest = manifest; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); expect(maybeParameterizeRoute('/')).toBeUndefined(); expect(maybeParameterizeRoute('/dynamic/static')).toBeUndefined(); @@ -107,7 +107,7 @@ describe('maybeParameterizeRoute', () => { }, ], }; - globalWithInjectedManifest._sentryRouteManifest = manifest; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); expect(maybeParameterizeRoute('/catchall/123')).toBe('/catchall/:path*?'); expect(maybeParameterizeRoute('/catchall/abc')).toBe('/catchall/:path*?'); @@ -135,7 +135,7 @@ describe('maybeParameterizeRoute', () => { }, ], }; - globalWithInjectedManifest._sentryRouteManifest = manifest; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); expect(maybeParameterizeRoute('/(auth)/login')).toBeUndefined(); expect(maybeParameterizeRoute('/(auth)/signup')).toBeUndefined(); @@ -163,7 +163,7 @@ describe('maybeParameterizeRoute', () => { }, ], }; - globalWithInjectedManifest._sentryRouteManifest = manifest; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); expect(maybeParameterizeRoute('/login')).toBeUndefined(); expect(maybeParameterizeRoute('/signup')).toBeUndefined(); @@ -184,7 +184,7 @@ describe('maybeParameterizeRoute', () => { }, ], }; - globalWithInjectedManifest._sentryRouteManifest = manifest; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); expect(maybeParameterizeRoute('/users/user-with-dashes/settings')).toBe('/users/:id/settings'); expect(maybeParameterizeRoute('/users/user_with_underscores/settings')).toBe('/users/:id/settings'); @@ -207,7 +207,7 @@ describe('maybeParameterizeRoute', () => { }, ], }; - globalWithInjectedManifest._sentryRouteManifest = manifest; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); expect(maybeParameterizeRoute('/users/123')).toBe('/users/:id'); expect(maybeParameterizeRoute('/about')).toBe('/:slug'); @@ -223,7 +223,7 @@ describe('maybeParameterizeRoute', () => { }, ], }; - globalWithInjectedManifest._sentryRouteManifest = manifest; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); expect(maybeParameterizeRoute('/users/123')).toBeUndefined(); }); @@ -239,7 +239,7 @@ describe('maybeParameterizeRoute', () => { }, ], }; - globalWithInjectedManifest._sentryRouteManifest = manifest; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); expect(maybeParameterizeRoute('/users/123')).toBeUndefined(); }); @@ -257,7 +257,7 @@ describe('maybeParameterizeRoute', () => { }, ], }; - globalWithInjectedManifest._sentryRouteManifest = manifest; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); expect(maybeParameterizeRoute('/unknown')).toBeUndefined(); expect(maybeParameterizeRoute('/posts/123')).toBeUndefined(); @@ -271,7 +271,7 @@ describe('maybeParameterizeRoute', () => { staticRoutes: [], dynamicRoutes: [], }; - globalWithInjectedManifest._sentryRouteManifest = manifest; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); expect(maybeParameterizeRoute('')).toBeUndefined(); }); @@ -281,7 +281,7 @@ describe('maybeParameterizeRoute', () => { staticRoutes: [{ path: '/' }], dynamicRoutes: [], }; - globalWithInjectedManifest._sentryRouteManifest = manifest; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); expect(maybeParameterizeRoute('/')).toBeUndefined(); }); @@ -297,7 +297,7 @@ describe('maybeParameterizeRoute', () => { }, ], }; - globalWithInjectedManifest._sentryRouteManifest = manifest; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); expect(maybeParameterizeRoute('/api/v1/users/123/posts/456/comments/789')).toBe( '/api/v1/users/:id/posts/:postId/comments/:commentId', @@ -387,7 +387,7 @@ describe('maybeParameterizeRoute', () => { }, ], }; - globalWithInjectedManifest._sentryRouteManifest = manifest; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); if (expectedRoute === undefined) { expect(maybeParameterizeRoute(inputRoute)).toBeUndefined(); From 97fc38e6c8ef22d590d8f902b7ad9f101c3141b5 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Sun, 13 Jul 2025 16:56:58 +0200 Subject: [PATCH 23/35] handle catchall edge case --- .../src/client/routing/parameterization.ts | 46 +++- .../test/client/parameterization.test.ts | 248 ++++++++++++++++++ 2 files changed, 292 insertions(+), 2 deletions(-) diff --git a/packages/nextjs/src/client/routing/parameterization.ts b/packages/nextjs/src/client/routing/parameterization.ts index f5aa32d098bf..c386448b5344 100644 --- a/packages/nextjs/src/client/routing/parameterization.ts +++ b/packages/nextjs/src/client/routing/parameterization.ts @@ -5,6 +5,40 @@ const globalWithInjectedManifest = GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryRouteManifest: RouteManifest | undefined; }; +/** + * Calculate the specificity score for a route path. + * Lower scores indicate more specific routes. + */ +function getRouteSpecificity(routePath: string): number { + const segments = routePath.split('/').filter(Boolean); + let score = 0; + + for (const segment of segments) { + if (segment.startsWith(':')) { + const paramName = segment.substring(1); + if (paramName.endsWith('*?')) { + // Optional catch-all: [[...param]] + score += 1000; + } else if (paramName.endsWith('*')) { + // Required catch-all: [...param] + score += 100; + } else { + // Regular dynamic segment: [param] + score += 10; + } + } + // Static segments add 0 to score as they are most specific + } + + return score; +} + +/** + * Parameterize a route using the route manifest. + * + * @param route - The route to parameterize. + * @returns The parameterized route or undefined if no parameterization is needed. + */ export const maybeParameterizeRoute = (route: string): string | undefined => { if ( !globalWithInjectedManifest._sentryRouteManifest || @@ -34,6 +68,8 @@ export const maybeParameterizeRoute = (route: string): string | undefined => { return undefined; } + const matches: string[] = []; + // Dynamic path: find the route pattern that matches the concrete route for (const dynamicRoute of manifest.dynamicRoutes) { if (dynamicRoute.regex) { @@ -41,7 +77,7 @@ export const maybeParameterizeRoute = (route: string): string | undefined => { // eslint-disable-next-line @sentry-internal/sdk/no-regexp-constructor -- regex patterns are from build-time route manifest, not user input const regex = new RegExp(dynamicRoute.regex); if (regex.test(route)) { - return dynamicRoute.path; + matches.push(dynamicRoute.path); } } catch (error) { // Just skip this route in case of invalid regex @@ -50,6 +86,12 @@ export const maybeParameterizeRoute = (route: string): string | undefined => { } } - // We should never end up here + if (matches.length === 1) { + return matches[0]; + } else if (matches.length > 1) { + // Only calculate specificity when we have multiple matches like [param] and [...params] + return matches.sort((a, b) => getRouteSpecificity(a) - getRouteSpecificity(b))[0]; + } + return undefined; }; diff --git a/packages/nextjs/test/client/parameterization.test.ts b/packages/nextjs/test/client/parameterization.test.ts index 57e5b4cfe134..e9f484e71827 100644 --- a/packages/nextjs/test/client/parameterization.test.ts +++ b/packages/nextjs/test/client/parameterization.test.ts @@ -396,4 +396,252 @@ describe('maybeParameterizeRoute', () => { } }); }); + + describe('route specificity and precedence', () => { + it('should prefer more specific routes over catch-all routes', () => { + const manifest: RouteManifest = { + staticRoutes: [], + dynamicRoutes: [ + { + path: '/:parameter', + regex: '^/([^/]+)$', + paramNames: ['parameter'], + }, + { + path: '/:parameters*', + regex: '^/(.+)$', + paramNames: ['parameters'], + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + // Single segment should match the specific route, not the catch-all + expect(maybeParameterizeRoute('/123')).toBe('/:parameter'); + expect(maybeParameterizeRoute('/abc')).toBe('/:parameter'); + expect(maybeParameterizeRoute('/user-id')).toBe('/:parameter'); + + // Multiple segments should match the catch-all + expect(maybeParameterizeRoute('/123/456')).toBe('/:parameters*'); + expect(maybeParameterizeRoute('/users/123/posts')).toBe('/:parameters*'); + }); + + it('should prefer regular dynamic routes over optional catch-all routes', () => { + const manifest: RouteManifest = { + staticRoutes: [], + dynamicRoutes: [ + { + path: '/:parameter', + regex: '^/([^/]+)$', + paramNames: ['parameter'], + }, + { + path: '/:parameters*?', + regex: '^(?:/(.*))?$', + paramNames: ['parameters'], + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + // Single segment should match the specific route, not the optional catch-all + expect(maybeParameterizeRoute('/123')).toBe('/:parameter'); + expect(maybeParameterizeRoute('/test')).toBe('/:parameter'); + }); + + it('should handle multiple levels of specificity correctly', () => { + const manifest: RouteManifest = { + staticRoutes: [{ path: '/static' }], + dynamicRoutes: [ + { + path: '/:param', + regex: '^/([^/]+)$', + paramNames: ['param'], + }, + { + path: '/:catch*', + regex: '^/(.+)$', + paramNames: ['catch'], + }, + { + path: '/:optional*?', + regex: '^(?:/(.*))?$', + paramNames: ['optional'], + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + // Static route should take precedence (no parameterization) + expect(maybeParameterizeRoute('/static')).toBeUndefined(); + + // Single segment should match regular dynamic route + expect(maybeParameterizeRoute('/dynamic')).toBe('/:param'); + + // Multiple segments should match required catch-all over optional catch-all + expect(maybeParameterizeRoute('/path/to/resource')).toBe('/:catch*'); + }); + + it('should handle real-world Next.js app directory structure', () => { + const manifest: RouteManifest = { + staticRoutes: [{ path: '/' }, { path: '/about' }, { path: '/contact' }], + dynamicRoutes: [ + { + path: '/blog/:slug', + regex: '^/blog/([^/]+)$', + paramNames: ['slug'], + }, + { + path: '/users/:id', + regex: '^/users/([^/]+)$', + paramNames: ['id'], + }, + { + path: '/users/:id/posts/:postId', + regex: '^/users/([^/]+)/posts/([^/]+)$', + paramNames: ['id', 'postId'], + }, + { + path: '/:segments*', + regex: '^/(.+)$', + paramNames: ['segments'], + }, + { + path: '/:catch*?', + regex: '^(?:/(.*))?$', + paramNames: ['catch'], + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + // Static routes should not be parameterized + expect(maybeParameterizeRoute('/')).toBeUndefined(); + expect(maybeParameterizeRoute('/about')).toBeUndefined(); + expect(maybeParameterizeRoute('/contact')).toBeUndefined(); + + // Specific dynamic routes should take precedence over catch-all + expect(maybeParameterizeRoute('/blog/my-post')).toBe('/blog/:slug'); + expect(maybeParameterizeRoute('/users/123')).toBe('/users/:id'); + expect(maybeParameterizeRoute('/users/john/posts/456')).toBe('/users/:id/posts/:postId'); + + // Unmatched multi-segment paths should match required catch-all + expect(maybeParameterizeRoute('/api/v1/data')).toBe('/:segments*'); + expect(maybeParameterizeRoute('/some/deep/nested/path')).toBe('/:segments*'); + }); + + it('should prefer routes with more static segments', () => { + const manifest: RouteManifest = { + staticRoutes: [], + dynamicRoutes: [ + { + path: '/api/users/:id', + regex: '^/api/users/([^/]+)$', + paramNames: ['id'], + }, + { + path: '/api/:resource/:id', + regex: '^/api/([^/]+)/([^/]+)$', + paramNames: ['resource', 'id'], + }, + { + path: '/:segments*', + regex: '^/(.+)$', + paramNames: ['segments'], + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + // More specific route with static segments should win + expect(maybeParameterizeRoute('/api/users/123')).toBe('/api/users/:id'); + + // Less specific but still targeted route should win over catch-all + expect(maybeParameterizeRoute('/api/posts/456')).toBe('/api/:resource/:id'); + + // Unmatched patterns should fall back to catch-all + expect(maybeParameterizeRoute('/some/other/path')).toBe('/:segments*'); + }); + + it('should handle complex nested catch-all scenarios', () => { + const manifest: RouteManifest = { + staticRoutes: [], + dynamicRoutes: [ + { + path: '/docs/:slug', + regex: '^/docs/([^/]+)$', + paramNames: ['slug'], + }, + { + path: '/docs/:sections*', + regex: '^/docs/(.+)$', + paramNames: ['sections'], + }, + { + path: '/files/:path*?', + regex: '^/files(?:/(.*))?$', + paramNames: ['path'], + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + // Single segment should match specific route + expect(maybeParameterizeRoute('/docs/introduction')).toBe('/docs/:slug'); + + // Multiple segments should match catch-all + expect(maybeParameterizeRoute('/docs/api/reference')).toBe('/docs/:sections*'); + expect(maybeParameterizeRoute('/docs/guide/getting-started/installation')).toBe('/docs/:sections*'); + + // Optional catch-all should match both empty and filled cases + expect(maybeParameterizeRoute('/files')).toBe('/files/:path*?'); + expect(maybeParameterizeRoute('/files/documents')).toBe('/files/:path*?'); + expect(maybeParameterizeRoute('/files/images/avatar.png')).toBe('/files/:path*?'); + }); + + it('should correctly order routes by specificity score', () => { + const manifest: RouteManifest = { + staticRoutes: [], + dynamicRoutes: [ + // These routes are intentionally in non-specificity order + { + path: '/:optional*?', // Specificity: 1000 (least specific) + regex: '^(?:/(.*))?$', + paramNames: ['optional'], + }, + { + path: '/:catchall*', // Specificity: 100 + regex: '^/(.+)$', + paramNames: ['catchall'], + }, + { + path: '/api/:endpoint/:id', // Specificity: 20 (2 dynamic segments) + regex: '^/api/([^/]+)/([^/]+)$', + paramNames: ['endpoint', 'id'], + }, + { + path: '/users/:id', // Specificity: 10 (1 dynamic segment) + regex: '^/users/([^/]+)$', + paramNames: ['id'], + }, + { + path: '/api/users/:id', // Specificity: 10 (1 dynamic segment) + regex: '^/api/users/([^/]+)$', + paramNames: ['id'], + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + // Most specific route should win despite order in manifest + expect(maybeParameterizeRoute('/users/123')).toBe('/users/:id'); + expect(maybeParameterizeRoute('/api/users/456')).toBe('/api/users/:id'); + + // More general dynamic route should win over catch-all + expect(maybeParameterizeRoute('/api/posts/789')).toBe('/api/:endpoint/:id'); + + // Catch-all should be used when no more specific routes match + expect(maybeParameterizeRoute('/some/random/path')).toBe('/:catchall*'); + }); + }); }); From 4192c374dcfb5e7b4ed77b3afc3c5b8acc61167b Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Sun, 13 Jul 2025 16:58:17 +0200 Subject: [PATCH 24/35] update existing router tests --- ...client-app-routing-instrumentation.test.ts | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/client-app-routing-instrumentation.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/client-app-routing-instrumentation.test.ts index 8069a1d1395b..a685f969eeda 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/client-app-routing-instrumentation.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-app-dir/tests/client-app-routing-instrumentation.test.ts @@ -6,7 +6,7 @@ test('Creates a pageload transaction for app router routes', async ({ page }) => const clientPageloadTransactionPromise = waitForTransaction('nextjs-app-dir', transactionEvent => { return ( - transactionEvent?.transaction === `/server-component/parameter/${randomRoute}` && + transactionEvent?.transaction === `/server-component/parameter/:parameter` && transactionEvent.contexts?.trace?.op === 'pageload' ); }); @@ -21,7 +21,7 @@ test('Creates a navigation transaction for app router routes', async ({ page }) const clientPageloadTransactionPromise = waitForTransaction('nextjs-app-dir', transactionEvent => { return ( - transactionEvent?.transaction === `/server-component/parameter/${randomRoute}` && + transactionEvent?.transaction === `/server-component/parameter/:parameter` && transactionEvent.contexts?.trace?.op === 'pageload' ); }); @@ -32,7 +32,7 @@ test('Creates a navigation transaction for app router routes', async ({ page }) const clientNavigationTransactionPromise = waitForTransaction('nextjs-app-dir', transactionEvent => { return ( - transactionEvent?.transaction === '/server-component/parameter/foo/bar/baz' && + transactionEvent?.transaction === '/server-component/parameter/:parameters*' && transactionEvent.contexts?.trace?.op === 'navigation' ); }); @@ -59,7 +59,7 @@ test('Creates a navigation transaction for app router routes', async ({ page }) test('Creates a navigation transaction for `router.push()`', async ({ page }) => { const navigationTransactionPromise = waitForTransaction('nextjs-app-dir', transactionEvent => { return ( - transactionEvent?.transaction === `/navigation/42/router-push` && + transactionEvent?.transaction === `/navigation/:param/router-push` && transactionEvent.contexts?.trace?.op === 'navigation' && transactionEvent.contexts.trace.data?.['navigation.type'] === 'router.push' ); @@ -75,7 +75,7 @@ test('Creates a navigation transaction for `router.push()`', async ({ page }) => test('Creates a navigation transaction for `router.replace()`', async ({ page }) => { const navigationTransactionPromise = waitForTransaction('nextjs-app-dir', transactionEvent => { return ( - transactionEvent?.transaction === `/navigation/42/router-replace` && + transactionEvent?.transaction === `/navigation/:param/router-replace` && transactionEvent.contexts?.trace?.op === 'navigation' && transactionEvent.contexts.trace.data?.['navigation.type'] === 'router.replace' ); @@ -91,7 +91,7 @@ test('Creates a navigation transaction for `router.replace()`', async ({ page }) test('Creates a navigation transaction for `router.back()`', async ({ page }) => { const navigationTransactionPromise = waitForTransaction('nextjs-app-dir', transactionEvent => { return ( - transactionEvent?.transaction === `/navigation/1337/router-back` && + transactionEvent?.transaction === `/navigation/:param/router-back` && transactionEvent.contexts?.trace?.op === 'navigation' ); }); @@ -116,7 +116,7 @@ test('Creates a navigation transaction for `router.back()`', async ({ page }) => test('Creates a navigation transaction for `router.forward()`', async ({ page }) => { const navigationTransactionPromise = waitForTransaction('nextjs-app-dir', transactionEvent => { return ( - transactionEvent?.transaction === `/navigation/42/router-push` && + transactionEvent?.transaction === `/navigation/:param/router-push` && transactionEvent.contexts?.trace?.op === 'navigation' && (transactionEvent.contexts.trace.data?.['navigation.type'] === 'router.forward' || transactionEvent.contexts.trace.data?.['navigation.type'] === 'router.traverse') @@ -137,7 +137,7 @@ test('Creates a navigation transaction for `router.forward()`', async ({ page }) test('Creates a navigation transaction for ``', async ({ page }) => { const navigationTransactionPromise = waitForTransaction('nextjs-app-dir', transactionEvent => { return ( - transactionEvent?.transaction === `/navigation/42/link` && + transactionEvent?.transaction === `/navigation/:param/link` && transactionEvent.contexts?.trace?.op === 'navigation' && transactionEvent.contexts.trace.data?.['navigation.type'] === 'router.push' ); @@ -152,7 +152,7 @@ test('Creates a navigation transaction for ``', async ({ page }) => { test('Creates a navigation transaction for ``', async ({ page }) => { const navigationTransactionPromise = waitForTransaction('nextjs-app-dir', transactionEvent => { return ( - transactionEvent?.transaction === `/navigation/42/link-replace` && + transactionEvent?.transaction === `/navigation/:param/link-replace` && transactionEvent.contexts?.trace?.op === 'navigation' && transactionEvent.contexts.trace.data?.['navigation.type'] === 'router.replace' ); @@ -168,7 +168,7 @@ test('Creates a navigation transaction for ``', async ({ page }) test('Creates a navigation transaction for browser-back', async ({ page }) => { const navigationTransactionPromise = waitForTransaction('nextjs-app-dir', transactionEvent => { return ( - transactionEvent?.transaction === `/navigation/42/browser-back` && + transactionEvent?.transaction === `/navigation/:param/browser-back` && transactionEvent.contexts?.trace?.op === 'navigation' && (transactionEvent.contexts.trace.data?.['navigation.type'] === 'browser.popstate' || transactionEvent.contexts.trace.data?.['navigation.type'] === 'router.traverse') @@ -187,7 +187,7 @@ test('Creates a navigation transaction for browser-back', async ({ page }) => { test('Creates a navigation transaction for browser-forward', async ({ page }) => { const navigationTransactionPromise = waitForTransaction('nextjs-app-dir', transactionEvent => { return ( - transactionEvent?.transaction === `/navigation/42/router-push` && + transactionEvent?.transaction === `/navigation/:param/router-push` && transactionEvent.contexts?.trace?.op === 'navigation' && (transactionEvent.contexts.trace.data?.['navigation.type'] === 'browser.popstate' || transactionEvent.contexts.trace.data?.['navigation.type'] === 'router.traverse') From 50cf21ac8977b1d22653f73dc4b687b557fa75ee Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Sun, 13 Jul 2025 17:10:12 +0200 Subject: [PATCH 25/35] pls run all tests ?! --- .../nextjs-15/tests/parameterized-routes.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/parameterized-routes.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/parameterized-routes.test.ts index b172ac70814c..fb93e77aaf8b 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/parameterized-routes.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/parameterized-routes.test.ts @@ -141,7 +141,7 @@ test('should create a partially parameterized transaction when the `app` directo }); }); -test('should create a nested parameterized transaction when the `app` directory is used', async ({ page }) => { +test('should create a nested parameterized transaction when the `app` directory is used.', async ({ page }) => { const transactionPromise = waitForTransaction('nextjs-15', async transactionEvent => { return ( transactionEvent.transaction === '/parameterized/:one/beep/:two' && From a039505f834f80650cfa6449e5008ebcd9d42fbd Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Sun, 13 Jul 2025 17:22:45 +0200 Subject: [PATCH 26/35] run next-15 tests sequentially --- .../nextjs-15/playwright.config.mjs | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/nextjs-15/playwright.config.mjs index 8448829443d6..c675d003853a 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15/playwright.config.mjs +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/playwright.config.mjs @@ -5,15 +5,9 @@ if (!testEnv) { throw new Error('No test env defined'); } -const config = getPlaywrightConfig( - { - startCommand: testEnv === 'development' ? 'pnpm next dev -p 3030' : 'pnpm next start -p 3030', - port: 3030, - }, - { - // This comes with the risk of tests leaking into each other but the tests run quite slow so we should parallelize - workers: '100%', - }, -); +const config = getPlaywrightConfig({ + startCommand: testEnv === 'development' ? 'pnpm next dev -p 3030' : 'pnpm next start -p 3030', + port: 3030, +}); export default config; From b01ddc7b992ba599dbb1162a66908166dc6984f7 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Mon, 14 Jul 2025 09:50:57 +0200 Subject: [PATCH 27/35] conditionally add turbo --- packages/nextjs/src/config/withSentryConfig.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/nextjs/src/config/withSentryConfig.ts b/packages/nextjs/src/config/withSentryConfig.ts index 7eb8e8874ab9..83229e971f24 100644 --- a/packages/nextjs/src/config/withSentryConfig.ts +++ b/packages/nextjs/src/config/withSentryConfig.ts @@ -315,14 +315,15 @@ function getFinalConfigObject( webpack: !isTurbopack ? constructWebpackConfigFunction(incomingUserNextConfigObject, userSentryOptions, releaseName, routeManifest) : undefined, - turbopack: - isTurbopackSupported && isTurbopack - ? constructTurbopackConfig({ + ...(isTurbopackSupported && isTurbopack + ? { + turbopack: constructTurbopackConfig({ userNextConfig: incomingUserNextConfigObject, userSentryOptions, routeManifest, - }) - : undefined, + }), + } + : {}), }; } From 72a61c49e4dfd36dfe10e98eea14c4cc1e5b2243 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Mon, 14 Jul 2025 14:11:41 +0200 Subject: [PATCH 28/35] finding this took about 10 years of my life --- .../src/client/routing/appRouterRoutingInstrumentation.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts b/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts index 0e886d4d038c..425daeb3e558 100644 --- a/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts +++ b/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts @@ -87,8 +87,9 @@ const GLOBAL_OBJ_WITH_NEXT_ROUTER = GLOBAL_OBJ as typeof GLOBAL_OBJ & { /** Instruments the Next.js app router for navigation. */ export function appRouterInstrumentNavigation(client: Client): void { routerTransitionHandler = (href, navigationType) => { - const parameterizedPathname = maybeParameterizeRoute(href); - const pathname = parameterizedPathname ?? new URL(href, WINDOW.location.href).pathname; + const unparameterizedPathname = new URL(href, WINDOW.location.href).pathname; + const parameterizedPathname = maybeParameterizeRoute(unparameterizedPathname); + const pathname = parameterizedPathname ?? unparameterizedPathname; if (navigationRoutingMode === 'router-patch') { navigationRoutingMode = 'transition-start-hook'; @@ -99,6 +100,7 @@ export function appRouterInstrumentNavigation(client: Client): void { currentNavigationSpan.updateName(pathname); currentNavigationSpan.setAttributes({ 'navigation.type': `router.${navigationType}`, + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: parameterizedPathname ? 'route' : 'url', }); currentRouterPatchingNavigationSpanRef.current = undefined; } else { From b62bdb64f7ab16f50957875bf9019894a4495bd9 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Mon, 14 Jul 2025 15:40:51 +0200 Subject: [PATCH 29/35] Update packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts Co-authored-by: Abhijeet Prasad --- packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts index 9d78c43c957b..8dfd8e35af38 100644 --- a/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts +++ b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts @@ -19,7 +19,6 @@ export function constructTurbopackConfig({ routeManifest, }: { userNextConfig: NextConfigObject; - userSentryOptions: SentryBuildOptions; routeManifest?: RouteManifest; }): TurbopackOptions { const newConfig: TurbopackOptions = { From 16eb90308d4f0ba960ed25fb5c375a69e1e9c9d0 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Mon, 14 Jul 2025 15:40:57 +0200 Subject: [PATCH 30/35] Update packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts Co-authored-by: Abhijeet Prasad --- .../nextjs/src/config/turbopack/constructTurbopackConfig.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts index 8dfd8e35af38..54cfe2afaee5 100644 --- a/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts +++ b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts @@ -10,7 +10,7 @@ import type { /** * Construct a Turbopack config object from a Next.js config object and a Turbopack options object. * - * @param nextConfig - The Next.js config object. + * @param userNextConfig - The Next.js config object. * @param turbopackOptions - The Turbopack options object. * @returns The Turbopack config object. */ From f4dbaca4a237b5ad087923eac75e64f22711c21b Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Mon, 14 Jul 2025 15:50:40 +0200 Subject: [PATCH 31/35] add log for existing matcher --- .../config/turbopack/constructTurbopackConfig.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts index 54cfe2afaee5..c61891895efe 100644 --- a/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts +++ b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts @@ -1,11 +1,8 @@ +import { logger } from '@sentry/core'; +import * as chalk from 'chalk'; import * as path from 'path'; import type { RouteManifest } from '../manifest/types'; -import type { - NextConfigObject, - SentryBuildOptions, - TurbopackOptions, - TurbopackRuleConfigItemOrShortcut, -} from '../types'; +import type { NextConfigObject, TurbopackOptions, TurbopackRuleConfigItemOrShortcut } from '../types'; /** * Construct a Turbopack config object from a Next.js config object and a Turbopack options object. @@ -66,6 +63,11 @@ export function safelyAddTurbopackRule( // If the rule already exists, we don't want to mess with it. if (existingRules[matcher]) { + logger.info( + `${chalk.cyan( + 'info', + )} - Turbopack rule already exists for ${matcher}. Please remove it from your Next.js config in order for Sentry to work properly.`, + ); return existingRules; } From 7159fcb68dbbe21e3f82f5aeefabc9b70a01df7c Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Mon, 14 Jul 2025 16:08:52 +0200 Subject: [PATCH 32/35] windows path + update tests --- .../turbopack/constructTurbopackConfig.ts | 2 +- .../constructTurbopackConfig.test.ts | 61 +++++++++++++------ 2 files changed, 43 insertions(+), 20 deletions(-) diff --git a/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts index c61891895efe..4e5c404ec56c 100644 --- a/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts +++ b/packages/nextjs/src/config/turbopack/constructTurbopackConfig.ts @@ -28,7 +28,7 @@ export function constructTurbopackConfig({ rule: { loaders: [ { - loader: path.resolve(__dirname, '../loaders/valueInjectionLoader.js'), + loader: path.resolve(__dirname, '..', 'loaders', 'valueInjectionLoader.js'), options: { values: { _sentryRouteManifest: JSON.stringify(routeManifest), diff --git a/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts b/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts index b5f5898f3645..813d3c0f8894 100644 --- a/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts +++ b/packages/nextjs/test/config/turbopack/constructTurbopackConfig.test.ts @@ -5,7 +5,7 @@ import { constructTurbopackConfig, safelyAddTurbopackRule, } from '../../../src/config/turbopack/constructTurbopackConfig'; -import type { NextConfigObject, SentryBuildOptions } from '../../../src/config/types'; +import type { NextConfigObject } from '../../../src/config/types'; // Mock path.resolve to return a predictable loader path vi.mock('path', async () => { @@ -18,25 +18,19 @@ vi.mock('path', async () => { describe('constructTurbopackConfig', () => { const mockRouteManifest: RouteManifest = { - routes: [ + dynamicRoutes: [{ path: '/users/[id]', regex: '/users/([^/]+)', paramNames: ['id'] }], + staticRoutes: [ { path: '/users', regex: '/users' }, - { path: '/users/[id]', regex: '/users/([^/]+)', paramNames: ['id'] }, { path: '/api/health', regex: '/api/health' }, ], }; - const mockSentryOptions: SentryBuildOptions = { - org: 'test-org', - project: 'test-project', - }; - describe('without existing turbopack config', () => { it('should create a basic turbopack config when no manifest is provided', () => { const userNextConfig: NextConfigObject = {}; const result = constructTurbopackConfig({ userNextConfig, - userSentryOptions: mockSentryOptions, }); expect(result).toEqual({}); @@ -47,7 +41,6 @@ describe('constructTurbopackConfig', () => { const result = constructTurbopackConfig({ userNextConfig, - userSentryOptions: mockSentryOptions, routeManifest: mockRouteManifest, }); @@ -75,11 +68,45 @@ describe('constructTurbopackConfig', () => { constructTurbopackConfig({ userNextConfig, - userSentryOptions: mockSentryOptions, routeManifest: mockRouteManifest, }); - expect(pathResolveSpy).toHaveBeenCalledWith(expect.any(String), '../loaders/valueInjectionLoader.js'); + expect(pathResolveSpy).toHaveBeenCalledWith(expect.any(String), '..', 'loaders', 'valueInjectionLoader.js'); + }); + + it('should handle Windows-style paths correctly', () => { + // Mock path.resolve to return a Windows-style path + const windowsLoaderPath = 'C:\\my\\project\\dist\\config\\loaders\\valueInjectionLoader.js'; + const pathResolveSpy = vi.spyOn(path, 'resolve'); + pathResolveSpy.mockReturnValue(windowsLoaderPath); + + const userNextConfig: NextConfigObject = {}; + + const result = constructTurbopackConfig({ + userNextConfig, + routeManifest: mockRouteManifest, + }); + + expect(result.rules).toBeDefined(); + expect(result.rules!['**/instrumentation-client.*']).toBeDefined(); + + const rule = result.rules!['**/instrumentation-client.*']; + expect(rule).toHaveProperty('loaders'); + + const ruleWithLoaders = rule as { loaders: Array<{ loader: string; options: any }> }; + expect(ruleWithLoaders.loaders).toBeDefined(); + expect(ruleWithLoaders.loaders).toHaveLength(1); + + const loader = ruleWithLoaders.loaders[0]!; + expect(loader).toHaveProperty('loader'); + expect(loader).toHaveProperty('options'); + expect(loader.options).toHaveProperty('values'); + expect(loader.options.values).toHaveProperty('_sentryRouteManifest'); + expect(loader.loader).toBe(windowsLoaderPath); + expect(pathResolveSpy).toHaveBeenCalledWith(expect.any(String), '..', 'loaders', 'valueInjectionLoader.js'); + + // Restore the original mock behavior + pathResolveSpy.mockReturnValue('/mocked/path/to/valueInjectionLoader.js'); }); }); @@ -98,7 +125,6 @@ describe('constructTurbopackConfig', () => { const result = constructTurbopackConfig({ userNextConfig, - userSentryOptions: mockSentryOptions, }); expect(result).toEqual({ @@ -125,7 +151,6 @@ describe('constructTurbopackConfig', () => { const result = constructTurbopackConfig({ userNextConfig, - userSentryOptions: mockSentryOptions, routeManifest: mockRouteManifest, }); @@ -171,7 +196,6 @@ describe('constructTurbopackConfig', () => { const result = constructTurbopackConfig({ userNextConfig, - userSentryOptions: mockSentryOptions, routeManifest: mockRouteManifest, }); @@ -186,11 +210,10 @@ describe('constructTurbopackConfig', () => { describe('with edge cases', () => { it('should handle empty route manifest', () => { const userNextConfig: NextConfigObject = {}; - const emptyManifest: RouteManifest = { routes: [] }; + const emptyManifest: RouteManifest = { dynamicRoutes: [], staticRoutes: [] }; const result = constructTurbopackConfig({ userNextConfig, - userSentryOptions: mockSentryOptions, routeManifest: emptyManifest, }); @@ -215,15 +238,15 @@ describe('constructTurbopackConfig', () => { it('should handle complex route manifest', () => { const userNextConfig: NextConfigObject = {}; const complexManifest: RouteManifest = { - routes: [ + dynamicRoutes: [ { path: '/users/[id]/posts/[postId]', regex: '/users/([^/]+)/posts/([^/]+)', paramNames: ['id', 'postId'] }, { path: '/api/[...params]', regex: '/api/(.+)', paramNames: ['params'] }, ], + staticRoutes: [], }; const result = constructTurbopackConfig({ userNextConfig, - userSentryOptions: mockSentryOptions, routeManifest: complexManifest, }); From 36322c38e954f177e1b34d1672083da2fd118b90 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Mon, 14 Jul 2025 16:12:26 +0200 Subject: [PATCH 33/35] . --- packages/nextjs/src/config/withSentryConfig.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/nextjs/src/config/withSentryConfig.ts b/packages/nextjs/src/config/withSentryConfig.ts index 83229e971f24..91127b8970f6 100644 --- a/packages/nextjs/src/config/withSentryConfig.ts +++ b/packages/nextjs/src/config/withSentryConfig.ts @@ -319,7 +319,6 @@ function getFinalConfigObject( ? { turbopack: constructTurbopackConfig({ userNextConfig: incomingUserNextConfigObject, - userSentryOptions, routeManifest, }), } From b0321c5175777644539cd0a0e8cb5cbb2c344f9c Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Mon, 14 Jul 2025 16:32:33 +0200 Subject: [PATCH 34/35] log error --- packages/nextjs/src/client/routing/parameterization.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/nextjs/src/client/routing/parameterization.ts b/packages/nextjs/src/client/routing/parameterization.ts index c386448b5344..e1ee299eabb7 100644 --- a/packages/nextjs/src/client/routing/parameterization.ts +++ b/packages/nextjs/src/client/routing/parameterization.ts @@ -1,4 +1,5 @@ -import { GLOBAL_OBJ } from '@sentry/core'; +import { GLOBAL_OBJ, logger } from '@sentry/core'; +import { DEBUG_BUILD } from '../../common/debug-build'; import type { RouteManifest } from '../../config/manifest/types'; const globalWithInjectedManifest = GLOBAL_OBJ as typeof GLOBAL_OBJ & { @@ -59,6 +60,7 @@ export const maybeParameterizeRoute = (route: string): string | undefined => { return undefined; } } catch (error) { + DEBUG_BUILD && logger.warn('Could not extract route manifest'); // Something went wrong while parsing the manifest, so we'll fallback to no parameterization return undefined; } From aafb0a53cdef89fe86f09c76e021e2f595a2209c Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Mon, 14 Jul 2025 16:57:38 +0200 Subject: [PATCH 35/35] optimize performance --- .../src/client/routing/parameterization.ts | 135 +++++++++++++----- 1 file changed, 103 insertions(+), 32 deletions(-) diff --git a/packages/nextjs/src/client/routing/parameterization.ts b/packages/nextjs/src/client/routing/parameterization.ts index e1ee299eabb7..8ce98044a588 100644 --- a/packages/nextjs/src/client/routing/parameterization.ts +++ b/packages/nextjs/src/client/routing/parameterization.ts @@ -6,6 +6,12 @@ const globalWithInjectedManifest = GLOBAL_OBJ as typeof GLOBAL_OBJ & { _sentryRouteManifest: RouteManifest | undefined; }; +// Some performance caches +let cachedManifest: RouteManifest | null = null; +let cachedManifestString: string | undefined = undefined; +const compiledRegexCache: Map = new Map(); +const routeResultCache: Map = new Map(); + /** * Calculate the specificity score for a route path. * Lower scores indicate more specific routes. @@ -35,19 +41,48 @@ function getRouteSpecificity(routePath: string): number { } /** - * Parameterize a route using the route manifest. - * - * @param route - The route to parameterize. - * @returns The parameterized route or undefined if no parameterization is needed. + * Get compiled regex from cache or create and cache it. */ -export const maybeParameterizeRoute = (route: string): string | undefined => { +function getCompiledRegex(regexString: string): RegExp | null { + if (compiledRegexCache.has(regexString)) { + return compiledRegexCache.get(regexString) ?? null; + } + + try { + // eslint-disable-next-line @sentry-internal/sdk/no-regexp-constructor -- regex patterns are from build-time route manifest, not user input + const regex = new RegExp(regexString); + compiledRegexCache.set(regexString, regex); + return regex; + } catch (error) { + DEBUG_BUILD && logger.warn('Could not compile regex', { regexString, error }); + // Cache the failure to avoid repeated attempts by storing undefined + return null; + } +} + +/** + * Get and cache the route manifest from the global object. + * @returns The parsed route manifest or null if not available/invalid. + */ +function getManifest(): RouteManifest | null { if ( - !globalWithInjectedManifest._sentryRouteManifest || + !globalWithInjectedManifest?._sentryRouteManifest || typeof globalWithInjectedManifest._sentryRouteManifest !== 'string' ) { - return undefined; + return null; + } + + const currentManifestString = globalWithInjectedManifest._sentryRouteManifest; + + // Return cached manifest if the string hasn't changed + if (cachedManifest && cachedManifestString === currentManifestString) { + return cachedManifest; } + // Clear caches when manifest changes + compiledRegexCache.clear(); + routeResultCache.clear(); + let manifest: RouteManifest = { staticRoutes: [], dynamicRoutes: [], @@ -55,45 +90,81 @@ export const maybeParameterizeRoute = (route: string): string | undefined => { // Shallow check if the manifest is actually what we expect it to be try { - manifest = JSON.parse(globalWithInjectedManifest._sentryRouteManifest); + manifest = JSON.parse(currentManifestString); if (!Array.isArray(manifest.staticRoutes) || !Array.isArray(manifest.dynamicRoutes)) { - return undefined; + return null; } + // Cache the successfully parsed manifest + cachedManifest = manifest; + cachedManifestString = currentManifestString; + return manifest; } catch (error) { - DEBUG_BUILD && logger.warn('Could not extract route manifest'); // Something went wrong while parsing the manifest, so we'll fallback to no parameterization - return undefined; - } - - // Static path: no parameterization needed - if (manifest.staticRoutes.some(r => r.path === route)) { - return undefined; + DEBUG_BUILD && logger.warn('Could not extract route manifest'); + return null; } +} +/** + * Find matching routes from static and dynamic route collections. + * @param route - The route to match against. + * @param staticRoutes - Array of static route objects. + * @param dynamicRoutes - Array of dynamic route objects. + * @returns Array of matching route paths. + */ +function findMatchingRoutes( + route: string, + staticRoutes: RouteManifest['staticRoutes'], + dynamicRoutes: RouteManifest['dynamicRoutes'], +): string[] { const matches: string[] = []; + // Static path: no parameterization needed, return empty array + if (staticRoutes.some(r => r.path === route)) { + return matches; + } + // Dynamic path: find the route pattern that matches the concrete route - for (const dynamicRoute of manifest.dynamicRoutes) { + for (const dynamicRoute of dynamicRoutes) { if (dynamicRoute.regex) { - try { - // eslint-disable-next-line @sentry-internal/sdk/no-regexp-constructor -- regex patterns are from build-time route manifest, not user input - const regex = new RegExp(dynamicRoute.regex); - if (regex.test(route)) { - matches.push(dynamicRoute.path); - } - } catch (error) { - // Just skip this route in case of invalid regex - continue; + const regex = getCompiledRegex(dynamicRoute.regex); + if (regex?.test(route)) { + matches.push(dynamicRoute.path); } } } - if (matches.length === 1) { - return matches[0]; - } else if (matches.length > 1) { - // Only calculate specificity when we have multiple matches like [param] and [...params] - return matches.sort((a, b) => getRouteSpecificity(a) - getRouteSpecificity(b))[0]; + return matches; +} + +/** + * Parameterize a route using the route manifest. + * + * @param route - The route to parameterize. + * @returns The parameterized route or undefined if no parameterization is needed. + */ +export const maybeParameterizeRoute = (route: string): string | undefined => { + const manifest = getManifest(); + if (!manifest) { + return undefined; + } + + // Check route result cache after manifest validation + if (routeResultCache.has(route)) { + return routeResultCache.get(route); } - return undefined; + const { staticRoutes, dynamicRoutes } = manifest; + if (!Array.isArray(staticRoutes) || !Array.isArray(dynamicRoutes)) { + return undefined; + } + + const matches = findMatchingRoutes(route, staticRoutes, dynamicRoutes); + + // We can always do the `sort()` call, it will short-circuit when it has one array item + const result = matches.sort((a, b) => getRouteSpecificity(a) - getRouteSpecificity(b))[0]; + + routeResultCache.set(route, result); + + return result; };