From a8399591f3f40a6846c06bc32a9ab1669c45e5c4 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Wed, 30 Sep 2026 08:33:03 +0200 Subject: [PATCH 1/5] fix(react-router): Resolve the Cloudflare entry in Workers and skip trace meta tags in prerendered pages In a Worker build, the Cloudflare Vite plugin resolves with the `browser` condition set, and the exports map lists `browser` first, so `@sentry/react-router` resolved to the client build. `Sentry.startSpan()` and `Sentry.flush()` then compiled to `undefined` in loaders and middleware. The Cloudflare entry is now resolved under the `workerd` condition. On Cloudflare, `react-router build` prerenders pages through the wrapped Worker, so `injectTraceMetaTags` wrote one build-time `sentry-trace` into the static HTML and every visitor continued that trace. The server build capture now also records the `prerender` list, `wrapSentryHandleRequest` registers the capture (on Cloudflare nothing did), and paths from that list get no meta tags. Co-Authored-By: Claude Opus 5.5 --- packages/react-router/package.json | 5 +++ packages/react-router/src/cloudflare/index.ts | 5 +++ .../react-router/src/server/serverBuild.ts | 31 ++++++++++++++-- .../src/server/wrapSentryHandleRequest.ts | 6 ++++ .../src/vite/makeServerBuildCapturePlugin.ts | 5 +-- .../test/server/serverBuild.test.ts | 35 ++++++++++++++++++- .../vite/makeServerBuildCapturePlugin.test.ts | 2 +- 7 files changed, 83 insertions(+), 6 deletions(-) diff --git a/packages/react-router/package.json b/packages/react-router/package.json index 92802dc6477c..59d2ebf5b2e7 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -20,6 +20,11 @@ "./package.json": "./package.json", ".": { "types": "./build/types/index.types.d.ts", + "workerd": { + "import": "./build/esm/cloudflare/index.js", + "require": "./build/cjs/cloudflare/index.js", + "default": "./build/esm/cloudflare/index.js" + }, "browser": { "import": "./build/esm/index.client.js", "require": "./build/cjs/index.client.js" diff --git a/packages/react-router/src/cloudflare/index.ts b/packages/react-router/src/cloudflare/index.ts index e1e349306043..67db950b59fb 100644 --- a/packages/react-router/src/cloudflare/index.ts +++ b/packages/react-router/src/cloudflare/index.ts @@ -1,4 +1,5 @@ import { getTraceMetaTags } from '@sentry/core'; +import { isPrerenderRequest } from '../server/serverBuild'; export * from '../client'; @@ -11,6 +12,10 @@ export { wrapSentryHandleRequest } from '../server/wrapSentryHandleRequest'; * @returns A new ReadableStream with Sentry trace meta tags injected into the head section */ export function injectTraceMetaTags(body: ReadableStream): ReadableStream { + if (isPrerenderRequest()) { + return body; + } + const headClosingTag = ''; const reader = body.getReader(); diff --git a/packages/react-router/src/server/serverBuild.ts b/packages/react-router/src/server/serverBuild.ts index 3b46c745b4b7..1dd606f3ed1c 100644 --- a/packages/react-router/src/server/serverBuild.ts +++ b/packages/react-router/src/server/serverBuild.ts @@ -1,7 +1,7 @@ -import { GLOBAL_OBJ } from '@sentry/core'; +import { getIsolationScope, GLOBAL_OBJ, parseStringToURLObject } from '@sentry/core'; /** - * Subset of ServerBuild shape for middleware name lookup. + * Subset of ServerBuild shape for middleware name lookup and prerender detection. * The official React Router types don't expose `middleware` on route modules yet. * @internal */ @@ -14,6 +14,8 @@ interface ServerBuildLike { }; } >; + /** The paths React Router prerenders to static HTML at build time. */ + prerender?: string[]; } /** @internal */ @@ -53,6 +55,31 @@ export function getMiddlewareName(routeId: string, index: number): string | unde return middlewareFn?.name || undefined; } +function withoutTrailingSlash(path: string): string { + return path.length > 1 && path.endsWith('/') ? path.slice(0, -1) : path; +} + +/** + * Whether the current request renders a path from the build's `prerender` list. + * + * In production these paths are served as static files and never reach the request handler, so a + * render of one is the build-time prerender. Trace meta tags must not be written into that HTML: + * every visitor of the static page would continue the same trace. + * + * @internal + */ +export function isPrerenderRequest(): boolean { + const prerender = _serverBuild?.prerender; + if (!prerender?.length) return false; + + const url = getIsolationScope().getScopeData().sdkProcessingMetadata.normalizedRequest?.url; + const pathname = url ? parseStringToURLObject(url)?.pathname : undefined; + if (!pathname) return false; + + const requestPath = withoutTrailingSlash(pathname); + return prerender.some(path => withoutTrailingSlash(path) === requestPath); +} + /** @internal */ export function registerServerBuildGlobal(): void { (GLOBAL_OBJ as GlobalObjWithBuildCapture)[GLOBAL_KEY] = setServerBuild; diff --git a/packages/react-router/src/server/wrapSentryHandleRequest.ts b/packages/react-router/src/server/wrapSentryHandleRequest.ts index 8a4af55ce62e..168c67254489 100644 --- a/packages/react-router/src/server/wrapSentryHandleRequest.ts +++ b/packages/react-router/src/server/wrapSentryHandleRequest.ts @@ -8,6 +8,7 @@ import { } from '@sentry/core'; import { flushIfServerless } from '@sentry/core/server'; import type { AppLoadContext, EntryContext, RouterContextProvider } from 'react-router'; +import { registerServerBuildGlobal } from './serverBuild'; import { isInstrumentationApiUsed } from './serverGlobals'; type OriginalHandleRequestWithoutMiddleware = ( @@ -53,6 +54,11 @@ export function wrapSentryHandleRequest( export function wrapSentryHandleRequest( originalHandle: OriginalHandleRequestWithoutMiddleware | OriginalHandleRequestWithMiddleware, ): OriginalHandleRequestWithoutMiddleware | OriginalHandleRequestWithMiddleware { + // `entry.server` is evaluated before the server build module, so the build's capture call at the + // end of that module finds this. Runtimes without the Node server integration (Cloudflare) only + // register it here. + registerServerBuildGlobal(); + return async function sentryInstrumentedHandleRequest( request: Request, responseStatusCode: number, diff --git a/packages/react-router/src/vite/makeServerBuildCapturePlugin.ts b/packages/react-router/src/vite/makeServerBuildCapturePlugin.ts index b22fc715d782..8811dd13780a 100644 --- a/packages/react-router/src/vite/makeServerBuildCapturePlugin.ts +++ b/packages/react-router/src/vite/makeServerBuildCapturePlugin.ts @@ -23,10 +23,11 @@ export function makeServerBuildCapturePlugin(): Plugin { return null; } - // `routes` is a module-scope export in the virtual:react-router/server-build module + // `routes` and `prerender` are module-scope exports in the virtual:react-router/server-build + // module. `prerender` is guarded with `typeof` because older React Router versions don't declare it. const injectedCode = `${code} if (typeof globalThis !== 'undefined' && typeof globalThis["${GLOBAL_KEY}"] === 'function') { - globalThis["${GLOBAL_KEY}"]({ routes }); + globalThis["${GLOBAL_KEY}"]({ routes, prerender: typeof prerender !== 'undefined' ? prerender : undefined }); } `; diff --git a/packages/react-router/test/server/serverBuild.test.ts b/packages/react-router/test/server/serverBuild.test.ts index 80eb7e2028b7..d18f9d40f799 100644 --- a/packages/react-router/test/server/serverBuild.test.ts +++ b/packages/react-router/test/server/serverBuild.test.ts @@ -1,8 +1,9 @@ -import { GLOBAL_OBJ } from '@sentry/core'; +import { getIsolationScope, GLOBAL_OBJ } from '@sentry/core'; import { afterEach, describe, expect, it } from 'vitest'; import { _resetServerBuild, getMiddlewareName, + isPrerenderRequest, isServerBuildLike, registerServerBuildGlobal, setServerBuild, @@ -61,6 +62,38 @@ describe('serverBuild', () => { }); }); + describe('isPrerenderRequest', () => { + afterEach(() => { + getIsolationScope().setSDKProcessingMetadata({ normalizedRequest: undefined }); + }); + + it('is true when the current request path is in the prerender list, with or without a trailing slash', () => { + setServerBuild({ routes: {}, prerender: ['/performance/static', '/about/'] }); + + getIsolationScope().setSDKProcessingMetadata({ + normalizedRequest: { url: 'http://localhost/performance/static/' }, + }); + expect(isPrerenderRequest()).toBe(true); + + getIsolationScope().setSDKProcessingMetadata({ normalizedRequest: { url: 'http://localhost/about' } }); + expect(isPrerenderRequest()).toBe(true); + }); + + it('is false for other paths, without a prerender list, or without a request', () => { + setServerBuild({ routes: {}, prerender: ['/performance/static'] }); + expect(isPrerenderRequest()).toBe(false); + + getIsolationScope().setSDKProcessingMetadata({ normalizedRequest: { url: 'http://localhost/performance' } }); + expect(isPrerenderRequest()).toBe(false); + + setServerBuild({ routes: {} }); + getIsolationScope().setSDKProcessingMetadata({ + normalizedRequest: { url: 'http://localhost/performance/static' }, + }); + expect(isPrerenderRequest()).toBe(false); + }); + }); + describe('registerServerBuildGlobal', () => { it('should register a global callback that calls setServerBuild', () => { registerServerBuildGlobal(); diff --git a/packages/react-router/test/vite/makeServerBuildCapturePlugin.test.ts b/packages/react-router/test/vite/makeServerBuildCapturePlugin.test.ts index ab77da695dc0..f1adc360477f 100644 --- a/packages/react-router/test/vite/makeServerBuildCapturePlugin.test.ts +++ b/packages/react-router/test/vite/makeServerBuildCapturePlugin.test.ts @@ -45,7 +45,7 @@ describe('makeServerBuildCapturePlugin', () => { expect(result).not.toBeNull(); expect(result.code).toContain(SERVER_BUILD_CODE); expect(result.code).toContain('__sentrySetServerBuild'); - expect(result.code).toContain('({ routes })'); + expect(result.code).toContain("({ routes, prerender: typeof prerender !== 'undefined' ? prerender : undefined })"); expect(result.map).toBeNull(); }); }); From 6b6b233665cb25fc5d05a251e621bbd9def70b64 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Wed, 30 Sep 2026 10:41:57 +0200 Subject: [PATCH 2/5] fixup! fix(react-router): Resolve the Cloudflare entry in Workers and skip trace meta tags in prerendered pages Co-Authored-By: Claude Opus 5.5 --- packages/react-router/package.json | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/react-router/package.json b/packages/react-router/package.json index 59d2ebf5b2e7..73aba2ba3c20 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -20,7 +20,7 @@ "./package.json": "./package.json", ".": { "types": "./build/types/index.types.d.ts", - "workerd": { + "worker": { "import": "./build/esm/cloudflare/index.js", "require": "./build/cjs/cloudflare/index.js", "default": "./build/esm/cloudflare/index.js" @@ -32,11 +32,6 @@ "node": { "import": "./build/esm/index.server.js", "require": "./build/cjs/index.server.js" - }, - "worker": { - "import": "./build/esm/cloudflare/index.js", - "require": "./build/cjs/cloudflare/index.js", - "default": "./build/esm/cloudflare/index.js" } }, "./cloudflare": { From f8e98c2c14724421cd098683992961b453db0298 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Wed, 30 Sep 2026 15:49:59 +0200 Subject: [PATCH 3/5] fixup! fix(react-router): Resolve the Cloudflare entry in Workers and skip trace meta tags in prerendered pages Co-Authored-By: Claude Opus 5.5 --- .../src/server/wrapSentryHandleRequest.ts | 7 +++- .../server/wrapSentryHandleRequest.test.ts | 39 +++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/packages/react-router/src/server/wrapSentryHandleRequest.ts b/packages/react-router/src/server/wrapSentryHandleRequest.ts index 168c67254489..f40d56cad735 100644 --- a/packages/react-router/src/server/wrapSentryHandleRequest.ts +++ b/packages/react-router/src/server/wrapSentryHandleRequest.ts @@ -66,8 +66,11 @@ export function wrapSentryHandleRequest( routerContext: EntryContext, loadContext: AppLoadContext | RouterContextProvider, ) { - const parameterizedPath = - routerContext?.staticHandlerContext?.matches?.[routerContext.staticHandlerContext.matches.length - 1]?.route.path; + const matches = routerContext?.staticHandlerContext?.matches; + // An index route has no `path` of its own and renders at its nearest ancestor's path, or at `/`. + const parameterizedPath = matches?.length + ? ([...matches].reverse().find(match => match.route.path)?.route.path ?? '/') + : undefined; const activeSpan = getActiveSpan(); const rootSpan = activeSpan ? getRootSpan(activeSpan) : undefined; diff --git a/packages/react-router/test/server/wrapSentryHandleRequest.test.ts b/packages/react-router/test/server/wrapSentryHandleRequest.test.ts index c48bedef8181..6fa1f2ef1026 100644 --- a/packages/react-router/test/server/wrapSentryHandleRequest.test.ts +++ b/packages/react-router/test/server/wrapSentryHandleRequest.test.ts @@ -74,6 +74,45 @@ describe('wrapSentryHandleRequest', () => { }); }); + test('should name an index route after its nearest ancestor path, or `/`', async () => { + const mockRootSpan = { setAttributes: vi.fn() }; + + (getActiveSpan as unknown as ReturnType).mockReturnValue({}); + (getRootSpan as unknown as ReturnType).mockReturnValue(mockRootSpan); + + const wrappedHandler = wrapSentryHandleRequest(vi.fn().mockResolvedValue('test')); + + const rootIndexContext = { + staticHandlerContext: { matches: [{ route: { path: '' } }, { route: { index: true } }] }, + } as any; + await wrappedHandler(new Request('https://nacho.queso/'), 200, new Headers(), rootIndexContext, {} as any); + + expect(mockRootSpan.setAttributes).toHaveBeenLastCalledWith({ + [HTTP_ROUTE]: '/', + [SENTRY_SEGMENT_NAME_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react_router.request_handler', + }); + + const nestedIndexContext = { + staticHandlerContext: { + matches: [{ route: { path: '' } }, { route: { path: 'dashboard' } }, { route: { index: true } }], + }, + } as any; + await wrappedHandler( + new Request('https://nacho.queso/dashboard'), + 200, + new Headers(), + nestedIndexContext, + {} as any, + ); + + expect(mockRootSpan.setAttributes).toHaveBeenLastCalledWith({ + [HTTP_ROUTE]: '/dashboard', + [SENTRY_SEGMENT_NAME_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.react_router.request_handler', + }); + }); + test('should not set span attributes when parameterized path does not exist', async () => { const mockActiveSpan = {}; const mockRootSpan = { setAttributes: vi.fn() }; From 23bec7b1e9add75a4740c95984ed08217bb76688 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Thu, 1 Oct 2026 14:19:53 +0200 Subject: [PATCH 4/5] fixup! fix(react-router): Resolve the Cloudflare entry in Workers and skip trace meta tags in prerendered pages Co-Authored-By: Claude Opus 5.5 --- .../tests/performance/trace-propagation.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/trace-propagation.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/trace-propagation.test.ts index b62e784ff3e3..bcdb5e2f34fa 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/trace-propagation.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-static/tests/performance/trace-propagation.test.ts @@ -19,7 +19,7 @@ test.describe('Trace propagation', () => { test('should have trace connection', async ({ page }) => { const serverTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { - return transactionEvent.transaction === 'GET *'; + return transactionEvent.transaction === 'GET /'; }); const clientTxPromise = waitForTransaction(APP_NAME, async transactionEvent => { From ba877af562bd3f1c25fc0d9075e027acb5258bce Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Thu, 1 Oct 2026 17:16:55 +0200 Subject: [PATCH 5/5] fixup! fix(react-router): Resolve the Cloudflare entry in Workers and skip trace meta tags in prerendered pages Co-Authored-By: Claude Opus 5.5 --- packages/react-router/package.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/react-router/package.json b/packages/react-router/package.json index 73aba2ba3c20..59d2ebf5b2e7 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -20,7 +20,7 @@ "./package.json": "./package.json", ".": { "types": "./build/types/index.types.d.ts", - "worker": { + "workerd": { "import": "./build/esm/cloudflare/index.js", "require": "./build/cjs/cloudflare/index.js", "default": "./build/esm/cloudflare/index.js" @@ -32,6 +32,11 @@ "node": { "import": "./build/esm/index.server.js", "require": "./build/cjs/index.server.js" + }, + "worker": { + "import": "./build/esm/cloudflare/index.js", + "require": "./build/cjs/cloudflare/index.js", + "default": "./build/esm/cloudflare/index.js" } }, "./cloudflare": {