From 88d8541ce684ab74c4a6db68b05f2c52b2beb3f2 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Wed, 12 Nov 2025 21:58:45 +0200 Subject: [PATCH 01/14] fix: Mark ISR pages for client side to ignore meta trace contents --- .../appRouterRoutingInstrumentation.ts | 65 ++++++++++++++++--- .../src/client/routing/parameterization.ts | 1 + .../config/manifest/createRouteManifest.ts | 38 +++++++++-- packages/nextjs/src/config/manifest/types.ts | 5 ++ 4 files changed, 96 insertions(+), 13 deletions(-) diff --git a/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts b/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts index 4006496d4a23..c8762eff0ee0 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 type { RouteManifest } from '../../config/manifest/types'; import { maybeParameterizeRoute } from './parameterization'; export const INCOMPLETE_APP_ROUTER_INSTRUMENTATION_TRANSACTION_NAME = 'incomplete-app-router-transaction'; @@ -33,20 +34,66 @@ let navigationRoutingMode: 'router-patch' | 'transition-start-hook' = 'router-pa const currentRouterPatchingNavigationSpanRef: NavigationSpanRef = { current: undefined }; +/** + * Check if the current route is an ISR/SSG page by looking it up in the route manifest + */ +function isIsrSsgRoute(pathname: string): boolean { + const globalWithManifest = GLOBAL_OBJ as typeof GLOBAL_OBJ & { + _sentryRouteManifest?: string | RouteManifest; + }; + + const manifestData = globalWithManifest._sentryRouteManifest; + if (!manifestData) { + return false; + } + + let manifest: RouteManifest; + if (typeof manifestData === 'string') { + try { + manifest = JSON.parse(manifestData); + } catch { + return false; + } + } else { + manifest = manifestData; + } + + if (!manifest.isrRoutes || manifest.isrRoutes.length === 0) { + return false; + } + + // Check if the pathname matches any ISR route + // For dynamic routes, we need to match the parameterized pattern + const parameterizedPath = maybeParameterizeRoute(pathname); + const pathToCheck = parameterizedPath || pathname; + + return manifest.isrRoutes.includes(pathToCheck); +} + /** 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: 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]: parameterizedPathname ? 'route' : 'url', + + // Check if this is an ISR/SSG page + // if so, don't use cached trace meta tags to prevent using cached trace data + const isIsrSsgPage = isIsrSsgRoute(WINDOW.location.pathname); + + startBrowserTracingPageLoadSpan( + client, + { + 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]: parameterizedPathname ? 'route' : 'url', + }, }, - }); + // For ISR/SSG pages, pass empty trace data to prevent using cached meta tags + isIsrSsgPage ? { sentryTrace: undefined, baggage: undefined } : undefined, + ); } interface NavigationSpanRef { diff --git a/packages/nextjs/src/client/routing/parameterization.ts b/packages/nextjs/src/client/routing/parameterization.ts index d13097435f41..1322b3d1b309 100644 --- a/packages/nextjs/src/client/routing/parameterization.ts +++ b/packages/nextjs/src/client/routing/parameterization.ts @@ -96,6 +96,7 @@ function getManifest(): RouteManifest | null { let manifest: RouteManifest = { staticRoutes: [], dynamicRoutes: [], + isrRoutes: [], }; // Shallow check if the manifest is actually what we expect it to be diff --git a/packages/nextjs/src/config/manifest/createRouteManifest.ts b/packages/nextjs/src/config/manifest/createRouteManifest.ts index 5e2a99f66285..d5a727959ae8 100644 --- a/packages/nextjs/src/config/manifest/createRouteManifest.ts +++ b/packages/nextjs/src/config/manifest/createRouteManifest.ts @@ -115,23 +115,46 @@ function hasOptionalPrefix(paramNames: string[]): boolean { return firstParam === 'locale' || firstParam === 'lang' || firstParam === 'language'; } +/** + * Check if a page file exports generateStaticParams (ISR/SSG indicator) + */ +function checkForGenerateStaticParams(pageFilePath: string): boolean { + try { + const content = fs.readFileSync(pageFilePath, 'utf8'); + // check for generateStaticParams export + // the regex covers `export function generateStaticParams`, `export async function generateStaticParams`, `export const generateStaticParams` + return /export\s+(async\s+)?function\s+generateStaticParams|export\s+const\s+generateStaticParams/.test(content); + } catch { + return false; + } +} + function scanAppDirectory( dir: string, basePath: string = '', includeRouteGroups: boolean = false, -): { dynamicRoutes: RouteInfo[]; staticRoutes: RouteInfo[] } { +): { dynamicRoutes: RouteInfo[]; staticRoutes: RouteInfo[]; isrRoutes: string[] } { const dynamicRoutes: RouteInfo[] = []; const staticRoutes: RouteInfo[] = []; + const isrRoutes: string[] = []; try { const entries = fs.readdirSync(dir, { withFileTypes: true }); - const pageFile = entries.some(entry => isPageFile(entry.name)); + const pageFile = entries.find(entry => isPageFile(entry.name)); if (pageFile) { // Conditionally normalize the path based on includeRouteGroups option const routePath = includeRouteGroups ? basePath || '/' : normalizeRoutePath(basePath || '/'); const isDynamic = routePath.includes(':'); + // Check if this page has generateStaticParams (ISR/SSG indicator) + const pageFilePath = path.join(dir, pageFile.name); + const hasGenerateStaticParams = checkForGenerateStaticParams(pageFilePath); + + if (hasGenerateStaticParams) { + isrRoutes.push(routePath); + } + if (isDynamic) { const { regex, paramNames, hasOptionalPrefix } = buildRegexForDynamicRoute(routePath); dynamicRoutes.push({ @@ -172,6 +195,7 @@ function scanAppDirectory( dynamicRoutes.push(...subRoutes.dynamicRoutes); staticRoutes.push(...subRoutes.staticRoutes); + isrRoutes.push(...subRoutes.isrRoutes); } } } catch (error) { @@ -179,7 +203,7 @@ function scanAppDirectory( console.warn('Error building route manifest:', error); } - return { dynamicRoutes, staticRoutes }; + return { dynamicRoutes, staticRoutes, isrRoutes }; } /** @@ -204,6 +228,7 @@ export function createRouteManifest(options?: CreateRouteManifestOptions): Route if (!targetDir) { return { + isrRoutes: [], dynamicRoutes: [], staticRoutes: [], }; @@ -214,11 +239,16 @@ export function createRouteManifest(options?: CreateRouteManifestOptions): Route return manifestCache; } - const { dynamicRoutes, staticRoutes } = scanAppDirectory(targetDir, options?.basePath, options?.includeRouteGroups); + const { dynamicRoutes, staticRoutes, isrRoutes } = scanAppDirectory( + targetDir, + options?.basePath, + options?.includeRouteGroups, + ); const manifest: RouteManifest = { dynamicRoutes, staticRoutes, + isrRoutes, }; // set cache diff --git a/packages/nextjs/src/config/manifest/types.ts b/packages/nextjs/src/config/manifest/types.ts index 0a0946be70f7..99fc42fb27d7 100644 --- a/packages/nextjs/src/config/manifest/types.ts +++ b/packages/nextjs/src/config/manifest/types.ts @@ -34,4 +34,9 @@ export type RouteManifest = { * List of all static routes */ staticRoutes: RouteInfo[]; + + /** + * List of ISR/SSG routes (routes with generateStaticParams) + */ + isrRoutes: string[]; }; From 9a8b48690702246a627062315f93f324652dc560 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Wed, 12 Nov 2025 22:12:50 +0200 Subject: [PATCH 02/14] fix: just delete the tags --- packages/nextjs/src/client/index.ts | 5 ++ .../appRouterRoutingInstrumentation.ts | 66 +++---------------- .../src/client/routing/isrRoutingTracing.ts | 62 +++++++++++++++++ 3 files changed, 77 insertions(+), 56 deletions(-) create mode 100644 packages/nextjs/src/client/routing/isrRoutingTracing.ts diff --git a/packages/nextjs/src/client/index.ts b/packages/nextjs/src/client/index.ts index 4d09e7e2d170..0b6680744012 100644 --- a/packages/nextjs/src/client/index.ts +++ b/packages/nextjs/src/client/index.ts @@ -15,6 +15,7 @@ export * from '../common'; export { captureUnderscoreErrorException } from '../common/pages-router-instrumentation/_error'; export { browserTracingIntegration } from './browserTracingIntegration'; export { captureRouterTransitionStart } from './routing/appRouterRoutingInstrumentation'; +import { removeIsrSsgTraceMetaTags } from './routing/isrRoutingTracing'; let clientIsInitialized = false; @@ -41,6 +42,10 @@ export function init(options: BrowserOptions): Client | undefined { } clientIsInitialized = true; + // Remove cached trace meta tags for ISR/SSG pages before initializing + // This prevents the browser tracing integration from using stale trace IDs + removeIsrSsgTraceMetaTags(); + const opts = { environment: getVercelEnv(true) || process.env.NODE_ENV, defaultIntegrations: getDefaultIntegrations(options), diff --git a/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts b/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts index c8762eff0ee0..83bc7c60e388 100644 --- a/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts +++ b/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts @@ -1,14 +1,13 @@ import type { Client, Span } from '@sentry/core'; import { browserPerformanceTimeOrigin, - GLOBAL_OBJ, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, } from '@sentry/core'; import { startBrowserTracingNavigationSpan, startBrowserTracingPageLoadSpan, WINDOW } from '@sentry/react'; -import type { RouteManifest } from '../../config/manifest/types'; import { maybeParameterizeRoute } from './parameterization'; +import { GLOBAL_OBJ } from '@sentry/core'; export const INCOMPLETE_APP_ROUTER_INSTRUMENTATION_TRANSACTION_NAME = 'incomplete-app-router-transaction'; @@ -34,66 +33,21 @@ let navigationRoutingMode: 'router-patch' | 'transition-start-hook' = 'router-pa const currentRouterPatchingNavigationSpanRef: NavigationSpanRef = { current: undefined }; -/** - * Check if the current route is an ISR/SSG page by looking it up in the route manifest - */ -function isIsrSsgRoute(pathname: string): boolean { - const globalWithManifest = GLOBAL_OBJ as typeof GLOBAL_OBJ & { - _sentryRouteManifest?: string | RouteManifest; - }; - - const manifestData = globalWithManifest._sentryRouteManifest; - if (!manifestData) { - return false; - } - - let manifest: RouteManifest; - if (typeof manifestData === 'string') { - try { - manifest = JSON.parse(manifestData); - } catch { - return false; - } - } else { - manifest = manifestData; - } - - if (!manifest.isrRoutes || manifest.isrRoutes.length === 0) { - return false; - } - - // Check if the pathname matches any ISR route - // For dynamic routes, we need to match the parameterized pattern - const parameterizedPath = maybeParameterizeRoute(pathname); - const pathToCheck = parameterizedPath || pathname; - - return manifest.isrRoutes.includes(pathToCheck); -} - /** Instruments the Next.js app router for pageloads. */ export function appRouterInstrumentPageLoad(client: Client): void { const parameterizedPathname = maybeParameterizeRoute(WINDOW.location.pathname); const origin = browserPerformanceTimeOrigin(); - // Check if this is an ISR/SSG page - // if so, don't use cached trace meta tags to prevent using cached trace data - const isIsrSsgPage = isIsrSsgRoute(WINDOW.location.pathname); - - startBrowserTracingPageLoadSpan( - client, - { - 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]: parameterizedPathname ? 'route' : 'url', - }, + startBrowserTracingPageLoadSpan(client, { + 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]: parameterizedPathname ? 'route' : 'url', }, - // For ISR/SSG pages, pass empty trace data to prevent using cached meta tags - isIsrSsgPage ? { sentryTrace: undefined, baggage: undefined } : undefined, - ); + }); } interface NavigationSpanRef { diff --git a/packages/nextjs/src/client/routing/isrRoutingTracing.ts b/packages/nextjs/src/client/routing/isrRoutingTracing.ts new file mode 100644 index 000000000000..94b3ac32d632 --- /dev/null +++ b/packages/nextjs/src/client/routing/isrRoutingTracing.ts @@ -0,0 +1,62 @@ +import type { RouteManifest } from '../../config/manifest/types'; +import { maybeParameterizeRoute } from './parameterization'; +import { GLOBAL_OBJ } from '@sentry/core'; + +const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { + _sentryRouteManifest: string | RouteManifest; +}; + +/** + * Check if the current page is an ISR/SSG route by checking the route manifest. + */ +function isIsrSsgRoute(pathname: string): boolean { + const manifestData = globalWithInjectedValues._sentryRouteManifest; + if (!manifestData) { + return false; + } + + let manifest: RouteManifest; + if (typeof manifestData === 'string') { + try { + manifest = JSON.parse(manifestData); + } catch { + return false; + } + } else { + manifest = manifestData; + } + + if (!manifest.isrRoutes || manifest.isrRoutes.length === 0) { + return false; + } + + const parameterizedPath = maybeParameterizeRoute(pathname); + const pathToCheck = parameterizedPath || pathname; + + return manifest.isrRoutes.includes(pathToCheck); +} + +/** + * Remove sentry-trace and baggage meta tags from the DOM if this is an ISR/SSG page. + * This prevents the browser tracing integration from using stale/cached trace IDs. + */ +export function removeIsrSsgTraceMetaTags(): void { + if (typeof document === 'undefined') { + return; + } + + if (!isIsrSsgRoute(window.location.pathname)) { + return; + } + + // Remove the meta tags so browserTracingIntegration won't pick them up + const sentryTraceMeta = document.querySelector('meta[name="sentry-trace"]'); + if (sentryTraceMeta) { + sentryTraceMeta.remove(); + } + + const baggageMeta = document.querySelector('meta[name="baggage"]'); + if (baggageMeta) { + baggageMeta.remove(); + } +} From 81731adad8914b30d7c1c9d1effa1a066a6cac52 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Wed, 12 Nov 2025 22:16:22 +0200 Subject: [PATCH 03/14] refactor: add a helper fn --- .../src/client/routing/isrRoutingTracing.ts | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/packages/nextjs/src/client/routing/isrRoutingTracing.ts b/packages/nextjs/src/client/routing/isrRoutingTracing.ts index 94b3ac32d632..7ff124dcf228 100644 --- a/packages/nextjs/src/client/routing/isrRoutingTracing.ts +++ b/packages/nextjs/src/client/routing/isrRoutingTracing.ts @@ -45,18 +45,23 @@ export function removeIsrSsgTraceMetaTags(): void { return; } + // Helper function to remove a meta tag + const removeMetaTag = (metaName: string) => { + try { + const meta = document.querySelector(`meta[name="${metaName}"]`); + if (meta) { + meta.remove(); + } + } catch { + // ignore errors when removing the meta tag + } + }; + if (!isIsrSsgRoute(window.location.pathname)) { return; } // Remove the meta tags so browserTracingIntegration won't pick them up - const sentryTraceMeta = document.querySelector('meta[name="sentry-trace"]'); - if (sentryTraceMeta) { - sentryTraceMeta.remove(); - } - - const baggageMeta = document.querySelector('meta[name="baggage"]'); - if (baggageMeta) { - baggageMeta.remove(); - } + removeMetaTag('sentry-trace'); + removeMetaTag('baggage'); } From ddef94cc09e81d4057c5c7dda1021a9fc55ea2ea Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Wed, 12 Nov 2025 22:33:29 +0200 Subject: [PATCH 04/14] tests: update tests --- .../nextjs/src/client/routing/isrRoutingTracing.ts | 12 ++++-------- .../manifest/suites/base-path/base-path.test.ts | 1 + .../suites/catchall-at-root/catchall-at-root.test.ts | 1 + .../config/manifest/suites/catchall/catchall.test.ts | 1 + .../config/manifest/suites/dynamic/dynamic.test.ts | 1 + .../suites/file-extensions/file-extensions.test.ts | 1 + .../suites/route-groups/route-groups.test.ts | 2 ++ .../config/manifest/suites/static/static.test.ts | 1 + 8 files changed, 12 insertions(+), 8 deletions(-) diff --git a/packages/nextjs/src/client/routing/isrRoutingTracing.ts b/packages/nextjs/src/client/routing/isrRoutingTracing.ts index 7ff124dcf228..4d53946cd94e 100644 --- a/packages/nextjs/src/client/routing/isrRoutingTracing.ts +++ b/packages/nextjs/src/client/routing/isrRoutingTracing.ts @@ -1,8 +1,8 @@ import type { RouteManifest } from '../../config/manifest/types'; import { maybeParameterizeRoute } from './parameterization'; -import { GLOBAL_OBJ } from '@sentry/core'; +import { WINDOW } from '@sentry/react'; -const globalWithInjectedValues = GLOBAL_OBJ as typeof GLOBAL_OBJ & { +const globalWithInjectedValues = WINDOW as typeof WINDOW & { _sentryRouteManifest: string | RouteManifest; }; @@ -41,14 +41,14 @@ function isIsrSsgRoute(pathname: string): boolean { * This prevents the browser tracing integration from using stale/cached trace IDs. */ export function removeIsrSsgTraceMetaTags(): void { - if (typeof document === 'undefined') { + if (!WINDOW.document || !isIsrSsgRoute(WINDOW.location.pathname)) { return; } // Helper function to remove a meta tag const removeMetaTag = (metaName: string) => { try { - const meta = document.querySelector(`meta[name="${metaName}"]`); + const meta = WINDOW.document.querySelector(`meta[name="${metaName}"]`); if (meta) { meta.remove(); } @@ -57,10 +57,6 @@ export function removeIsrSsgTraceMetaTags(): void { } }; - if (!isIsrSsgRoute(window.location.pathname)) { - return; - } - // Remove the meta tags so browserTracingIntegration won't pick them up removeMetaTag('sentry-trace'); removeMetaTag('baggage'); diff --git a/packages/nextjs/test/config/manifest/suites/base-path/base-path.test.ts b/packages/nextjs/test/config/manifest/suites/base-path/base-path.test.ts index 097e3f603693..7a9a88033d85 100644 --- a/packages/nextjs/test/config/manifest/suites/base-path/base-path.test.ts +++ b/packages/nextjs/test/config/manifest/suites/base-path/base-path.test.ts @@ -19,6 +19,7 @@ describe('basePath', () => { hasOptionalPrefix: false, }, ], + isrRoutes: [], }); }); diff --git a/packages/nextjs/test/config/manifest/suites/catchall-at-root/catchall-at-root.test.ts b/packages/nextjs/test/config/manifest/suites/catchall-at-root/catchall-at-root.test.ts index 8d78f24a0986..4e8e62199592 100644 --- a/packages/nextjs/test/config/manifest/suites/catchall-at-root/catchall-at-root.test.ts +++ b/packages/nextjs/test/config/manifest/suites/catchall-at-root/catchall-at-root.test.ts @@ -16,6 +16,7 @@ describe('catchall', () => { hasOptionalPrefix: false, }, ], + isrRoutes: [], }); }); 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 d259a1a38223..34b9334dba05 100644 --- a/packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts +++ b/packages/nextjs/test/config/manifest/suites/catchall/catchall.test.ts @@ -16,6 +16,7 @@ describe('catchall', () => { hasOptionalPrefix: false, }, ], + isrRoutes: [], }); }); 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 2ea4b4aca5d8..fb0111d941e5 100644 --- a/packages/nextjs/test/config/manifest/suites/dynamic/dynamic.test.ts +++ b/packages/nextjs/test/config/manifest/suites/dynamic/dynamic.test.ts @@ -34,6 +34,7 @@ describe('dynamic', () => { hasOptionalPrefix: false, }, ], + isrRoutes: [], }); }); 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 2c898b1e8e96..79daf2d4d58c 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 @@ -16,6 +16,7 @@ describe('file-extensions', () => { { path: '/typescript' }, ], dynamicRoutes: [], + isrRoutes: [], }); }); }); 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 8e1fe463190e..c2d455361c4c 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 @@ -26,6 +26,7 @@ describe('route-groups', () => { hasOptionalPrefix: false, }, ], + isrRoutes: [], }); }); @@ -59,6 +60,7 @@ describe('route-groups', () => { hasOptionalPrefix: false, }, ], + isrRoutes: [], }); }); 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 a6f03f49b6fe..6701ef0875d4 100644 --- a/packages/nextjs/test/config/manifest/suites/static/static.test.ts +++ b/packages/nextjs/test/config/manifest/suites/static/static.test.ts @@ -8,6 +8,7 @@ describe('static', () => { expect(manifest).toEqual({ staticRoutes: [{ path: '/' }, { path: '/some/nested' }, { path: '/user' }, { path: '/users' }], dynamicRoutes: [], + isrRoutes: [], }); }); }); From 7d1bdacb8e72456690bc1e14ddb5a373e55d425c Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Wed, 12 Nov 2025 22:36:46 +0200 Subject: [PATCH 05/14] fix: lint --- packages/nextjs/src/client/index.ts | 2 +- .../src/client/routing/appRouterRoutingInstrumentation.ts | 3 +-- packages/nextjs/src/client/routing/isrRoutingTracing.ts | 6 +++--- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/nextjs/src/client/index.ts b/packages/nextjs/src/client/index.ts index 0b6680744012..a9e33cedbb1b 100644 --- a/packages/nextjs/src/client/index.ts +++ b/packages/nextjs/src/client/index.ts @@ -8,6 +8,7 @@ import { isRedirectNavigationError } from '../common/nextNavigationErrorUtils'; import { browserTracingIntegration } from './browserTracingIntegration'; import { nextjsClientStackFrameNormalizationIntegration } from './clientNormalizationIntegration'; import { INCOMPLETE_APP_ROUTER_INSTRUMENTATION_TRANSACTION_NAME } from './routing/appRouterRoutingInstrumentation'; +import { removeIsrSsgTraceMetaTags } from './routing/isrRoutingTracing'; import { applyTunnelRouteOption } from './tunnelRoute'; export * from '@sentry/react'; @@ -15,7 +16,6 @@ export * from '../common'; export { captureUnderscoreErrorException } from '../common/pages-router-instrumentation/_error'; export { browserTracingIntegration } from './browserTracingIntegration'; export { captureRouterTransitionStart } from './routing/appRouterRoutingInstrumentation'; -import { removeIsrSsgTraceMetaTags } from './routing/isrRoutingTracing'; let clientIsInitialized = false; diff --git a/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts b/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts index 83bc7c60e388..4006496d4a23 100644 --- a/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts +++ b/packages/nextjs/src/client/routing/appRouterRoutingInstrumentation.ts @@ -1,13 +1,13 @@ import type { Client, Span } from '@sentry/core'; import { browserPerformanceTimeOrigin, + GLOBAL_OBJ, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, } from '@sentry/core'; import { startBrowserTracingNavigationSpan, startBrowserTracingPageLoadSpan, WINDOW } from '@sentry/react'; import { maybeParameterizeRoute } from './parameterization'; -import { GLOBAL_OBJ } from '@sentry/core'; export const INCOMPLETE_APP_ROUTER_INSTRUMENTATION_TRANSACTION_NAME = 'incomplete-app-router-transaction'; @@ -37,7 +37,6 @@ const currentRouterPatchingNavigationSpanRef: NavigationSpanRef = { current: und export function appRouterInstrumentPageLoad(client: Client): void { const parameterizedPathname = maybeParameterizeRoute(WINDOW.location.pathname); const origin = browserPerformanceTimeOrigin(); - startBrowserTracingPageLoadSpan(client, { name: parameterizedPathname ?? WINDOW.location.pathname, // pageload should always start at timeOrigin (and needs to be in s, not ms) diff --git a/packages/nextjs/src/client/routing/isrRoutingTracing.ts b/packages/nextjs/src/client/routing/isrRoutingTracing.ts index 4d53946cd94e..1201e0e42b8d 100644 --- a/packages/nextjs/src/client/routing/isrRoutingTracing.ts +++ b/packages/nextjs/src/client/routing/isrRoutingTracing.ts @@ -1,6 +1,6 @@ +import { WINDOW } from '@sentry/react'; import type { RouteManifest } from '../../config/manifest/types'; import { maybeParameterizeRoute } from './parameterization'; -import { WINDOW } from '@sentry/react'; const globalWithInjectedValues = WINDOW as typeof WINDOW & { _sentryRouteManifest: string | RouteManifest; @@ -46,7 +46,7 @@ export function removeIsrSsgTraceMetaTags(): void { } // Helper function to remove a meta tag - const removeMetaTag = (metaName: string) => { + function removeMetaTag(metaName: string): void { try { const meta = WINDOW.document.querySelector(`meta[name="${metaName}"]`); if (meta) { @@ -55,7 +55,7 @@ export function removeIsrSsgTraceMetaTags(): void { } catch { // ignore errors when removing the meta tag } - }; + } // Remove the meta tags so browserTracingIntegration won't pick them up removeMetaTag('sentry-trace'); From 0d0706a08abdf5fc12dc69ba820b892aae58227d Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 13 Nov 2025 01:46:03 +0200 Subject: [PATCH 06/14] test: added unit tests for isr route matching and detection --- .../app/articles/[category]/[slug]/page.tsx | 2 + .../manifest/suites/isr/app/blog/page.tsx | 2 + .../suites/isr/app/docs/[[...path]]/page.tsx | 2 + .../isr/app/guides/[...segments]/page.tsx | 2 + .../config/manifest/suites/isr/app/page.tsx | 2 + .../suites/isr/app/posts/[slug]/page.tsx | 2 + .../suites/isr/app/products/[id]/page.tsx | 2 + .../manifest/suites/isr/app/regular/page.tsx | 2 + .../isr/app/users/[id]/profile/page.tsx | 2 + .../config/manifest/suites/isr/isr.test.ts | 345 ++++++++++++++++++ 10 files changed, 363 insertions(+) create mode 100644 packages/nextjs/test/config/manifest/suites/isr/app/articles/[category]/[slug]/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/isr/app/blog/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/isr/app/docs/[[...path]]/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/isr/app/guides/[...segments]/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/isr/app/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/isr/app/posts/[slug]/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/isr/app/products/[id]/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/isr/app/regular/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/isr/app/users/[id]/profile/page.tsx create mode 100644 packages/nextjs/test/config/manifest/suites/isr/isr.test.ts diff --git a/packages/nextjs/test/config/manifest/suites/isr/app/articles/[category]/[slug]/page.tsx b/packages/nextjs/test/config/manifest/suites/isr/app/articles/[category]/[slug]/page.tsx new file mode 100644 index 000000000000..24b26184353a --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/isr/app/articles/[category]/[slug]/page.tsx @@ -0,0 +1,2 @@ +// Nested dynamic ISR page +export async function generateStaticParams(): Promise {} diff --git a/packages/nextjs/test/config/manifest/suites/isr/app/blog/page.tsx b/packages/nextjs/test/config/manifest/suites/isr/app/blog/page.tsx new file mode 100644 index 000000000000..027e160f91d3 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/isr/app/blog/page.tsx @@ -0,0 +1,2 @@ +// Static ISR page +export async function generateStaticParams(): Promise {} diff --git a/packages/nextjs/test/config/manifest/suites/isr/app/docs/[[...path]]/page.tsx b/packages/nextjs/test/config/manifest/suites/isr/app/docs/[[...path]]/page.tsx new file mode 100644 index 000000000000..f98be769d9c3 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/isr/app/docs/[[...path]]/page.tsx @@ -0,0 +1,2 @@ +// Optional catchall ISR page +export async function generateStaticParams(): Promise {} diff --git a/packages/nextjs/test/config/manifest/suites/isr/app/guides/[...segments]/page.tsx b/packages/nextjs/test/config/manifest/suites/isr/app/guides/[...segments]/page.tsx new file mode 100644 index 000000000000..b22caba78d2f --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/isr/app/guides/[...segments]/page.tsx @@ -0,0 +1,2 @@ +// Required catchall ISR page +export async function generateStaticParams(): Promise {} diff --git a/packages/nextjs/test/config/manifest/suites/isr/app/page.tsx b/packages/nextjs/test/config/manifest/suites/isr/app/page.tsx new file mode 100644 index 000000000000..b4aef2560f50 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/isr/app/page.tsx @@ -0,0 +1,2 @@ +// Static ISR page at root +export async function generateStaticParams(): Promise {} diff --git a/packages/nextjs/test/config/manifest/suites/isr/app/posts/[slug]/page.tsx b/packages/nextjs/test/config/manifest/suites/isr/app/posts/[slug]/page.tsx new file mode 100644 index 000000000000..a68fb122c81d --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/isr/app/posts/[slug]/page.tsx @@ -0,0 +1,2 @@ +// Dynamic ISR with async function +export const generateStaticParams = async (): Promise => {}; diff --git a/packages/nextjs/test/config/manifest/suites/isr/app/products/[id]/page.tsx b/packages/nextjs/test/config/manifest/suites/isr/app/products/[id]/page.tsx new file mode 100644 index 000000000000..f18f341f1f43 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/isr/app/products/[id]/page.tsx @@ -0,0 +1,2 @@ +// Dynamic ISR page with generateStaticParams +export async function generateStaticParams(): Promise {} diff --git a/packages/nextjs/test/config/manifest/suites/isr/app/regular/page.tsx b/packages/nextjs/test/config/manifest/suites/isr/app/regular/page.tsx new file mode 100644 index 000000000000..b46b50a71d44 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/isr/app/regular/page.tsx @@ -0,0 +1,2 @@ +// Regular page without ISR (no generateStaticParams) +export {}; diff --git a/packages/nextjs/test/config/manifest/suites/isr/app/users/[id]/profile/page.tsx b/packages/nextjs/test/config/manifest/suites/isr/app/users/[id]/profile/page.tsx new file mode 100644 index 000000000000..3774c141885d --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/isr/app/users/[id]/profile/page.tsx @@ -0,0 +1,2 @@ +// Mixed static-dynamic ISR page +export async function generateStaticParams(): Promise {} diff --git a/packages/nextjs/test/config/manifest/suites/isr/isr.test.ts b/packages/nextjs/test/config/manifest/suites/isr/isr.test.ts new file mode 100644 index 000000000000..56d097df0180 --- /dev/null +++ b/packages/nextjs/test/config/manifest/suites/isr/isr.test.ts @@ -0,0 +1,345 @@ +import path from 'path'; +import { describe, expect, test } from 'vitest'; +import { createRouteManifest } from '../../../../../src/config/manifest/createRouteManifest'; + +describe('ISR route detection and matching', () => { + const manifest = createRouteManifest({ appDirPath: path.join(__dirname, 'app') }); + + describe('ISR detection', () => { + test('should detect static ISR pages with generateStaticParams', () => { + expect(manifest.isrRoutes).toContain('/'); + expect(manifest.isrRoutes).toContain('/blog'); + }); + + test('should detect dynamic ISR pages with generateStaticParams', () => { + expect(manifest.isrRoutes).toContain('/products/:id'); + expect(manifest.isrRoutes).toContain('/posts/:slug'); + }); + + test('should detect nested dynamic ISR pages', () => { + expect(manifest.isrRoutes).toContain('/articles/:category/:slug'); + }); + + test('should detect optional catchall ISR pages', () => { + expect(manifest.isrRoutes).toContain('/docs/:path*?'); + }); + + test('should detect required catchall ISR pages', () => { + expect(manifest.isrRoutes).toContain('/guides/:segments*'); + }); + + test('should detect mixed static-dynamic ISR pages', () => { + expect(manifest.isrRoutes).toContain('/users/:id/profile'); + }); + + test('should NOT detect pages without generateStaticParams as ISR', () => { + expect(manifest.isrRoutes).not.toContain('/regular'); + }); + + test('should detect both function and const generateStaticParams', () => { + // /blog uses function declaration + // /posts/[slug] uses const declaration + expect(manifest.isrRoutes).toContain('/blog'); + expect(manifest.isrRoutes).toContain('/posts/:slug'); + }); + + test('should detect async generateStaticParams', () => { + // Multiple pages use async - this should work + expect(manifest.isrRoutes).toContain('/products/:id'); + expect(manifest.isrRoutes).toContain('/posts/:slug'); + }); + }); + + describe('Route matching against pathnames', () => { + describe('single dynamic segment ISR routes', () => { + test('should match /products/:id against various product IDs', () => { + const route = manifest.dynamicRoutes.find(r => r.path === '/products/:id'); + expect(route).toBeDefined(); + const regex = new RegExp(route!.regex!); + + // Should match + expect(regex.test('/products/1')).toBe(true); + expect(regex.test('/products/123')).toBe(true); + expect(regex.test('/products/abc-def')).toBe(true); + expect(regex.test('/products/product-with-dashes')).toBe(true); + expect(regex.test('/products/UPPERCASE')).toBe(true); + + // Should NOT match + expect(regex.test('/products')).toBe(false); + expect(regex.test('/products/')).toBe(false); + expect(regex.test('/products/123/extra')).toBe(false); + expect(regex.test('/product/123')).toBe(false); // typo + }); + + test('should match /posts/:slug against various slugs', () => { + const route = manifest.dynamicRoutes.find(r => r.path === '/posts/:slug'); + expect(route).toBeDefined(); + const regex = new RegExp(route!.regex!); + + // Should match + expect(regex.test('/posts/hello')).toBe(true); + expect(regex.test('/posts/world')).toBe(true); + expect(regex.test('/posts/my-awesome-post')).toBe(true); + expect(regex.test('/posts/post_with_underscores')).toBe(true); + + // Should NOT match + expect(regex.test('/posts')).toBe(false); + expect(regex.test('/posts/')).toBe(false); + expect(regex.test('/posts/hello/world')).toBe(false); + }); + }); + + describe('nested dynamic segments ISR routes', () => { + test('should match /articles/:category/:slug against various paths', () => { + const route = manifest.dynamicRoutes.find(r => r.path === '/articles/:category/:slug'); + expect(route).toBeDefined(); + const regex = new RegExp(route!.regex!); + + // Should match + expect(regex.test('/articles/tech/nextjs-guide')).toBe(true); + expect(regex.test('/articles/tech/react-tips')).toBe(true); + expect(regex.test('/articles/programming/typescript-advanced')).toBe(true); + expect(regex.test('/articles/news/breaking-news-2024')).toBe(true); + + // Should NOT match + expect(regex.test('/articles')).toBe(false); + expect(regex.test('/articles/tech')).toBe(false); + expect(regex.test('/articles/tech/nextjs-guide/extra')).toBe(false); + + // Extract parameters + const match = '/articles/tech/nextjs-guide'.match(regex); + expect(match).toBeTruthy(); + expect(match?.[1]).toBe('tech'); + expect(match?.[2]).toBe('nextjs-guide'); + }); + }); + + describe('mixed static-dynamic ISR routes', () => { + test('should match /users/:id/profile against user profile paths', () => { + const route = manifest.dynamicRoutes.find(r => r.path === '/users/:id/profile'); + expect(route).toBeDefined(); + const regex = new RegExp(route!.regex!); + + // Should match + expect(regex.test('/users/user1/profile')).toBe(true); + expect(regex.test('/users/user2/profile')).toBe(true); + expect(regex.test('/users/john-doe/profile')).toBe(true); + expect(regex.test('/users/123/profile')).toBe(true); + + // Should NOT match + expect(regex.test('/users/user1')).toBe(false); + expect(regex.test('/users/user1/profile/edit')).toBe(false); + expect(regex.test('/users/profile')).toBe(false); + expect(regex.test('/user/user1/profile')).toBe(false); // typo + + // Extract parameter + const match = '/users/john-doe/profile'.match(regex); + expect(match).toBeTruthy(); + expect(match?.[1]).toBe('john-doe'); + }); + }); + + describe('optional catchall ISR routes', () => { + test('should match /docs/:path*? against various documentation paths', () => { + const route = manifest.dynamicRoutes.find(r => r.path === '/docs/:path*?'); + expect(route).toBeDefined(); + const regex = new RegExp(route!.regex!); + + // Should match - with paths + expect(regex.test('/docs/getting-started')).toBe(true); + expect(regex.test('/docs/api/reference')).toBe(true); + expect(regex.test('/docs/guides/installation/quick-start')).toBe(true); + expect(regex.test('/docs/a')).toBe(true); + expect(regex.test('/docs/a/b/c/d/e')).toBe(true); + + // Should match - without path (optional catchall) + expect(regex.test('/docs')).toBe(true); + + // Should NOT match + expect(regex.test('/doc')).toBe(false); // typo + expect(regex.test('/')).toBe(false); + expect(regex.test('/documents/test')).toBe(false); + + // Extract parameters + const matchWithPath = '/docs/api/reference'.match(regex); + expect(matchWithPath).toBeTruthy(); + expect(matchWithPath?.[1]).toBe('api/reference'); + + const matchNoPath = '/docs'.match(regex); + expect(matchNoPath).toBeTruthy(); + // Optional catchall without path + expect(matchNoPath?.[1]).toBeUndefined(); + }); + }); + + describe('required catchall ISR routes', () => { + test('should match /guides/:segments* against guide paths', () => { + const route = manifest.dynamicRoutes.find(r => r.path === '/guides/:segments*'); + expect(route).toBeDefined(); + const regex = new RegExp(route!.regex!); + + // Should match - with paths (required) + expect(regex.test('/guides/intro')).toBe(true); + expect(regex.test('/guides/advanced/topics')).toBe(true); + expect(regex.test('/guides/getting-started/installation/setup')).toBe(true); + + // Should NOT match - without path (required catchall needs at least one segment) + expect(regex.test('/guides')).toBe(false); + expect(regex.test('/guides/')).toBe(false); + + // Should NOT match - wrong path + expect(regex.test('/guide/intro')).toBe(false); // typo + expect(regex.test('/')).toBe(false); + + // Extract parameters + const match = '/guides/advanced/topics'.match(regex); + expect(match).toBeTruthy(); + expect(match?.[1]).toBe('advanced/topics'); + }); + }); + + describe('real-world pathname simulations', () => { + test('should identify ISR pages from window.location.pathname examples', () => { + const testCases = [ + { pathname: '/', isISR: true, matchedRoute: '/' }, + { pathname: '/blog', isISR: true, matchedRoute: '/blog' }, + { pathname: '/products/123', isISR: true, matchedRoute: '/products/:id' }, + { pathname: '/products/gaming-laptop', isISR: true, matchedRoute: '/products/:id' }, + { pathname: '/posts/hello-world', isISR: true, matchedRoute: '/posts/:slug' }, + { pathname: '/articles/tech/nextjs-guide', isISR: true, matchedRoute: '/articles/:category/:slug' }, + { pathname: '/users/john/profile', isISR: true, matchedRoute: '/users/:id/profile' }, + { pathname: '/docs', isISR: true, matchedRoute: '/docs/:path*?' }, + { pathname: '/docs/getting-started', isISR: true, matchedRoute: '/docs/:path*?' }, + { pathname: '/docs/api/reference/advanced', isISR: true, matchedRoute: '/docs/:path*?' }, + { pathname: '/guides/intro', isISR: true, matchedRoute: '/guides/:segments*' }, + { pathname: '/guides/advanced/topics/performance', isISR: true, matchedRoute: '/guides/:segments*' }, + { pathname: '/regular', isISR: false, matchedRoute: null }, + ]; + + testCases.forEach(({ pathname, isISR, matchedRoute }) => { + // Check if pathname matches any ISR route + let foundMatch = false; + let foundRoute = null; + + // Check static ISR routes + if (manifest.isrRoutes.includes(pathname)) { + foundMatch = true; + foundRoute = pathname; + } + + // Check dynamic ISR routes + if (!foundMatch) { + for (const route of manifest.dynamicRoutes) { + if (manifest.isrRoutes.includes(route.path)) { + const regex = new RegExp(route.regex!); + if (regex.test(pathname)) { + foundMatch = true; + foundRoute = route.path; + break; + } + } + } + } + + expect(foundMatch).toBe(isISR); + if (matchedRoute) { + expect(foundRoute).toBe(matchedRoute); + } + }); + }); + }); + + describe('edge cases and special characters', () => { + test('should handle paths with special characters in dynamic segments', () => { + const route = manifest.dynamicRoutes.find(r => r.path === '/products/:id'); + const regex = new RegExp(route!.regex!); + + expect(regex.test('/products/product-123')).toBe(true); + expect(regex.test('/products/product_456')).toBe(true); + expect(regex.test('/products/PRODUCT-ABC')).toBe(true); + expect(regex.test('/products/2024-new-product')).toBe(true); + }); + + test('should handle deeply nested catchall paths', () => { + const route = manifest.dynamicRoutes.find(r => r.path === '/docs/:path*?'); + const regex = new RegExp(route!.regex!); + + expect(regex.test('/docs/a/b/c/d/e/f/g/h/i/j')).toBe(true); + }); + + test('should not match paths with trailing slashes if route does not have them', () => { + const route = manifest.dynamicRoutes.find(r => r.path === '/products/:id'); + const regex = new RegExp(route!.regex!); + + // Most Next.js routes don't match trailing slashes + expect(regex.test('/products/123/')).toBe(false); + }); + }); + + describe('parameter extraction for ISR routes', () => { + test('should extract single parameter from ISR route', () => { + const route = manifest.dynamicRoutes.find(r => r.path === '/products/:id'); + const regex = new RegExp(route!.regex!); + + const match = '/products/gaming-laptop'.match(regex); + expect(match).toBeTruthy(); + expect(route?.paramNames).toEqual(['id']); + expect(match?.[1]).toBe('gaming-laptop'); + }); + + test('should extract multiple parameters from nested ISR route', () => { + const route = manifest.dynamicRoutes.find(r => r.path === '/articles/:category/:slug'); + const regex = new RegExp(route!.regex!); + + const match = '/articles/programming/typescript-advanced'.match(regex); + expect(match).toBeTruthy(); + expect(route?.paramNames).toEqual(['category', 'slug']); + expect(match?.[1]).toBe('programming'); + expect(match?.[2]).toBe('typescript-advanced'); + }); + + test('should extract catchall parameter from ISR route', () => { + const route = manifest.dynamicRoutes.find(r => r.path === '/docs/:path*?'); + const regex = new RegExp(route!.regex!); + + const match = '/docs/api/reference/advanced'.match(regex); + expect(match).toBeTruthy(); + expect(route?.paramNames).toEqual(['path']); + expect(match?.[1]).toBe('api/reference/advanced'); + }); + }); + }); + + describe('complete manifest structure', () => { + test('should have correct structure with all route types', () => { + expect(manifest).toHaveProperty('staticRoutes'); + expect(manifest).toHaveProperty('dynamicRoutes'); + expect(manifest).toHaveProperty('isrRoutes'); + expect(Array.isArray(manifest.staticRoutes)).toBe(true); + expect(Array.isArray(manifest.dynamicRoutes)).toBe(true); + expect(Array.isArray(manifest.isrRoutes)).toBe(true); + }); + + test('should include both ISR and non-ISR routes in main route lists', () => { + // ISR static routes should be in staticRoutes + expect(manifest.staticRoutes.some(r => r.path === '/')).toBe(true); + expect(manifest.staticRoutes.some(r => r.path === '/blog')).toBe(true); + + // Non-ISR static routes should also be in staticRoutes + expect(manifest.staticRoutes.some(r => r.path === '/regular')).toBe(true); + + // ISR dynamic routes should be in dynamicRoutes + expect(manifest.dynamicRoutes.some(r => r.path === '/products/:id')).toBe(true); + }); + + test('should only include ISR routes in isrRoutes list', () => { + // ISR routes should be in the list + expect(manifest.isrRoutes).toContain('/'); + expect(manifest.isrRoutes).toContain('/blog'); + expect(manifest.isrRoutes).toContain('/products/:id'); + + // Non-ISR routes should NOT be in the list + expect(manifest.isrRoutes).not.toContain('/regular'); + }); + }); +}); From 4303908092e91b5334d86b61d1dc5a28447f3440 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 13 Nov 2025 02:23:31 +0200 Subject: [PATCH 07/14] test: added e2e tests --- .../nextjs-15/app/isr-test/[product]/page.tsx | 14 +++ .../nextjs-15/app/isr-test/static/page.tsx | 12 +++ .../app/non-isr-test/[item]/page.tsx | 11 +++ .../nextjs-15/tests/isr-routes.test.ts | 97 +++++++++++++++++++ .../nextjs-16/app/isr-test/[product]/page.tsx | 14 +++ .../nextjs-16/app/isr-test/static/page.tsx | 12 +++ .../app/non-isr-test/[item]/page.tsx | 11 +++ .../nextjs-16/tests/isr-routes.test.ts | 97 +++++++++++++++++++ 8 files changed, 268 insertions(+) create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15/app/isr-test/[product]/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15/app/isr-test/static/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15/app/non-isr-test/[item]/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-15/tests/isr-routes.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/app/isr-test/[product]/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/app/isr-test/static/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/app/non-isr-test/[item]/page.tsx create mode 100644 dev-packages/e2e-tests/test-applications/nextjs-16/tests/isr-routes.test.ts diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/app/isr-test/[product]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-15/app/isr-test/[product]/page.tsx new file mode 100644 index 000000000000..741b10a561a4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/app/isr-test/[product]/page.tsx @@ -0,0 +1,14 @@ +export async function generateStaticParams(): Promise> { + return [{ product: 'laptop' }, { product: 'phone' }, { product: 'tablet' }]; +} + +export default async function ISRProductPage({ params }: { params: Promise<{ product: string }> }) { + const { product } = await params; + + return ( +
+

