diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/.npmrc b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/.npmrc new file mode 100644 index 000000000000..a3160f4de175 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/.npmrc @@ -0,0 +1,4 @@ +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 +public-hoist-pattern[]=*import-in-the-middle* +public-hoist-pattern[]=*require-in-the-middle* diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/app/[...slug]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/app/[...slug]/page.tsx new file mode 100644 index 000000000000..477881513d3f --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/app/[...slug]/page.tsx @@ -0,0 +1,3 @@ +export default function CatchAllPage() { + return
Catch-all page
; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/app/layout.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/app/layout.tsx new file mode 100644 index 000000000000..c8f9cee0b787 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/app/layout.tsx @@ -0,0 +1,7 @@ +export default function Layout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/app/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/app/page.tsx new file mode 100644 index 000000000000..b55e84109d2c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/app/page.tsx @@ -0,0 +1,20 @@ +import Link from 'next/link'; + +export default function Page() { + return ( +
+

Next 16 trailing slash test app

+ +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/app/parameterized/[param]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/app/parameterized/[param]/page.tsx new file mode 100644 index 000000000000..e3a3dafe45f2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/app/parameterized/[param]/page.tsx @@ -0,0 +1,3 @@ +export default function ParameterizedPage() { + return
Dynamic parameterized page
; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/app/parameterized/static/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/app/parameterized/static/page.tsx new file mode 100644 index 000000000000..4cb7f5887fc4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/app/parameterized/static/page.tsx @@ -0,0 +1,3 @@ +export default function ParameterizedStaticPage() { + return
Parameterized static page
; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/app/static-page/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/app/static-page/page.tsx new file mode 100644 index 000000000000..16ef0482d53b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/app/static-page/page.tsx @@ -0,0 +1,3 @@ +export default function StaticPage() { + return
Static page
; +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/instrumentation-client.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/instrumentation-client.ts new file mode 100644 index 000000000000..4870c64e7959 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/instrumentation-client.ts @@ -0,0 +1,11 @@ +import * as Sentry from '@sentry/nextjs'; + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1.0, + sendDefaultPii: true, +}); + +export const onRouterTransitionStart = Sentry.captureRouterTransitionStart; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/instrumentation.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/instrumentation.ts new file mode 100644 index 000000000000..964f937c439a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/instrumentation.ts @@ -0,0 +1,13 @@ +import * as Sentry from '@sentry/nextjs'; + +export async function register() { + if (process.env.NEXT_RUNTIME === 'nodejs') { + await import('./sentry.server.config'); + } + + if (process.env.NEXT_RUNTIME === 'edge') { + await import('./sentry.edge.config'); + } +} + +export const onRequestError = Sentry.captureRequestError; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/next.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/next.config.ts new file mode 100644 index 000000000000..80946b61ec01 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/next.config.ts @@ -0,0 +1,10 @@ +import { withSentryConfig } from '@sentry/nextjs'; +import type { NextConfig } from 'next'; + +const nextConfig: NextConfig = { + trailingSlash: true, +}; + +export default withSentryConfig(nextConfig, { + silent: true, +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/package.json b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/package.json new file mode 100644 index 000000000000..c097fd9e2b98 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/package.json @@ -0,0 +1,43 @@ +{ + "name": "nextjs-16-trailing-slash", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build > .tmp_build_stdout 2> .tmp_build_stderr || (cat .tmp_build_stdout && cat .tmp_build_stderr && exit 1)", + "clean": "npx rimraf node_modules pnpm-lock.yaml .tmp_dev_server_logs", + "start": "next start", + "test:prod": "TEST_ENV=production playwright test", + "test:build": "pnpm install && pnpm build", + "test:build-latest": "pnpm install && pnpm add next@latest && pnpm build", + "test:assert": "pnpm test:prod" + }, + "dependencies": { + "@sentry/nextjs": "latest || *", + "@sentry/core": "latest || *", + "import-in-the-middle": "^2", + "next": "16.1.5", + "react": "19.1.0", + "react-dom": "19.1.0", + "require-in-the-middle": "^8" + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "typescript": "^5" + }, + "volta": { + "extends": "../../package.json" + }, + "sentryTest": { + "variants": [ + { + "build-command": "pnpm test:build-latest", + "label": "nextjs-16-trailing-slash (latest, turbopack)" + } + ] + } +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/playwright.config.mjs new file mode 100644 index 000000000000..38548e975851 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/playwright.config.mjs @@ -0,0 +1,25 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; +const testEnv = process.env.TEST_ENV; + +if (!testEnv) { + throw new Error('No test env defined'); +} + +const getStartCommand = () => { + if (testEnv === 'development') { + return 'pnpm next dev -p 3030 2>&1 | tee .tmp_dev_server_logs'; + } + + if (testEnv === 'production') { + return 'pnpm next start -p 3030'; + } + + throw new Error(`Unknown test env: ${testEnv}`); +}; + +const config = getPlaywrightConfig({ + startCommand: getStartCommand(), + port: 3030, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/sentry.edge.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/sentry.edge.config.ts new file mode 100644 index 000000000000..85bd765c9c44 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/sentry.edge.config.ts @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/nextjs'; + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1.0, + sendDefaultPii: true, +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/sentry.server.config.ts new file mode 100644 index 000000000000..85bd765c9c44 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/sentry.server.config.ts @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/nextjs'; + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.NEXT_PUBLIC_E2E_TEST_DSN, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1.0, + sendDefaultPii: true, +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/start-event-proxy.mjs new file mode 100644 index 000000000000..31efe3256f8b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/start-event-proxy.mjs @@ -0,0 +1,14 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +const packageJson = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json'))); + +startEventProxyServer({ + port: 3031, + proxyServerName: 'nextjs-16-trailing-slash', + envelopeDumpPath: path.join( + process.cwd(), + `event-dumps/next-16-trailing-slash-v${packageJson.dependencies.next}-${process.env.TEST_ENV}.dump`, + ), +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/tests/trailing-slash-parameterization.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/tests/trailing-slash-parameterization.test.ts new file mode 100644 index 000000000000..cfdfe12d0c27 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/tests/trailing-slash-parameterization.test.ts @@ -0,0 +1,147 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +// These tests verify that pageload transactions are correctly named when +// trailingSlash: true is enabled in next.config.ts, even when a catch-all +// route exists. See: https://github.com/getsentry/sentry-javascript/issues/19241 + +test('should create a correctly named pageload transaction for a static route', async ({ page }) => { + const transactionPromise = waitForTransaction('nextjs-16-trailing-slash', async transactionEvent => { + return transactionEvent.transaction === '/static-page' && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto(`/static-page`); + + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + contexts: { + 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', + }, + }, + transaction: '/static-page', + transaction_info: { source: 'url' }, + type: 'transaction', + }); +}); + +test('should create a correctly named pageload transaction for a parameterized route', async ({ page }) => { + const transactionPromise = waitForTransaction('nextjs-16-trailing-slash', async transactionEvent => { + return ( + transactionEvent.transaction === '/parameterized/:param' && transactionEvent.contexts?.trace?.op === 'pageload' + ); + }); + + await page.goto(`/parameterized/some-value`); + + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + contexts: { + 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', + }, + }, + transaction: '/parameterized/:param', + transaction_info: { source: 'route' }, + type: 'transaction', + }); +}); + +test('should create a correctly named pageload transaction for a static nested route under parameterized', async ({ + page, +}) => { + const transactionPromise = waitForTransaction('nextjs-16-trailing-slash', async transactionEvent => { + return ( + transactionEvent.transaction === '/parameterized/static' && transactionEvent.contexts?.trace?.op === 'pageload' + ); + }); + + await page.goto(`/parameterized/static`); + + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + contexts: { + 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', + }, + }, + transaction: '/parameterized/static', + transaction_info: { source: 'url' }, + type: 'transaction', + }); +}); + +test('should create a correctly named pageload transaction for the catch-all route', async ({ page }) => { + const transactionPromise = waitForTransaction('nextjs-16-trailing-slash', async transactionEvent => { + return transactionEvent.transaction === '/:slug*' && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto(`/some/unmatched/path`); + + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + contexts: { + 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', + }, + }, + transaction: '/:slug*', + transaction_info: { source: 'route' }, + type: 'transaction', + }); +}); + +test('should create a correctly named pageload transaction for the home page', async ({ page }) => { + const transactionPromise = waitForTransaction('nextjs-16-trailing-slash', async transactionEvent => { + return transactionEvent.transaction === '/' && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto(`/`); + + const transaction = await transactionPromise; + + expect(transaction).toMatchObject({ + contexts: { + 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', + }, + }, + transaction: '/', + transaction_info: { source: 'url' }, + type: 'transaction', + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/tsconfig.json b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/tsconfig.json new file mode 100644 index 000000000000..cc9ed39b5aa2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-trailing-slash/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts", "**/*.mts"], + "exclude": ["node_modules"] +} diff --git a/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts b/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts index 4006496d4a23..3ecc2aec7a6b 100644 --- a/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts +++ b/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts @@ -9,6 +9,14 @@ import { import { startBrowserTracingNavigationSpan, startBrowserTracingPageLoadSpan, WINDOW } from '@sentry/react'; import { maybeParameterizeRoute } from './parameterization'; +/** + * Strips trailing slash from a pathname, unless it's the root path. + * This normalizes paths like '/about/' to '/about' to handle Next.js `trailingSlash: true` config. + */ +function stripTrailingSlash(pathname: string): string { + return pathname.length > 1 && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname; +} + export const INCOMPLETE_APP_ROUTER_INSTRUMENTATION_TRANSACTION_NAME = 'incomplete-app-router-transaction'; /** @@ -35,10 +43,11 @@ 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 pathname = stripTrailingSlash(WINDOW.location.pathname); + const parameterizedPathname = maybeParameterizeRoute(pathname); const origin = browserPerformanceTimeOrigin(); startBrowserTracingPageLoadSpan(client, { - name: parameterizedPathname ?? WINDOW.location.pathname, + name: parameterizedPathname ?? pathname, // pageload should always start at timeOrigin (and needs to be in s, not ms) startTime: origin ? origin / 1000 : undefined, attributes: { @@ -93,7 +102,7 @@ export function appRouterInstrumentNavigation(client: Client): void { routerTransitionHandler = (href, navigationType) => { const basePath = process.env._sentryBasePath ?? globalWithInjectedBasePath._sentryBasePath; const normalizedHref = basePath && !href.startsWith(basePath) ? `${basePath}${href}` : href; - const unparameterizedPathname = new URL(normalizedHref, WINDOW.location.href).pathname; + const unparameterizedPathname = stripTrailingSlash(new URL(normalizedHref, WINDOW.location.href).pathname); const parameterizedPathname = maybeParameterizeRoute(unparameterizedPathname); const pathname = parameterizedPathname ?? unparameterizedPathname; @@ -123,16 +132,17 @@ export function appRouterInstrumentNavigation(client: Client): void { }; WINDOW.addEventListener('popstate', () => { - const parameterizedPathname = maybeParameterizeRoute(WINDOW.location.pathname); + const pathname = stripTrailingSlash(WINDOW.location.pathname); + const parameterizedPathname = maybeParameterizeRoute(pathname); if (currentRouterPatchingNavigationSpanRef.current?.isRecording()) { - currentRouterPatchingNavigationSpanRef.current.updateName(parameterizedPathname ?? WINDOW.location.pathname); + currentRouterPatchingNavigationSpanRef.current.updateName(parameterizedPathname ?? pathname); currentRouterPatchingNavigationSpanRef.current.setAttribute( SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, parameterizedPathname ? 'route' : 'url', ); } else { currentRouterPatchingNavigationSpanRef.current = startBrowserTracingNavigationSpan(client, { - name: parameterizedPathname ?? WINDOW.location.pathname, + name: parameterizedPathname ?? pathname, attributes: { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.nextjs.app_router_instrumentation', [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: parameterizedPathname ? 'route' : 'url', @@ -217,10 +227,10 @@ function patchRouter(client: Client, router: NextRouter, currentNavigationSpanRe const normalizedHref = basePath && typeof href === 'string' && !href.startsWith(basePath) ? `${basePath}${href}` : href; if (routerFunctionName === 'push') { - transactionName = transactionNameifyRouterArgument(normalizedHref); + transactionName = stripTrailingSlash(transactionNameifyRouterArgument(normalizedHref)); transactionAttributes['navigation.type'] = 'router.push'; } else if (routerFunctionName === 'replace') { - transactionName = transactionNameifyRouterArgument(normalizedHref); + transactionName = stripTrailingSlash(transactionNameifyRouterArgument(normalizedHref)); transactionAttributes['navigation.type'] = 'router.replace'; } else if (routerFunctionName === 'back') { transactionAttributes['navigation.type'] = 'router.back'; diff --git a/packages/nextjs/src/client/routing/parameterization.ts b/packages/nextjs/src/client/routing/parameterization.ts index 1bf6c22d5fe0..6bcffa43d420 100644 --- a/packages/nextjs/src/client/routing/parameterization.ts +++ b/packages/nextjs/src/client/routing/parameterization.ts @@ -178,9 +178,16 @@ export const maybeParameterizeRoute = (route: string): string | undefined => { return undefined; } + // Normalize trailing slashes to handle `trailingSlash: true` in Next.js config. + // When trailingSlash is enabled, all URLs get a trailing slash appended (e.g. '/about' becomes '/about/'), + // but route manifests store paths without trailing slashes. Without normalization, static routes fail + // exact-match checks and dynamic route regexes don't match, causing all routes to fall through to + // catch-all patterns. See: https://github.com/getsentry/sentry-javascript/issues/19241 + const normalizedRoute = route.length > 1 && route.endsWith('/') ? route.slice(0, -1) : route; + // Check route result cache after manifest validation - if (routeResultCache.has(route)) { - return routeResultCache.get(route); + if (routeResultCache.has(normalizedRoute)) { + return routeResultCache.get(normalizedRoute); } const { staticRoutes, dynamicRoutes } = manifest; @@ -188,12 +195,12 @@ export const maybeParameterizeRoute = (route: string): string | undefined => { return undefined; } - const matches = findMatchingRoutes(route, staticRoutes, dynamicRoutes); + const matches = findMatchingRoutes(normalizedRoute, 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); + routeResultCache.set(normalizedRoute, result); return result; }; diff --git a/packages/nextjs/test/client/parameterization.test.ts b/packages/nextjs/test/client/parameterization.test.ts index e593596aa8c1..dd7abff0bcd0 100644 --- a/packages/nextjs/test/client/parameterization.test.ts +++ b/packages/nextjs/test/client/parameterization.test.ts @@ -933,4 +933,133 @@ describe('maybeParameterizeRoute', () => { expect(maybeParameterizeRoute('/fr/about')).toBe('/:locale/about'); }); }); + + describe('trailing slash normalization (trailingSlash: true)', () => { + it('should match static routes when path has a trailing slash', () => { + const manifest: RouteManifest = { + staticRoutes: [{ path: '/' }, { path: '/about' }, { path: '/settings/profile' }], + dynamicRoutes: [], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + expect(maybeParameterizeRoute('/about/')).toBeUndefined(); + expect(maybeParameterizeRoute('/settings/profile/')).toBeUndefined(); + // Root path should still work + expect(maybeParameterizeRoute('/')).toBeUndefined(); + }); + + it('should match dynamic routes when path has a trailing slash', () => { + const manifest: RouteManifest = { + staticRoutes: [], + dynamicRoutes: [ + { + path: '/users/:id', + regex: '^/users/([^/]+)$', + paramNames: ['id'], + }, + { + path: '/users/:id/posts/:postId', + regex: '^/users/([^/]+)/posts/([^/]+)$', + paramNames: ['id', 'postId'], + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + expect(maybeParameterizeRoute('/users/123/')).toBe('/users/:id'); + expect(maybeParameterizeRoute('/users/123/posts/456/')).toBe('/users/:id/posts/:postId'); + }); + + it('should not incorrectly match catch-all routes when a more specific route exists and path has trailing slash', () => { + const manifest: RouteManifest = { + staticRoutes: [{ path: '/' }, { path: '/about' }, { path: '/contact' }], + dynamicRoutes: [ + { + path: '/blog/:slug', + regex: '^/blog/([^/]+)$', + paramNames: ['slug'], + }, + { + path: '/parameterized/:param', + regex: '^/parameterized/([^/]+)$', + paramNames: ['param'], + }, + { + path: '/:slug*', + regex: '^/(.+)$', + paramNames: ['slug'], + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + // Static routes with trailing slash should NOT fall through to catch-all + expect(maybeParameterizeRoute('/about/')).toBeUndefined(); + expect(maybeParameterizeRoute('/contact/')).toBeUndefined(); + + // Dynamic routes with trailing slash should match correctly, not catch-all + expect(maybeParameterizeRoute('/blog/my-post/')).toBe('/blog/:slug'); + expect(maybeParameterizeRoute('/parameterized/some-value/')).toBe('/parameterized/:param'); + + // Actual catch-all routes should still work + expect(maybeParameterizeRoute('/unknown/path')).toBe('/:slug*'); + expect(maybeParameterizeRoute('/unknown/path/')).toBe('/:slug*'); + }); + + it('should handle trailing slash with optional catch-all routes', () => { + const manifest: RouteManifest = { + staticRoutes: [{ path: '/' }, { path: '/static-page' }], + dynamicRoutes: [ + { + path: '/parameterized/:param', + regex: '^/parameterized/([^/]+)$', + paramNames: ['param'], + }, + { + path: '/:slug*?', + regex: '^/(.*)$', + paramNames: ['slug'], + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + // Static route with trailing slash should not match the optional catch-all + expect(maybeParameterizeRoute('/static-page/')).toBeUndefined(); + + // Dynamic route with trailing slash should not match the optional catch-all + expect(maybeParameterizeRoute('/parameterized/value/')).toBe('/parameterized/:param'); + + // Root with trailing slash is just '/' - should match static + expect(maybeParameterizeRoute('/')).toBeUndefined(); + }); + + it('should produce the same result for paths with and without trailing slashes', () => { + const manifest: RouteManifest = { + staticRoutes: [{ path: '/' }, { path: '/about' }], + dynamicRoutes: [ + { + path: '/users/:id', + regex: '^/users/([^/]+)$', + paramNames: ['id'], + }, + { + path: '/:slug*', + regex: '^/(.+)$', + paramNames: ['slug'], + }, + ], + }; + globalWithInjectedManifest._sentryRouteManifest = JSON.stringify(manifest); + + // Static routes + expect(maybeParameterizeRoute('/about')).toBe(maybeParameterizeRoute('/about/')); + + // Dynamic routes + expect(maybeParameterizeRoute('/users/123')).toBe(maybeParameterizeRoute('/users/123/')); + + // Root + expect(maybeParameterizeRoute('/')).toBe(maybeParameterizeRoute('/')); + }); + }); });