ISR Product: {product}

+
{product}
+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/app/isr-test/static/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-15/app/isr-test/static/page.tsx new file mode 100644 index 000000000000..6980f81e5e2e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/app/isr-test/static/page.tsx @@ -0,0 +1,12 @@ +export async function generateStaticParams(): Promise { + return []; +} + +export default function ISRStaticPage() { + return ( +
+

ISR Static Page

+
static-isr
+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/app/non-isr-test/[item]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-15/app/non-isr-test/[item]/page.tsx new file mode 100644 index 000000000000..e0bafdb24181 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/app/non-isr-test/[item]/page.tsx @@ -0,0 +1,11 @@ +// No generateStaticParams - this is NOT an ISR page +export default async function NonISRPage({ params }: { params: Promise<{ item: string }> }) { + const { item } = await params; + + return ( +
+

Non-ISR Dynamic Page: {item}

+
{item}
+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/isr-routes.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/isr-routes.test.ts new file mode 100644 index 000000000000..f5b8e122cb67 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/isr-routes.test.ts @@ -0,0 +1,97 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('should remove sentry-trace and baggage meta tags on ISR dynamic route page load', async ({ page }) => { + // Navigate to ISR page + await page.goto('/isr-test/laptop'); + + // Wait for page to be fully loaded + await expect(page.locator('#isr-product-id')).toHaveText('laptop'); + + // Check that sentry-trace and baggage meta tags are removed for ISR pages + await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0); + await expect(page.locator('meta[name="baggage"]')).toHaveCount(0); +}); + +test('should remove sentry-trace and baggage meta tags on ISR static route', async ({ page }) => { + // Navigate to ISR static page + await page.goto('/isr-test/static'); + + // Wait for page to be fully loaded + await expect(page.locator('#isr-static-marker')).toHaveText('static-isr'); + + // Check that sentry-trace and baggage meta tags are removed for ISR pages + await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0); + await expect(page.locator('meta[name="baggage"]')).toHaveCount(0); +}); + +test('should remove meta tags for different ISR dynamic route values', async ({ page }) => { + // Test with 'phone' (one of the pre-generated static params) + await page.goto('/isr-test/phone'); + await expect(page.locator('#isr-product-id')).toHaveText('phone'); + + await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0); + await expect(page.locator('meta[name="baggage"]')).toHaveCount(0); + + // Test with 'tablet' + await page.goto('/isr-test/tablet'); + await expect(page.locator('#isr-product-id')).toHaveText('tablet'); + + await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0); + await expect(page.locator('meta[name="baggage"]')).toHaveCount(0); +}); + +test('should create unique transactions for ISR pages (not using stale trace IDs)', async ({ page }) => { + // First navigation - capture the trace ID + const firstTransactionPromise = waitForTransaction('nextjs-15', async transactionEvent => { + return transactionEvent.transaction === '/isr-test/:product' && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto('/isr-test/laptop'); + const firstTransaction = await firstTransactionPromise; + const firstTraceId = firstTransaction.contexts?.trace?.trace_id; + + expect(firstTraceId).toBeDefined(); + expect(firstTraceId).toMatch(/[a-f0-9]{32}/); + + // Second navigation to the same ISR route with different param + const secondTransactionPromise = waitForTransaction('nextjs-15', async transactionEvent => { + return !!( + transactionEvent.transaction === '/isr-test/:product' && + transactionEvent.contexts?.trace?.op === 'pageload' && + transactionEvent.request?.url?.includes('/isr-test/phone') + ); + }); + + await page.goto('/isr-test/phone'); + const secondTransaction = await secondTransactionPromise; + const secondTraceId = secondTransaction.contexts?.trace?.trace_id; + + expect(secondTraceId).toBeDefined(); + expect(secondTraceId).toMatch(/[a-f0-9]{32}/); + + // Verify that each page load gets a NEW trace ID (not reusing cached/stale ones) + expect(firstTraceId).not.toBe(secondTraceId); +}); + +test('ISR route should be identified correctly in the route manifest', async ({ page }) => { + const transactionPromise = waitForTransaction('nextjs-15', async transactionEvent => { + return transactionEvent.transaction === '/isr-test/:product' && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto('/isr-test/laptop'); + const transaction = await transactionPromise; + + // Verify the transaction is properly parameterized + expect(transaction).toMatchObject({ + transaction: '/isr-test/:product', + transaction_info: { source: 'route' }, + contexts: { + trace: { + data: { + 'sentry.source': 'route', + }, + }, + }, + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/app/isr-test/[product]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16/app/isr-test/[product]/page.tsx new file mode 100644 index 000000000000..741b10a561a4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/app/isr-test/[product]/page.tsx @@ -0,0 +1,14 @@ +export async function generateStaticParams(): Promise> { + return [{ product: 'laptop' }, { product: 'phone' }, { product: 'tablet' }]; +} + +export default async function ISRProductPage({ params }: { params: Promise<{ product: string }> }) { + const { product } = await params; + + return ( +
+

ISR Product: {product}

+
{product}
+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/app/isr-test/static/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16/app/isr-test/static/page.tsx new file mode 100644 index 000000000000..6980f81e5e2e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/app/isr-test/static/page.tsx @@ -0,0 +1,12 @@ +export async function generateStaticParams(): Promise { + return []; +} + +export default function ISRStaticPage() { + return ( +
+

ISR Static Page

+
static-isr
+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/app/non-isr-test/[item]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16/app/non-isr-test/[item]/page.tsx new file mode 100644 index 000000000000..e0bafdb24181 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/app/non-isr-test/[item]/page.tsx @@ -0,0 +1,11 @@ +// No generateStaticParams - this is NOT an ISR page +export default async function NonISRPage({ params }: { params: Promise<{ item: string }> }) { + const { item } = await params; + + return ( +
+

Non-ISR Dynamic Page: {item}

+
{item}
+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/isr-routes.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/isr-routes.test.ts new file mode 100644 index 000000000000..9c462a652c98 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/isr-routes.test.ts @@ -0,0 +1,97 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('should remove sentry-trace and baggage meta tags on ISR dynamic route page load', async ({ page }) => { + // Navigate to ISR page + await page.goto('/isr-test/laptop'); + + // Wait for page to be fully loaded + await expect(page.locator('#isr-product-id')).toHaveText('laptop'); + + // Check that sentry-trace and baggage meta tags are removed for ISR pages + await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0); + await expect(page.locator('meta[name="baggage"]')).toHaveCount(0); +}); + +test('should remove sentry-trace and baggage meta tags on ISR static route', async ({ page }) => { + // Navigate to ISR static page + await page.goto('/isr-test/static'); + + // Wait for page to be fully loaded + await expect(page.locator('#isr-static-marker')).toHaveText('static-isr'); + + // Check that sentry-trace and baggage meta tags are removed for ISR pages + await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0); + await expect(page.locator('meta[name="baggage"]')).toHaveCount(0); +}); + +test('should remove meta tags for different ISR dynamic route values', async ({ page }) => { + // Test with 'phone' (one of the pre-generated static params) + await page.goto('/isr-test/phone'); + await expect(page.locator('#isr-product-id')).toHaveText('phone'); + + await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0); + await expect(page.locator('meta[name="baggage"]')).toHaveCount(0); + + // Test with 'tablet' + await page.goto('/isr-test/tablet'); + await expect(page.locator('#isr-product-id')).toHaveText('tablet'); + + await expect(page.locator('meta[name="sentry-trace"]')).toHaveCount(0); + await expect(page.locator('meta[name="baggage"]')).toHaveCount(0); +}); + +test('should create unique transactions for ISR pages (not using stale trace IDs)', async ({ page }) => { + // First navigation - capture the trace ID + const firstTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { + return transactionEvent.transaction === '/isr-test/:product' && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto('/isr-test/laptop'); + const firstTransaction = await firstTransactionPromise; + const firstTraceId = firstTransaction.contexts?.trace?.trace_id; + + expect(firstTraceId).toBeDefined(); + expect(firstTraceId).toMatch(/[a-f0-9]{32}/); + + // Second navigation to the same ISR route with different param + const secondTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { + return !!( + transactionEvent.transaction === '/isr-test/:product' && + transactionEvent.contexts?.trace?.op === 'pageload' && + transactionEvent.request?.url?.includes('/isr-test/phone') + ); + }); + + await page.goto('/isr-test/phone'); + const secondTransaction = await secondTransactionPromise; + const secondTraceId = secondTransaction.contexts?.trace?.trace_id; + + expect(secondTraceId).toBeDefined(); + expect(secondTraceId).toMatch(/[a-f0-9]{32}/); + + // Verify that each page load gets a NEW trace ID (not reusing cached/stale ones) + expect(firstTraceId).not.toBe(secondTraceId); +}); + +test('ISR route should be identified correctly in the route manifest', async ({ page }) => { + const transactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { + return transactionEvent.transaction === '/isr-test/:product' && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto('/isr-test/laptop'); + const transaction = await transactionPromise; + + // Verify the transaction is properly parameterized + expect(transaction).toMatchObject({ + transaction: '/isr-test/:product', + transaction_info: { source: 'route' }, + contexts: { + trace: { + data: { + 'sentry.source': 'route', + }, + }, + }, + }); +}); From aaf244f3322e6f618856d22422a6652ebd226d08 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 13 Nov 2025 12:01:42 +0200 Subject: [PATCH 08/14] tests: test the actual transaction sending for page loads --- .../nextjs-15/app/isr-test/[product]/page.tsx | 3 + .../nextjs-15/app/isr-test/static/page.tsx | 3 + .../nextjs-15/instrumentation-client.ts | 3 +- .../nextjs-15/tests/isr-routes.test.ts | 59 +++++++++---------- .../nextjs-16/app/isr-test/[product]/page.tsx | 3 + .../nextjs-16/app/isr-test/static/page.tsx | 3 + .../nextjs-16/tests/isr-routes.test.ts | 59 +++++++++---------- 7 files changed, 70 insertions(+), 63 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/app/isr-test/[product]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-15/app/isr-test/[product]/page.tsx index 741b10a561a4..cd1e085e2763 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15/app/isr-test/[product]/page.tsx +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/app/isr-test/[product]/page.tsx @@ -1,3 +1,6 @@ +export const revalidate = 60; // ISR: revalidate every 60 seconds +export const dynamicParams = true; // Allow dynamic params beyond generateStaticParams + export async function generateStaticParams(): Promise> { return [{ product: 'laptop' }, { product: 'phone' }, { product: 'tablet' }]; } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/app/isr-test/static/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-15/app/isr-test/static/page.tsx index 6980f81e5e2e..f49605bd9da4 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15/app/isr-test/static/page.tsx +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/app/isr-test/static/page.tsx @@ -1,3 +1,6 @@ +export const revalidate = 60; // ISR: revalidate every 60 seconds +export const dynamicParams = true; + export async function generateStaticParams(): Promise { return []; } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/instrumentation-client.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/instrumentation-client.ts index 4870c64e7959..0737d2043169 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15/instrumentation-client.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/instrumentation-client.ts @@ -2,10 +2,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, + dsn: 'https://username@domain/123', tunnel: `http://localhost:3031/`, // proxy server tracesSampleRate: 1.0, sendDefaultPii: true, + debug: true, }); export const onRouterTransitionStart = Sentry.captureRouterTransitionStart; diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/isr-routes.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/isr-routes.test.ts index f5b8e122cb67..215f6cbb0bfc 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15/tests/isr-routes.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-15/tests/isr-routes.test.ts @@ -41,37 +41,34 @@ test('should remove meta tags for different ISR dynamic route values', async ({ await expect(page.locator('meta[name="baggage"]')).toHaveCount(0); }); -test('should create unique transactions for ISR pages (not using stale trace IDs)', async ({ page }) => { - // First navigation - capture the trace ID - const firstTransactionPromise = waitForTransaction('nextjs-15', async transactionEvent => { - return transactionEvent.transaction === '/isr-test/:product' && transactionEvent.contexts?.trace?.op === 'pageload'; - }); - - await page.goto('/isr-test/laptop'); - const firstTransaction = await firstTransactionPromise; - const firstTraceId = firstTransaction.contexts?.trace?.trace_id; - - expect(firstTraceId).toBeDefined(); - expect(firstTraceId).toMatch(/[a-f0-9]{32}/); - - // Second navigation to the same ISR route with different param - const secondTransactionPromise = waitForTransaction('nextjs-15', async transactionEvent => { - return !!( - transactionEvent.transaction === '/isr-test/:product' && - transactionEvent.contexts?.trace?.op === 'pageload' && - transactionEvent.request?.url?.includes('/isr-test/phone') - ); - }); - - await page.goto('/isr-test/phone'); - const secondTransaction = await secondTransactionPromise; - const secondTraceId = secondTransaction.contexts?.trace?.trace_id; - - expect(secondTraceId).toBeDefined(); - expect(secondTraceId).toMatch(/[a-f0-9]{32}/); - - // Verify that each page load gets a NEW trace ID (not reusing cached/stale ones) - expect(firstTraceId).not.toBe(secondTraceId); +test('should create unique transactions for ISR pages on each visit', async ({ page }) => { + const traceIds: string[] = []; + + // Load the same ISR page 5 times to ensure cached HTML meta tags are consistently removed + for (let i = 0; i < 5; i++) { + const transactionPromise = waitForTransaction('nextjs-15', async transactionEvent => { + return !!( + transactionEvent.transaction === '/isr-test/:product' && transactionEvent.contexts?.trace?.op === 'pageload' + ); + }); + + if (i === 0) { + await page.goto('/isr-test/laptop'); + } else { + await page.reload(); + } + + const transaction = await transactionPromise; + const traceId = transaction.contexts?.trace?.trace_id; + + expect(traceId).toBeDefined(); + expect(traceId).toMatch(/[a-f0-9]{32}/); + traceIds.push(traceId!); + } + + // Verify all 5 page loads have unique trace IDs (no reuse of cached/stale meta tags) + const uniqueTraceIds = new Set(traceIds); + expect(uniqueTraceIds.size).toBe(5); }); test('ISR route should be identified correctly in the route manifest', async ({ page }) => { diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/app/isr-test/[product]/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16/app/isr-test/[product]/page.tsx index 741b10a561a4..cd1e085e2763 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/app/isr-test/[product]/page.tsx +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/app/isr-test/[product]/page.tsx @@ -1,3 +1,6 @@ +export const revalidate = 60; // ISR: revalidate every 60 seconds +export const dynamicParams = true; // Allow dynamic params beyond generateStaticParams + export async function generateStaticParams(): Promise> { return [{ product: 'laptop' }, { product: 'phone' }, { product: 'tablet' }]; } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/app/isr-test/static/page.tsx b/dev-packages/e2e-tests/test-applications/nextjs-16/app/isr-test/static/page.tsx index 6980f81e5e2e..f49605bd9da4 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/app/isr-test/static/page.tsx +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/app/isr-test/static/page.tsx @@ -1,3 +1,6 @@ +export const revalidate = 60; // ISR: revalidate every 60 seconds +export const dynamicParams = true; + export async function generateStaticParams(): Promise { return []; } diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/isr-routes.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/isr-routes.test.ts index 9c462a652c98..541cff9c064c 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16/tests/isr-routes.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16/tests/isr-routes.test.ts @@ -41,37 +41,34 @@ test('should remove meta tags for different ISR dynamic route values', async ({ await expect(page.locator('meta[name="baggage"]')).toHaveCount(0); }); -test('should create unique transactions for ISR pages (not using stale trace IDs)', async ({ page }) => { - // First navigation - capture the trace ID - const firstTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { - return transactionEvent.transaction === '/isr-test/:product' && transactionEvent.contexts?.trace?.op === 'pageload'; - }); - - await page.goto('/isr-test/laptop'); - const firstTransaction = await firstTransactionPromise; - const firstTraceId = firstTransaction.contexts?.trace?.trace_id; - - expect(firstTraceId).toBeDefined(); - expect(firstTraceId).toMatch(/[a-f0-9]{32}/); - - // Second navigation to the same ISR route with different param - const secondTransactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { - return !!( - transactionEvent.transaction === '/isr-test/:product' && - transactionEvent.contexts?.trace?.op === 'pageload' && - transactionEvent.request?.url?.includes('/isr-test/phone') - ); - }); - - await page.goto('/isr-test/phone'); - const secondTransaction = await secondTransactionPromise; - const secondTraceId = secondTransaction.contexts?.trace?.trace_id; - - expect(secondTraceId).toBeDefined(); - expect(secondTraceId).toMatch(/[a-f0-9]{32}/); - - // Verify that each page load gets a NEW trace ID (not reusing cached/stale ones) - expect(firstTraceId).not.toBe(secondTraceId); +test('should create unique transactions for ISR pages on each visit', async ({ page }) => { + const traceIds: string[] = []; + + // Load the same ISR page 5 times to ensure cached HTML meta tags are consistently removed + for (let i = 0; i < 5; i++) { + const transactionPromise = waitForTransaction('nextjs-16', async transactionEvent => { + return !!( + transactionEvent.transaction === '/isr-test/:product' && transactionEvent.contexts?.trace?.op === 'pageload' + ); + }); + + if (i === 0) { + await page.goto('/isr-test/laptop'); + } else { + await page.reload(); + } + + const transaction = await transactionPromise; + const traceId = transaction.contexts?.trace?.trace_id; + + expect(traceId).toBeDefined(); + expect(traceId).toMatch(/[a-f0-9]{32}/); + traceIds.push(traceId!); + } + + // Verify all 5 page loads have unique trace IDs (no reuse of cached/stale meta tags) + const uniqueTraceIds = new Set(traceIds); + expect(uniqueTraceIds.size).toBe(5); }); test('ISR route should be identified correctly in the route manifest', async ({ page }) => { From f46d8170a653ccdbd3441b3a08754beaae61fbe8 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 13 Nov 2025 12:32:57 +0200 Subject: [PATCH 09/14] fix: added explicit array check --- packages/nextjs/src/client/routing/isrRoutingTracing.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nextjs/src/client/routing/isrRoutingTracing.ts b/packages/nextjs/src/client/routing/isrRoutingTracing.ts index 1201e0e42b8d..4e8b6002806e 100644 --- a/packages/nextjs/src/client/routing/isrRoutingTracing.ts +++ b/packages/nextjs/src/client/routing/isrRoutingTracing.ts @@ -26,7 +26,7 @@ function isIsrSsgRoute(pathname: string): boolean { manifest = manifestData; } - if (!manifest.isrRoutes || manifest.isrRoutes.length === 0) { + if (!manifest.isrRoutes || !Array.isArray(manifest.isrRoutes) || manifest.isrRoutes.length === 0) { return false; } From c72b29ac57b6ce30e426c943c4ebf2f6ae617a90 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 17 Nov 2025 11:54:48 +0200 Subject: [PATCH 10/14] fix: route manifest is always a string --- .../src/client/routing/isrRoutingTracing.ts | 16 +- .../test/client/isrRoutingTracing.test.ts | 262 ++++++++++++++++++ 2 files changed, 268 insertions(+), 10 deletions(-) create mode 100644 packages/nextjs/test/client/isrRoutingTracing.test.ts diff --git a/packages/nextjs/src/client/routing/isrRoutingTracing.ts b/packages/nextjs/src/client/routing/isrRoutingTracing.ts index 4e8b6002806e..723bf8e073fe 100644 --- a/packages/nextjs/src/client/routing/isrRoutingTracing.ts +++ b/packages/nextjs/src/client/routing/isrRoutingTracing.ts @@ -3,7 +3,7 @@ import type { RouteManifest } from '../../config/manifest/types'; import { maybeParameterizeRoute } from './parameterization'; const globalWithInjectedValues = WINDOW as typeof WINDOW & { - _sentryRouteManifest: string | RouteManifest; + _sentryRouteManifest: string; }; /** @@ -11,19 +11,15 @@ const globalWithInjectedValues = WINDOW as typeof WINDOW & { */ function isIsrSsgRoute(pathname: string): boolean { const manifestData = globalWithInjectedValues._sentryRouteManifest; - if (!manifestData) { + if (!manifestData || typeof manifestData !== 'string') { return false; } let manifest: RouteManifest; - if (typeof manifestData === 'string') { - try { - manifest = JSON.parse(manifestData); - } catch { - return false; - } - } else { - manifest = manifestData; + try { + manifest = JSON.parse(manifestData); + } catch { + return false; } if (!manifest.isrRoutes || !Array.isArray(manifest.isrRoutes) || manifest.isrRoutes.length === 0) { diff --git a/packages/nextjs/test/client/isrRoutingTracing.test.ts b/packages/nextjs/test/client/isrRoutingTracing.test.ts new file mode 100644 index 000000000000..e5ef690b0c08 --- /dev/null +++ b/packages/nextjs/test/client/isrRoutingTracing.test.ts @@ -0,0 +1,262 @@ +import { WINDOW } from '@sentry/react'; +import { JSDOM } from 'jsdom'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { removeIsrSsgTraceMetaTags } from '../../src/client/routing/isrRoutingTracing'; +import type { RouteManifest } from '../../src/config/manifest/types'; + +const globalWithInjectedValues = WINDOW as typeof WINDOW & { + _sentryRouteManifest: string; +}; + +describe('isrRoutingTracing', () => { + let dom: JSDOM; + + beforeEach(() => { + // Set up a fresh DOM environment for each test + dom = new JSDOM('', { + url: 'https://example.com/', + }); + Object.defineProperty(global, 'document', { value: dom.window.document, writable: true }); + Object.defineProperty(global, 'location', { value: dom.window.location, writable: true }); + + // Clear the injected manifest + delete globalWithInjectedValues._sentryRouteManifest; + }); + + afterEach(() => { + // Clean up + vi.clearAllMocks(); + }); + + describe('removeIsrSsgTraceMetaTags', () => { + const mockManifest: RouteManifest = { + staticRoutes: [{ path: '/' }, { path: '/blog' }], + dynamicRoutes: [ + { + path: '/products/:id', + regex: '^/products/([^/]+?)(?:/)?$', + paramNames: ['id'], + hasOptionalPrefix: false, + }, + { + path: '/posts/:slug', + regex: '^/posts/([^/]+?)(?:/)?$', + paramNames: ['slug'], + hasOptionalPrefix: false, + }, + ], + isrRoutes: ['/', '/blog', '/products/:id', '/posts/:slug'], + }; + + it('should remove meta tags when on a static ISR route', () => { + // Set up DOM with meta tags + const sentryTraceMeta = dom.window.document.createElement('meta'); + sentryTraceMeta.setAttribute('name', 'sentry-trace'); + sentryTraceMeta.setAttribute('content', 'trace-id-12345'); + dom.window.document.head.appendChild(sentryTraceMeta); + + const baggageMeta = dom.window.document.createElement('meta'); + baggageMeta.setAttribute('name', 'baggage'); + baggageMeta.setAttribute('content', 'sentry-trace-id=12345'); + dom.window.document.head.appendChild(baggageMeta); + + // Set up route manifest (as stringified JSON, which is how it's injected in production) + globalWithInjectedValues._sentryRouteManifest = JSON.stringify(mockManifest); + + // Set location to an ISR route + Object.defineProperty(global, 'location', { + value: { ...dom.window.location, pathname: '/blog' }, + writable: true, + }); + + // Call the function + removeIsrSsgTraceMetaTags(); + + // Verify meta tags were removed + expect(dom.window.document.querySelector('meta[name="sentry-trace"]')).toBeNull(); + expect(dom.window.document.querySelector('meta[name="baggage"]')).toBeNull(); + }); + + it('should remove meta tags when on a dynamic ISR route', () => { + // Set up DOM with meta tags + const sentryTraceMeta = dom.window.document.createElement('meta'); + sentryTraceMeta.setAttribute('name', 'sentry-trace'); + sentryTraceMeta.setAttribute('content', 'trace-id-12345'); + dom.window.document.head.appendChild(sentryTraceMeta); + + const baggageMeta = dom.window.document.createElement('meta'); + baggageMeta.setAttribute('name', 'baggage'); + baggageMeta.setAttribute('content', 'sentry-trace-id=12345'); + dom.window.document.head.appendChild(baggageMeta); + + // Set up route manifest + globalWithInjectedValues._sentryRouteManifest = JSON.stringify(mockManifest); + + // Set location to a dynamic ISR route + Object.defineProperty(global, 'location', { + value: { ...dom.window.location, pathname: '/products/123' }, + writable: true, + }); + + // Call the function + removeIsrSsgTraceMetaTags(); + + // Verify meta tags were removed + expect(dom.window.document.querySelector('meta[name="sentry-trace"]')).toBeNull(); + expect(dom.window.document.querySelector('meta[name="baggage"]')).toBeNull(); + }); + + it('should NOT remove meta tags when on a non-ISR route', () => { + // Set up DOM with meta tags + const sentryTraceMeta = dom.window.document.createElement('meta'); + sentryTraceMeta.setAttribute('name', 'sentry-trace'); + sentryTraceMeta.setAttribute('content', 'trace-id-12345'); + dom.window.document.head.appendChild(sentryTraceMeta); + + const baggageMeta = dom.window.document.createElement('meta'); + baggageMeta.setAttribute('name', 'baggage'); + baggageMeta.setAttribute('content', 'sentry-trace-id=12345'); + dom.window.document.head.appendChild(baggageMeta); + + // Set up route manifest + globalWithInjectedValues._sentryRouteManifest = JSON.stringify(mockManifest); + + // Set location to a non-ISR route + Object.defineProperty(global, 'location', { + value: { ...dom.window.location, pathname: '/regular-page' }, + writable: true, + }); + + // Call the function + removeIsrSsgTraceMetaTags(); + + // Verify meta tags were NOT removed + expect(dom.window.document.querySelector('meta[name="sentry-trace"]')).not.toBeNull(); + expect(dom.window.document.querySelector('meta[name="baggage"]')).not.toBeNull(); + }); + + it('should handle missing manifest gracefully', () => { + // Set up DOM with meta tags + const sentryTraceMeta = dom.window.document.createElement('meta'); + sentryTraceMeta.setAttribute('name', 'sentry-trace'); + sentryTraceMeta.setAttribute('content', 'trace-id-12345'); + dom.window.document.head.appendChild(sentryTraceMeta); + + // No manifest set + // globalWithInjectedValues._sentryRouteManifest is undefined + + // Set location + Object.defineProperty(global, 'location', { + value: { ...dom.window.location, pathname: '/blog' }, + writable: true, + }); + + // Call the function (should not throw) + expect(() => removeIsrSsgTraceMetaTags()).not.toThrow(); + + // Verify meta tags were NOT removed (no manifest means no ISR detection) + expect(dom.window.document.querySelector('meta[name="sentry-trace"]')).not.toBeNull(); + }); + + it('should handle invalid JSON manifest gracefully', () => { + // Set up DOM with meta tags + const sentryTraceMeta = dom.window.document.createElement('meta'); + sentryTraceMeta.setAttribute('name', 'sentry-trace'); + sentryTraceMeta.setAttribute('content', 'trace-id-12345'); + dom.window.document.head.appendChild(sentryTraceMeta); + + // Set up invalid manifest + globalWithInjectedValues._sentryRouteManifest = 'invalid json {'; + + // Set location + Object.defineProperty(global, 'location', { + value: { ...dom.window.location, pathname: '/blog' }, + writable: true, + }); + + // Call the function (should not throw) + expect(() => removeIsrSsgTraceMetaTags()).not.toThrow(); + + // Verify meta tags were NOT removed (invalid manifest means no ISR detection) + expect(dom.window.document.querySelector('meta[name="sentry-trace"]')).not.toBeNull(); + }); + + it('should handle manifest with no ISR routes', () => { + // Set up DOM with meta tags + const sentryTraceMeta = dom.window.document.createElement('meta'); + sentryTraceMeta.setAttribute('name', 'sentry-trace'); + sentryTraceMeta.setAttribute('content', 'trace-id-12345'); + dom.window.document.head.appendChild(sentryTraceMeta); + + // Set up manifest with no ISR routes + const manifestWithNoISR: RouteManifest = { + staticRoutes: [{ path: '/' }], + dynamicRoutes: [], + isrRoutes: [], + }; + globalWithInjectedValues._sentryRouteManifest = JSON.stringify(manifestWithNoISR); + + // Set location + Object.defineProperty(global, 'location', { + value: { ...dom.window.location, pathname: '/' }, + writable: true, + }); + + // Call the function + removeIsrSsgTraceMetaTags(); + + // Verify meta tags were NOT removed (no ISR routes in manifest) + expect(dom.window.document.querySelector('meta[name="sentry-trace"]')).not.toBeNull(); + }); + + it('should handle missing meta tags gracefully', () => { + // Set up DOM without meta tags + + // Set up route manifest + globalWithInjectedValues._sentryRouteManifest = JSON.stringify(mockManifest); + + // Set location to an ISR route + Object.defineProperty(global, 'location', { + value: { ...dom.window.location, pathname: '/blog' }, + writable: true, + }); + + // Call the function (should not throw) + expect(() => removeIsrSsgTraceMetaTags()).not.toThrow(); + + // Verify no errors and still no meta tags + expect(dom.window.document.querySelector('meta[name="sentry-trace"]')).toBeNull(); + expect(dom.window.document.querySelector('meta[name="baggage"]')).toBeNull(); + }); + + it('should work with parameterized dynamic routes', () => { + // Set up DOM with meta tags + const sentryTraceMeta = dom.window.document.createElement('meta'); + sentryTraceMeta.setAttribute('name', 'sentry-trace'); + sentryTraceMeta.setAttribute('content', 'trace-id-12345'); + dom.window.document.head.appendChild(sentryTraceMeta); + + const baggageMeta = dom.window.document.createElement('meta'); + baggageMeta.setAttribute('name', 'baggage'); + baggageMeta.setAttribute('content', 'sentry-trace-id=12345'); + dom.window.document.head.appendChild(baggageMeta); + + // Set up route manifest + globalWithInjectedValues._sentryRouteManifest = JSON.stringify(mockManifest); + + // Set location to a different dynamic ISR route value + Object.defineProperty(global, 'location', { + value: { ...dom.window.location, pathname: '/posts/my-awesome-post' }, + writable: true, + }); + + // Call the function + removeIsrSsgTraceMetaTags(); + + // Verify meta tags were removed (should match /posts/:slug) + expect(dom.window.document.querySelector('meta[name="sentry-trace"]')).toBeNull(); + expect(dom.window.document.querySelector('meta[name="baggage"]')).toBeNull(); + }); + }); +}); + From ed76ec80c5a1fdc4d467fe946ae345324c3ec9fa Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 17 Nov 2025 12:30:28 +0200 Subject: [PATCH 11/14] fix: re-use types and lint issue --- packages/nextjs/src/config/manifest/createRouteManifest.ts | 6 +----- packages/nextjs/test/client/isrRoutingTracing.test.ts | 1 - 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/nextjs/src/config/manifest/createRouteManifest.ts b/packages/nextjs/src/config/manifest/createRouteManifest.ts index d5a727959ae8..d37285983d31 100644 --- a/packages/nextjs/src/config/manifest/createRouteManifest.ts +++ b/packages/nextjs/src/config/manifest/createRouteManifest.ts @@ -129,11 +129,7 @@ function checkForGenerateStaticParams(pageFilePath: string): boolean { } } -function scanAppDirectory( - dir: string, - basePath: string = '', - includeRouteGroups: boolean = false, -): { dynamicRoutes: RouteInfo[]; staticRoutes: RouteInfo[]; isrRoutes: string[] } { +function scanAppDirectory(dir: string, basePath: string = '', includeRouteGroups: boolean = false): RouteManifest { const dynamicRoutes: RouteInfo[] = []; const staticRoutes: RouteInfo[] = []; const isrRoutes: string[] = []; diff --git a/packages/nextjs/test/client/isrRoutingTracing.test.ts b/packages/nextjs/test/client/isrRoutingTracing.test.ts index e5ef690b0c08..ea321283fee0 100644 --- a/packages/nextjs/test/client/isrRoutingTracing.test.ts +++ b/packages/nextjs/test/client/isrRoutingTracing.test.ts @@ -259,4 +259,3 @@ describe('isrRoutingTracing', () => { }); }); }); - From ec730219eb42af5a8c75653cf78707beedb77068 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 17 Nov 2025 18:24:37 +0200 Subject: [PATCH 12/14] fix: gate the meta removal with __SENTRY_TRACING__ --- packages/nextjs/src/client/index.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/nextjs/src/client/index.ts b/packages/nextjs/src/client/index.ts index a9e33cedbb1b..a171652b7221 100644 --- a/packages/nextjs/src/client/index.ts +++ b/packages/nextjs/src/client/index.ts @@ -44,7 +44,9 @@ export function init(options: BrowserOptions): Client | undefined { // Remove cached trace meta tags for ISR/SSG pages before initializing // This prevents the browser tracing integration from using stale trace IDs - removeIsrSsgTraceMetaTags(); + if (typeof __SENTRY_TRACING__ === 'undefined' || __SENTRY_TRACING__) { + removeIsrSsgTraceMetaTags(); + } const opts = { environment: getVercelEnv(true) || process.env.NODE_ENV, From a49edb2055637f5f8b3bcdc4eb067228f783a9e7 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 17 Nov 2025 18:33:48 +0200 Subject: [PATCH 13/14] feat(perf): re-use the existing parsed route manifest --- .../src/client/routing/isrRoutingTracing.ts | 33 +++++++++---------- .../src/client/routing/parameterization.ts | 2 +- 2 files changed, 16 insertions(+), 19 deletions(-) diff --git a/packages/nextjs/src/client/routing/isrRoutingTracing.ts b/packages/nextjs/src/client/routing/isrRoutingTracing.ts index 723bf8e073fe..5a4a4cb1ce83 100644 --- a/packages/nextjs/src/client/routing/isrRoutingTracing.ts +++ b/packages/nextjs/src/client/routing/isrRoutingTracing.ts @@ -1,35 +1,32 @@ import { WINDOW } from '@sentry/react'; -import type { RouteManifest } from '../../config/manifest/types'; -import { maybeParameterizeRoute } from './parameterization'; +import { getManifest, maybeParameterizeRoute } from './parameterization'; -const globalWithInjectedValues = WINDOW as typeof WINDOW & { - _sentryRouteManifest: string; -}; +const IS_ISR_SSG_ROUTE_CACHE = new Map(); /** * Check if the current page is an ISR/SSG route by checking the route manifest. */ function isIsrSsgRoute(pathname: string): boolean { - const manifestData = globalWithInjectedValues._sentryRouteManifest; - if (!manifestData || typeof manifestData !== 'string') { - return false; - } + // Early parameterization to get the cache key + const parameterizedPath = maybeParameterizeRoute(pathname); + const pathToCheck = parameterizedPath || pathname; - let manifest: RouteManifest; - try { - manifest = JSON.parse(manifestData); - } catch { - return false; + // Check cache using the parameterized path as the key + if (IS_ISR_SSG_ROUTE_CACHE.has(pathToCheck)) { + return IS_ISR_SSG_ROUTE_CACHE.get(pathToCheck) as boolean; } - if (!manifest.isrRoutes || !Array.isArray(manifest.isrRoutes) || manifest.isrRoutes.length === 0) { + // Cache miss get the manifest + const manifest = getManifest(); + if (!manifest?.isrRoutes || !Array.isArray(manifest.isrRoutes) || manifest.isrRoutes.length === 0) { + IS_ISR_SSG_ROUTE_CACHE.set(pathToCheck, false); return false; } - const parameterizedPath = maybeParameterizeRoute(pathname); - const pathToCheck = parameterizedPath || pathname; + const isIsrSsgRoute = manifest.isrRoutes.includes(pathToCheck); + IS_ISR_SSG_ROUTE_CACHE.set(pathToCheck, isIsrSsgRoute); - return manifest.isrRoutes.includes(pathToCheck); + return isIsrSsgRoute; } /** diff --git a/packages/nextjs/src/client/routing/parameterization.ts b/packages/nextjs/src/client/routing/parameterization.ts index 1322b3d1b309..1bf6c22d5fe0 100644 --- a/packages/nextjs/src/client/routing/parameterization.ts +++ b/packages/nextjs/src/client/routing/parameterization.ts @@ -74,7 +74,7 @@ function getCompiledRegex(regexString: string): RegExp | 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 { +export function getManifest(): RouteManifest | null { if ( !globalWithInjectedManifest?._sentryRouteManifest || typeof globalWithInjectedManifest._sentryRouteManifest !== 'string' From 0854965e2f900d6872598ebc8d8107edd463ce31 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 17 Nov 2025 18:57:05 +0200 Subject: [PATCH 14/14] tests: added cache unit tests --- .../src/client/routing/isrRoutingTracing.ts | 9 +- .../test/client/isrRoutingTracing.test.ts | 141 +++++++++++++++++- 2 files changed, 146 insertions(+), 4 deletions(-) diff --git a/packages/nextjs/src/client/routing/isrRoutingTracing.ts b/packages/nextjs/src/client/routing/isrRoutingTracing.ts index 5a4a4cb1ce83..567d30aa9852 100644 --- a/packages/nextjs/src/client/routing/isrRoutingTracing.ts +++ b/packages/nextjs/src/client/routing/isrRoutingTracing.ts @@ -1,12 +1,17 @@ import { WINDOW } from '@sentry/react'; import { getManifest, maybeParameterizeRoute } from './parameterization'; -const IS_ISR_SSG_ROUTE_CACHE = new Map(); +/** + * Cache for ISR/SSG route checks. Exported for testing purposes. + * @internal + */ +export const IS_ISR_SSG_ROUTE_CACHE = new Map(); /** * Check if the current page is an ISR/SSG route by checking the route manifest. + * @internal Exported for testing purposes. */ -function isIsrSsgRoute(pathname: string): boolean { +export function isIsrSsgRoute(pathname: string): boolean { // Early parameterization to get the cache key const parameterizedPath = maybeParameterizeRoute(pathname); const pathToCheck = parameterizedPath || pathname; diff --git a/packages/nextjs/test/client/isrRoutingTracing.test.ts b/packages/nextjs/test/client/isrRoutingTracing.test.ts index ea321283fee0..be553e086449 100644 --- a/packages/nextjs/test/client/isrRoutingTracing.test.ts +++ b/packages/nextjs/test/client/isrRoutingTracing.test.ts @@ -1,11 +1,15 @@ import { WINDOW } from '@sentry/react'; import { JSDOM } from 'jsdom'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { removeIsrSsgTraceMetaTags } from '../../src/client/routing/isrRoutingTracing'; +import { + IS_ISR_SSG_ROUTE_CACHE, + isIsrSsgRoute, + removeIsrSsgTraceMetaTags, +} from '../../src/client/routing/isrRoutingTracing'; import type { RouteManifest } from '../../src/config/manifest/types'; const globalWithInjectedValues = WINDOW as typeof WINDOW & { - _sentryRouteManifest: string; + _sentryRouteManifest?: string; }; describe('isrRoutingTracing', () => { @@ -136,6 +140,9 @@ describe('isrRoutingTracing', () => { }); it('should handle missing manifest gracefully', () => { + // Clear cache to ensure fresh state + IS_ISR_SSG_ROUTE_CACHE.clear(); + // Set up DOM with meta tags const sentryTraceMeta = dom.window.document.createElement('meta'); sentryTraceMeta.setAttribute('name', 'sentry-trace'); @@ -159,6 +166,9 @@ describe('isrRoutingTracing', () => { }); it('should handle invalid JSON manifest gracefully', () => { + // Clear cache to ensure fresh state + IS_ISR_SSG_ROUTE_CACHE.clear(); + // Set up DOM with meta tags const sentryTraceMeta = dom.window.document.createElement('meta'); sentryTraceMeta.setAttribute('name', 'sentry-trace'); @@ -258,4 +268,131 @@ describe('isrRoutingTracing', () => { expect(dom.window.document.querySelector('meta[name="baggage"]')).toBeNull(); }); }); + + describe('isIsrSsgRoute caching', () => { + const mockManifest: RouteManifest = { + staticRoutes: [{ path: '/' }, { path: '/blog' }], + dynamicRoutes: [ + { + path: '/products/:id', + regex: '^/products/([^/]+?)(?:/)?$', + paramNames: ['id'], + hasOptionalPrefix: false, + }, + { + path: '/posts/:slug', + regex: '^/posts/([^/]+?)(?:/)?$', + paramNames: ['slug'], + hasOptionalPrefix: false, + }, + ], + isrRoutes: ['/', '/blog', '/products/:id', '/posts/:slug'], + }; + + beforeEach(() => { + // Clear cache before each test + IS_ISR_SSG_ROUTE_CACHE.clear(); + // Set up route manifest + globalWithInjectedValues._sentryRouteManifest = JSON.stringify(mockManifest); + }); + + it('should cache results by parameterized route, not concrete pathname', () => { + // First call with /products/123 + const result1 = isIsrSsgRoute('/products/123'); + expect(result1).toBe(true); + expect(IS_ISR_SSG_ROUTE_CACHE.size).toBe(1); + expect(IS_ISR_SSG_ROUTE_CACHE.has('/products/:id')).toBe(true); + + // Second call with different concrete path /products/456 + const result2 = isIsrSsgRoute('/products/456'); + expect(result2).toBe(true); + // Cache size should still be 1 - both paths map to same parameterized route + expect(IS_ISR_SSG_ROUTE_CACHE.size).toBe(1); + expect(IS_ISR_SSG_ROUTE_CACHE.has('/products/:id')).toBe(true); + + // Third call with yet another path /products/999 + const result3 = isIsrSsgRoute('/products/999'); + expect(result3).toBe(true); + // Still just 1 cache entry + expect(IS_ISR_SSG_ROUTE_CACHE.size).toBe(1); + }); + + it('should use cached results on subsequent calls with same route pattern', () => { + // Clear cache + IS_ISR_SSG_ROUTE_CACHE.clear(); + + // First call - cache miss, will populate cache + isIsrSsgRoute('/products/1'); + expect(IS_ISR_SSG_ROUTE_CACHE.size).toBe(1); + expect(IS_ISR_SSG_ROUTE_CACHE.has('/products/:id')).toBe(true); + + // Second call with different concrete path - cache hit + const result2 = isIsrSsgRoute('/products/2'); + expect(result2).toBe(true); + // Cache size unchanged - using cached result + expect(IS_ISR_SSG_ROUTE_CACHE.size).toBe(1); + + // Third call - still cache hit + const result3 = isIsrSsgRoute('/products/3'); + expect(result3).toBe(true); + expect(IS_ISR_SSG_ROUTE_CACHE.size).toBe(1); + }); + + it('should cache false results for non-ISR routes', () => { + const result1 = isIsrSsgRoute('/not-an-isr-route'); + expect(result1).toBe(false); + expect(IS_ISR_SSG_ROUTE_CACHE.has('/not-an-isr-route')).toBe(true); + expect(IS_ISR_SSG_ROUTE_CACHE.get('/not-an-isr-route')).toBe(false); + + // Second call should use cache + const result2 = isIsrSsgRoute('/not-an-isr-route'); + expect(result2).toBe(false); + }); + + it('should cache false results when manifest is invalid', () => { + IS_ISR_SSG_ROUTE_CACHE.clear(); + globalWithInjectedValues._sentryRouteManifest = 'invalid json'; + + const result = isIsrSsgRoute('/any-route'); + expect(result).toBe(false); + expect(IS_ISR_SSG_ROUTE_CACHE.has('/any-route')).toBe(true); + expect(IS_ISR_SSG_ROUTE_CACHE.get('/any-route')).toBe(false); + }); + + it('should cache static routes without parameterization', () => { + const result1 = isIsrSsgRoute('/blog'); + expect(result1).toBe(true); + expect(IS_ISR_SSG_ROUTE_CACHE.has('/blog')).toBe(true); + + // Second call should use cache + const result2 = isIsrSsgRoute('/blog'); + expect(result2).toBe(true); + }); + + it('should maintain separate cache entries for different route patterns', () => { + // Check multiple different routes + isIsrSsgRoute('/products/1'); + isIsrSsgRoute('/posts/hello'); + isIsrSsgRoute('/blog'); + isIsrSsgRoute('/'); + + // Should have 4 cache entries (one for each unique route pattern) + expect(IS_ISR_SSG_ROUTE_CACHE.size).toBe(4); + expect(IS_ISR_SSG_ROUTE_CACHE.has('/products/:id')).toBe(true); + expect(IS_ISR_SSG_ROUTE_CACHE.has('/posts/:slug')).toBe(true); + expect(IS_ISR_SSG_ROUTE_CACHE.has('/blog')).toBe(true); + expect(IS_ISR_SSG_ROUTE_CACHE.has('/')).toBe(true); + }); + + it('should efficiently handle multiple calls to same dynamic route with different params', () => { + // Simulate real-world scenario with many different product IDs + for (let i = 1; i <= 100; i++) { + isIsrSsgRoute(`/products/${i}`); + } + + // Should only have 1 cache entry despite 100 calls + expect(IS_ISR_SSG_ROUTE_CACHE.size).toBe(1); + expect(IS_ISR_SSG_ROUTE_CACHE.has('/products/:id')).toBe(true); + }); + }); });