From 9df44337783a36b97dc92d971900601c3d18d21e Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Mon, 3 Aug 2026 13:10:54 +0200 Subject: [PATCH 1/8] handle rr sourcemaps options --- .../src/vite/buildEnd/handleOnBuildEnd.ts | 19 +- .../src/vite/makeCustomSentryVitePlugins.ts | 32 ++- .../vite/buildEnd/handleOnBuildEnd.test.ts | 88 ++++++++ .../vite/makeCustomSentryVitePlugins.test.ts | 201 +++++++++++++++++- 4 files changed, 329 insertions(+), 11 deletions(-) diff --git a/packages/react-router/src/vite/buildEnd/handleOnBuildEnd.ts b/packages/react-router/src/vite/buildEnd/handleOnBuildEnd.ts index 2cdd2c5cd09b..6049661649cb 100644 --- a/packages/react-router/src/vite/buildEnd/handleOnBuildEnd.ts +++ b/packages/react-router/src/vite/buildEnd/handleOnBuildEnd.ts @@ -16,6 +16,22 @@ function getSentryConfig(viteConfig: unknown): SentryReactRouterBuildOptions { return (viteConfig as { sentryConfig: SentryReactRouterBuildOptions }).sentryConfig; } +/** + * This hook is the only place that injects debug IDs and uploads source maps for React + * Router, so `disable` has to be honoured wherever the user set it. Reading it from the + * top-level config only would silently ignore `unstable_sentryVitePluginOptions`. + */ +function isSourceMapUploadDisabled( + sentryConfig: SentryReactRouterBuildOptions, +): boolean | 'disable-upload' | undefined { + // eslint-disable-next-line typescript/no-deprecated + if (sentryConfig.sourceMapsUploadOptions?.enabled === false) { + return true; + } + + return sentryConfig.sourcemaps?.disable ?? sentryConfig.unstable_sentryVitePluginOptions?.sourcemaps?.disable; +} + /** * A build end hook that handles Sentry release creation and source map uploads. * It creates a new Sentry release if configured, uploads source maps to Sentry, @@ -48,8 +64,7 @@ export const sentryOnBuildEnd: BuildEndHook = async ({ reactRouterConfig, viteCo ...unstableSentryVitePluginOptions?.sourcemaps, ...sentryConfig.sourcemaps, ...sourceMapsUploadOptions, - // eslint-disable-next-line typescript/no-deprecated - disable: sourceMapsUploadOptions?.enabled === false ? true : sentryConfig.sourcemaps?.disable, + disable: isSourceMapUploadDisabled(sentryConfig), }, release: { ...unstableSentryVitePluginOptions?.release, diff --git a/packages/react-router/src/vite/makeCustomSentryVitePlugins.ts b/packages/react-router/src/vite/makeCustomSentryVitePlugins.ts index b2d41378db33..4c3f631fa091 100644 --- a/packages/react-router/src/vite/makeCustomSentryVitePlugins.ts +++ b/packages/react-router/src/vite/makeCustomSentryVitePlugins.ts @@ -19,6 +19,19 @@ export async function makeCustomSentryVitePlugins(options: SentryReactRouterBuil release, } = options; + const unstableSourcemapsDisable = unstable_sentryVitePluginOptions?.sourcemaps?.disable; + + // Any value other than `true` asks the Vite plugin to inject debug IDs, which the + // `sentryOnBuildEnd` hook already does - so it is ignored rather than honoured. + if (unstableSourcemapsDisable !== undefined && unstableSourcemapsDisable !== true) { + // eslint-disable-next-line no-console + console.warn( + `[Sentry] Ignoring \`unstable_sentryVitePluginOptions.sourcemaps.disable: ${JSON.stringify( + unstableSourcemapsDisable, + )}\`. Debug ID injection and source map upload are handled by the \`sentryOnBuildEnd\` hook for React Router; letting the Vite plugin do it as well injects a second debug ID per chunk and breaks source map resolution. Remove the option, or set \`sourcemaps.disable: true\` at the top level to opt out of Sentry source maps entirely.`, + ); + } + const sentryVitePlugins = sentryVitePlugin({ applicationKey, authToken: authToken ?? process.env.SENTRY_AUTH_TOKEN, @@ -27,27 +40,34 @@ export async function makeCustomSentryVitePlugins(options: SentryReactRouterBuil org: org ?? process.env.SENTRY_ORG, project: project ?? process.env.SENTRY_PROJECT, telemetry: telemetry ?? true, + // Spread here so it can override the plain options above, but not the objects + // merged below - object spread replaces whole keys rather than deep-merging. + ...unstable_sentryVitePluginOptions, _metaOptions: { + ...unstable_sentryVitePluginOptions?._metaOptions, telemetry: { + ...unstable_sentryVitePluginOptions?._metaOptions?.telemetry, metaFramework: 'react-router', }, - ...unstable_sentryVitePluginOptions?._metaOptions, }, reactComponentAnnotation: { - enabled: reactComponentAnnotation?.enabled ?? undefined, - ignoredComponents: reactComponentAnnotation?.ignoredComponents ?? undefined, + // Only assign when set, as an explicit `undefined` would erase the unstable value + ...(reactComponentAnnotation?.enabled !== undefined && { enabled: reactComponentAnnotation.enabled }), + ...(reactComponentAnnotation?.ignoredComponents !== undefined && { + ignoredComponents: reactComponentAnnotation.ignoredComponents, + }), ...unstable_sentryVitePluginOptions?.reactComponentAnnotation, }, release: { ...unstable_sentryVitePluginOptions?.release, ...release, }, - // will be handled in buildEnd hook sourcemaps: { - disable: true, ...unstable_sentryVitePluginOptions?.sourcemaps, + // Injection and upload are handled in the buildEnd hook, so the Vite plugin must + // never do it too. This is deliberately not overridable - see the warning above. + disable: true, }, - ...unstable_sentryVitePluginOptions, }) as Plugin[]; return sentryVitePlugins; diff --git a/packages/react-router/test/vite/buildEnd/handleOnBuildEnd.test.ts b/packages/react-router/test/vite/buildEnd/handleOnBuildEnd.test.ts index a607ff3ccfc6..6251fc664d21 100644 --- a/packages/react-router/test/vite/buildEnd/handleOnBuildEnd.test.ts +++ b/packages/react-router/test/vite/buildEnd/handleOnBuildEnd.test.ts @@ -178,6 +178,94 @@ describe('sentryOnBuildEnd', () => { expect(mockSentryCliInstance.releases.uploadSourceMaps).not.toHaveBeenCalled(); }); + it('should not upload source maps when disabled via top-level sourcemaps.disable', async () => { + const config = { + ...defaultConfig, + viteConfig: { + ...defaultConfig.viteConfig, + sentryConfig: { + ...defaultConfig.viteConfig.sentryConfig, + sourcemaps: { disable: true }, + }, + } as unknown as TestConfig, + }; + + // @ts-expect-error - mocking the React config + await sentryOnBuildEnd(config); + + expect(mockSentryCliInstance.execute).not.toHaveBeenCalled(); + expect(mockSentryCliInstance.releases.uploadSourceMaps).not.toHaveBeenCalled(); + }); + + // `disable` used to be read from the top-level config only, so this opt-out was + // silently ignored while the Vite plugin honoured it - see #22929. + it('should not upload source maps when disabled via unstable_sentryVitePluginOptions', async () => { + const config = { + ...defaultConfig, + viteConfig: { + ...defaultConfig.viteConfig, + sentryConfig: { + ...defaultConfig.viteConfig.sentryConfig, + unstable_sentryVitePluginOptions: { + sourcemaps: { disable: true }, + }, + }, + } as unknown as TestConfig, + }; + + // @ts-expect-error - mocking the React config + await sentryOnBuildEnd(config); + + expect(mockSentryCliInstance.execute).not.toHaveBeenCalled(); + expect(mockSentryCliInstance.releases.uploadSourceMaps).not.toHaveBeenCalled(); + }); + + it('should let top-level sourcemaps.disable override unstable_sentryVitePluginOptions', async () => { + const config = { + ...defaultConfig, + viteConfig: { + ...defaultConfig.viteConfig, + sentryConfig: { + ...defaultConfig.viteConfig.sentryConfig, + sourcemaps: { disable: false }, + unstable_sentryVitePluginOptions: { + sourcemaps: { disable: true }, + }, + }, + } as unknown as TestConfig, + }; + + // @ts-expect-error - mocking the React config + await sentryOnBuildEnd(config); + + expect(mockSentryCliInstance.releases.uploadSourceMaps).toHaveBeenCalled(); + }); + + it('should still upload source maps when unstable_sentryVitePluginOptions only sets unrelated sourcemaps keys', async () => { + const config = { + ...defaultConfig, + viteConfig: { + ...defaultConfig.viteConfig, + sentryConfig: { + ...defaultConfig.viteConfig.sentryConfig, + unstable_sentryVitePluginOptions: { + sourcemaps: { filesToDeleteAfterUpload: ['./build/**/*.map'] }, + }, + }, + } as unknown as TestConfig, + }; + + // @ts-expect-error - mocking the React config + await sentryOnBuildEnd(config); + + expect(mockSentryCliInstance.execute).toHaveBeenCalledWith(['sourcemaps', 'inject', '/build'], false); + expect(mockSentryCliInstance.releases.uploadSourceMaps).toHaveBeenCalled(); + expect(glob).toHaveBeenCalledWith(['./build/**/*.map'], { + absolute: true, + nodir: true, + }); + }); + it('should delete source maps after upload with default pattern', async () => { // @ts-expect-error - mocking the React config await sentryOnBuildEnd(defaultConfig); diff --git a/packages/react-router/test/vite/makeCustomSentryVitePlugins.test.ts b/packages/react-router/test/vite/makeCustomSentryVitePlugins.test.ts index 2434d7592c5e..6e2aa163298b 100644 --- a/packages/react-router/test/vite/makeCustomSentryVitePlugins.test.ts +++ b/packages/react-router/test/vite/makeCustomSentryVitePlugins.test.ts @@ -1,5 +1,5 @@ import { sentryVitePlugin } from '@sentry/bundler-plugins/vite'; -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { makeCustomSentryVitePlugins } from '../../src/vite/makeCustomSentryVitePlugins'; vi.mock('@sentry/bundler-plugins/vite', () => ({ @@ -7,6 +7,12 @@ vi.mock('@sentry/bundler-plugins/vite', () => ({ })); describe('makeCustomSentryVitePlugins', () => { + beforeEach(() => { + // Without this, `toHaveBeenCalledWith` can match a call made by an earlier test, + // so assertions pass against stale arguments instead of their own. + vi.clearAllMocks(); + }); + it('should pass release configuration to sentryVitePlugin', async () => { const options = { release: { @@ -33,16 +39,19 @@ describe('makeCustomSentryVitePlugins', () => { unstable_sentryVitePluginOptions: { release: { name: 'unstable-release', + setCommits: { auto: true as const }, }, }, }; await makeCustomSentryVitePlugins(options); + // Top-level `release` wins field-wise, but unstable-only fields are preserved expect(sentryVitePlugin).toHaveBeenCalledWith( expect.objectContaining({ release: { name: 'test-release', + setCommits: { auto: true }, }, }), ); @@ -78,7 +87,7 @@ describe('makeCustomSentryVitePlugins', () => { ); }); - it('should allow overriding sourcemaps via unstable_sentryVitePluginOptions', async () => { + it('should merge sourcemaps options from unstable_sentryVitePluginOptions while keeping disable', async () => { await makeCustomSentryVitePlugins({ unstable_sentryVitePluginOptions: { sourcemaps: { @@ -87,13 +96,199 @@ describe('makeCustomSentryVitePlugins', () => { }, }); - // unstable_sentryVitePluginOptions is spread last, so it fully overrides sourcemaps expect(sentryVitePlugin).toHaveBeenCalledWith( expect.objectContaining({ sourcemaps: { assets: ['dist/**'], + disable: true, + }, + }), + ); + }); + + // Regression test for https://github.com/getsentry/sentry-javascript/issues/22929: + // any `sourcemaps` key used to drop `disable: true`, re-enabling debug ID injection + // in the Vite plugin on top of the one done by `sentryOnBuildEnd`. + it('should keep sourcemaps disabled when unstable_sentryVitePluginOptions sets an unrelated sourcemaps key', async () => { + await makeCustomSentryVitePlugins({ + authToken: 'token', + org: 'org', + project: 'project', + unstable_sentryVitePluginOptions: { + release: { name: 'commit-sha', setCommits: { auto: true } }, + sourcemaps: { + filesToDeleteAfterUpload: ['./build/**/*.map'], + }, + }, + }); + + expect(sentryVitePlugin).toHaveBeenCalledWith( + expect.objectContaining({ + sourcemaps: { + filesToDeleteAfterUpload: ['./build/**/*.map'], + disable: true, + }, + }), + ); + }); + + it('should not let unstable_sentryVitePluginOptions re-enable sourcemaps via disable: false', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await makeCustomSentryVitePlugins({ + unstable_sentryVitePluginOptions: { + sourcemaps: { + disable: false, + }, + }, + }); + + expect(sentryVitePlugin).toHaveBeenCalledWith( + expect.objectContaining({ + sourcemaps: expect.objectContaining({ disable: true }), + }), + ); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('sourcemaps.disable: false')); + + warnSpy.mockRestore(); + }); + + it('should not let unstable_sentryVitePluginOptions re-enable sourcemaps via disable: "disable-upload"', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await makeCustomSentryVitePlugins({ + unstable_sentryVitePluginOptions: { + sourcemaps: { + disable: 'disable-upload', }, + }, + }); + + expect(sentryVitePlugin).toHaveBeenCalledWith( + expect.objectContaining({ + sourcemaps: expect.objectContaining({ disable: true }), }), ); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('disable-upload')); + + warnSpy.mockRestore(); + }); + + it('should not warn when unstable_sentryVitePluginOptions sets sourcemaps.disable: true', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await makeCustomSentryVitePlugins({ + unstable_sentryVitePluginOptions: { sourcemaps: { disable: true } }, + }); + + expect(warnSpy).not.toHaveBeenCalled(); + + warnSpy.mockRestore(); + }); + + it('should not warn when unstable_sentryVitePluginOptions does not set sourcemaps.disable', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await makeCustomSentryVitePlugins({ + unstable_sentryVitePluginOptions: { sourcemaps: { assets: ['dist/**'] } }, + }); + + expect(warnSpy).not.toHaveBeenCalled(); + + warnSpy.mockRestore(); + }); + + // metaFramework identifies the SDK to Sentry telemetry, so it stays pinned even + // though unstable_sentryVitePluginOptions can override other options. + it('should keep metaFramework when unstable_sentryVitePluginOptions sets _metaOptions.telemetry', async () => { + await makeCustomSentryVitePlugins({ + unstable_sentryVitePluginOptions: { + _metaOptions: { + telemetry: { + metaFramework: 'something-else', + }, + }, + }, + }); + + expect(sentryVitePlugin).toHaveBeenCalledWith( + expect.objectContaining({ + _metaOptions: { + telemetry: { + metaFramework: 'react-router', + }, + }, + }), + ); + }); + + it('should keep reactComponentAnnotation from unstable_sentryVitePluginOptions when top-level is unset', async () => { + await makeCustomSentryVitePlugins({ + unstable_sentryVitePluginOptions: { + reactComponentAnnotation: { + enabled: true, + ignoredComponents: ['Foo'], + }, + }, + }); + + expect(sentryVitePlugin).toHaveBeenCalledWith( + expect.objectContaining({ + reactComponentAnnotation: { + enabled: true, + ignoredComponents: ['Foo'], + }, + }), + ); + }); + + it('should merge reactComponentAnnotation field-wise with unstable_sentryVitePluginOptions', async () => { + await makeCustomSentryVitePlugins({ + reactComponentAnnotation: { enabled: true }, + unstable_sentryVitePluginOptions: { + reactComponentAnnotation: { ignoredComponents: ['Foo'] }, + }, + }); + + expect(sentryVitePlugin).toHaveBeenCalledWith( + expect.objectContaining({ + reactComponentAnnotation: { + enabled: true, + ignoredComponents: ['Foo'], + }, + }), + ); + }); + + // `unstable_sentryVitePluginOptions` is documented as being able to override any + // option the SDK passes to the Vite plugin, so plain top-level keys stay overridable. + it('should let unstable_sentryVitePluginOptions override plain top-level options', async () => { + await makeCustomSentryVitePlugins({ + org: 'top-level-org', + project: 'top-level-project', + telemetry: false, + unstable_sentryVitePluginOptions: { + org: 'unstable-org', + project: 'unstable-project', + }, + }); + + expect(sentryVitePlugin).toHaveBeenCalledWith( + expect.objectContaining({ + org: 'unstable-org', + project: 'unstable-project', + telemetry: false, + }), + ); + }); + + it('should pass through unstable_sentryVitePluginOptions keys that have no top-level equivalent', async () => { + await makeCustomSentryVitePlugins({ + unstable_sentryVitePluginOptions: { + silent: true, + }, + }); + + expect(sentryVitePlugin).toHaveBeenCalledWith(expect.objectContaining({ silent: true })); }); }); From c784c15e23d61fa99d435be147da9daf0ce02218 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Mon, 3 Aug 2026 13:31:24 +0200 Subject: [PATCH 2/8] fix disable upload mode --- .../src/vite/buildEnd/handleOnBuildEnd.ts | 48 +++++++---- .../src/vite/makeCustomSentryVitePlugins.ts | 9 +- .../vite/buildEnd/handleOnBuildEnd.test.ts | 82 +++++++++++++++++++ 3 files changed, 117 insertions(+), 22 deletions(-) diff --git a/packages/react-router/src/vite/buildEnd/handleOnBuildEnd.ts b/packages/react-router/src/vite/buildEnd/handleOnBuildEnd.ts index 6049661649cb..59e32fe1918c 100644 --- a/packages/react-router/src/vite/buildEnd/handleOnBuildEnd.ts +++ b/packages/react-router/src/vite/buildEnd/handleOnBuildEnd.ts @@ -21,9 +21,7 @@ function getSentryConfig(viteConfig: unknown): SentryReactRouterBuildOptions { * Router, so `disable` has to be honoured wherever the user set it. Reading it from the * top-level config only would silently ignore `unstable_sentryVitePluginOptions`. */ -function isSourceMapUploadDisabled( - sentryConfig: SentryReactRouterBuildOptions, -): boolean | 'disable-upload' | undefined { +function resolveSourceMapsDisable(sentryConfig: SentryReactRouterBuildOptions): boolean | 'disable-upload' | undefined { // eslint-disable-next-line typescript/no-deprecated if (sentryConfig.sourceMapsUploadOptions?.enabled === false) { return true; @@ -64,7 +62,7 @@ export const sentryOnBuildEnd: BuildEndHook = async ({ reactRouterConfig, viteCo ...unstableSentryVitePluginOptions?.sourcemaps, ...sentryConfig.sourcemaps, ...sourceMapsUploadOptions, - disable: isSourceMapUploadDisabled(sentryConfig), + disable: resolveSourceMapsDisable(sentryConfig), }, release: { ...unstableSentryVitePluginOptions?.release, @@ -95,7 +93,12 @@ export const sentryOnBuildEnd: BuildEndHook = async ({ reactRouterConfig, viteCo } } - if (!sourcemaps?.disable && viteConfig.build.sourcemap !== false) { + // `disable: 'disable-upload'` still injects debug IDs, so that source maps can be + // uploaded manually at a later point - only `true` turns source maps off entirely. + const sourceMapsFullyDisabled = sourcemaps?.disable === true; + const uploadDisabled = sourceMapsFullyDisabled || sourcemaps?.disable === 'disable-upload'; + + if (!sourceMapsFullyDisabled && viteConfig.build.sourcemap !== false) { // inject debugIds try { await cliInstance.execute( @@ -107,21 +110,30 @@ export const sentryOnBuildEnd: BuildEndHook = async ({ reactRouterConfig, viteCo console.error('[Sentry] Could not inject debug ids', error); } - // upload sourcemaps - try { - await cliInstance.releases.uploadSourceMaps(release?.name || 'undefined', { - include: [ - { - paths: [reactRouterConfig.buildDirectory], - }, - ], - live: 'rejectOnError', - }); - } catch (error) { - // eslint-disable-next-line no-console - console.error('[Sentry] Could not upload sourcemaps', error); + if (!uploadDisabled) { + // upload sourcemaps + try { + await cliInstance.releases.uploadSourceMaps(release?.name || 'undefined', { + include: [ + { + paths: [reactRouterConfig.buildDirectory], + }, + ], + live: 'rejectOnError', + }); + } catch (error) { + // eslint-disable-next-line no-console + console.error('[Sentry] Could not upload sourcemaps', error); + } } } + + // Only clean up source maps that were actually uploaded. Deleting them after skipping + // the upload would leave the user with neither, breaking a manual upload. + if (uploadDisabled) { + return; + } + // delete sourcemaps after upload let updatedFilesToDeleteAfterUpload = await sourcemaps?.filesToDeleteAfterUpload; diff --git a/packages/react-router/src/vite/makeCustomSentryVitePlugins.ts b/packages/react-router/src/vite/makeCustomSentryVitePlugins.ts index 4c3f631fa091..0c7b87e5fcbb 100644 --- a/packages/react-router/src/vite/makeCustomSentryVitePlugins.ts +++ b/packages/react-router/src/vite/makeCustomSentryVitePlugins.ts @@ -21,14 +21,15 @@ export async function makeCustomSentryVitePlugins(options: SentryReactRouterBuil const unstableSourcemapsDisable = unstable_sentryVitePluginOptions?.sourcemaps?.disable; - // Any value other than `true` asks the Vite plugin to inject debug IDs, which the - // `sentryOnBuildEnd` hook already does - so it is ignored rather than honoured. + // Anything other than `true` would have the Vite plugin inject debug IDs on top of the + // ones `sentryOnBuildEnd` injects, which breaks source map resolution. The value still + // applies to the buildEnd hook - only the Vite plugin ignores it. if (unstableSourcemapsDisable !== undefined && unstableSourcemapsDisable !== true) { // eslint-disable-next-line no-console console.warn( - `[Sentry] Ignoring \`unstable_sentryVitePluginOptions.sourcemaps.disable: ${JSON.stringify( + `[Sentry] \`unstable_sentryVitePluginOptions.sourcemaps.disable: ${JSON.stringify( unstableSourcemapsDisable, - )}\`. Debug ID injection and source map upload are handled by the \`sentryOnBuildEnd\` hook for React Router; letting the Vite plugin do it as well injects a second debug ID per chunk and breaks source map resolution. Remove the option, or set \`sourcemaps.disable: true\` at the top level to opt out of Sentry source maps entirely.`, + )}\` does not apply to the Vite plugin. Debug ID injection and source map upload are handled by the \`sentryOnBuildEnd\` hook for React Router, so letting the Vite plugin do it as well would inject a second debug ID per chunk. The option still applies to \`sentryOnBuildEnd\`.`, ); } diff --git a/packages/react-router/test/vite/buildEnd/handleOnBuildEnd.test.ts b/packages/react-router/test/vite/buildEnd/handleOnBuildEnd.test.ts index 6251fc664d21..e2b0985d57d2 100644 --- a/packages/react-router/test/vite/buildEnd/handleOnBuildEnd.test.ts +++ b/packages/react-router/test/vite/buildEnd/handleOnBuildEnd.test.ts @@ -266,6 +266,88 @@ describe('sentryOnBuildEnd', () => { }); }); + // `'disable-upload'` means "inject debug IDs, but let me upload the maps myself", so + // injection must still run and the maps must survive. + it('should inject debug IDs but skip upload and deletion when disable is "disable-upload"', async () => { + const config = { + ...defaultConfig, + viteConfig: { + ...defaultConfig.viteConfig, + sentryConfig: { + ...defaultConfig.viteConfig.sentryConfig, + sourcemaps: { disable: 'disable-upload' }, + }, + } as unknown as TestConfig, + }; + + // @ts-expect-error - mocking the React config + await sentryOnBuildEnd(config); + + expect(mockSentryCliInstance.execute).toHaveBeenCalledWith(['sourcemaps', 'inject', '/build'], false); + expect(mockSentryCliInstance.releases.uploadSourceMaps).not.toHaveBeenCalled(); + expect(glob).not.toHaveBeenCalled(); + }); + + it('should honour "disable-upload" set via unstable_sentryVitePluginOptions', async () => { + const config = { + ...defaultConfig, + viteConfig: { + ...defaultConfig.viteConfig, + sentryConfig: { + ...defaultConfig.viteConfig.sentryConfig, + unstable_sentryVitePluginOptions: { + sourcemaps: { disable: 'disable-upload' }, + }, + }, + } as unknown as TestConfig, + }; + + // @ts-expect-error - mocking the React config + await sentryOnBuildEnd(config); + + expect(mockSentryCliInstance.execute).toHaveBeenCalledWith(['sourcemaps', 'inject', '/build'], false); + expect(mockSentryCliInstance.releases.uploadSourceMaps).not.toHaveBeenCalled(); + expect(glob).not.toHaveBeenCalled(); + }); + + // Deleting maps that were never uploaded would leave the user with neither. + it('should not delete source maps when upload is disabled', async () => { + const config = { + ...defaultConfig, + viteConfig: { + ...defaultConfig.viteConfig, + sentryConfig: { + ...defaultConfig.viteConfig.sentryConfig, + sourcemaps: { disable: true }, + }, + } as unknown as TestConfig, + }; + + // @ts-expect-error - mocking the React config + await sentryOnBuildEnd(config); + + expect(glob).not.toHaveBeenCalled(); + expect(fs.promises.rm).not.toHaveBeenCalled(); + }); + + it('should not delete source maps when disabled via the deprecated sourceMapsUploadOptions', async () => { + const config = { + ...defaultConfig, + viteConfig: { + ...defaultConfig.viteConfig, + sentryConfig: { + ...defaultConfig.viteConfig.sentryConfig, + sourceMapsUploadOptions: { enabled: false }, + }, + } as unknown as TestConfig, + }; + + // @ts-expect-error - mocking the React config + await sentryOnBuildEnd(config); + + expect(glob).not.toHaveBeenCalled(); + }); + it('should delete source maps after upload with default pattern', async () => { // @ts-expect-error - mocking the React config await sentryOnBuildEnd(defaultConfig); From 1d90d5bd9ec1f9267fa897003df8cf18a7c5c1c8 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Mon, 3 Aug 2026 13:44:38 +0200 Subject: [PATCH 3/8] fix(react-router): Don't pass filesToDeleteAfterUpload to the Vite plugin The bundler plugin deletes these files in a `finally` block in `writeBundle` that runs regardless of `sourcemaps.disable`, so forwarding the option removed the maps before `sentryOnBuildEnd` could inject debug IDs and upload them - the same end symptom as the double-injection bug, reached from the other side. Deletion still happens in `sentryOnBuildEnd`, driven by the same user option. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/vite/makeCustomSentryVitePlugins.ts | 4 ++++ .../vite/makeCustomSentryVitePlugins.test.ts | 24 +++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/packages/react-router/src/vite/makeCustomSentryVitePlugins.ts b/packages/react-router/src/vite/makeCustomSentryVitePlugins.ts index 0c7b87e5fcbb..9d8c0a8a7ceb 100644 --- a/packages/react-router/src/vite/makeCustomSentryVitePlugins.ts +++ b/packages/react-router/src/vite/makeCustomSentryVitePlugins.ts @@ -68,6 +68,10 @@ export async function makeCustomSentryVitePlugins(options: SentryReactRouterBuil // Injection and upload are handled in the buildEnd hook, so the Vite plugin must // never do it too. This is deliberately not overridable - see the warning above. disable: true, + // The plugin deletes these in a `finally` block that runs regardless of `disable`, + // which would remove the maps before `sentryOnBuildEnd` gets to upload them. + // Deletion is handled there instead, from the same option. + filesToDeleteAfterUpload: undefined, }, }) as Plugin[]; diff --git a/packages/react-router/test/vite/makeCustomSentryVitePlugins.test.ts b/packages/react-router/test/vite/makeCustomSentryVitePlugins.test.ts index 6e2aa163298b..34893960b522 100644 --- a/packages/react-router/test/vite/makeCustomSentryVitePlugins.test.ts +++ b/packages/react-router/test/vite/makeCustomSentryVitePlugins.test.ts @@ -125,8 +125,32 @@ describe('makeCustomSentryVitePlugins', () => { expect(sentryVitePlugin).toHaveBeenCalledWith( expect.objectContaining({ sourcemaps: { + filesToDeleteAfterUpload: undefined, + disable: true, + }, + }), + ); + }); + + // The plugin's `writeBundle` deletes these in a `finally` block that runs even when + // `sourcemaps.disable` is set, which would remove the maps before `sentryOnBuildEnd` + // uploads them. `sentryOnBuildEnd` performs the deletion instead. + it('should not forward filesToDeleteAfterUpload to the Vite plugin', async () => { + await makeCustomSentryVitePlugins({ + unstable_sentryVitePluginOptions: { + sourcemaps: { + assets: ['dist/**'], filesToDeleteAfterUpload: ['./build/**/*.map'], + }, + }, + }); + + expect(sentryVitePlugin).toHaveBeenCalledWith( + expect.objectContaining({ + sourcemaps: { + assets: ['dist/**'], disable: true, + filesToDeleteAfterUpload: undefined, }, }), ); From c96aeeb57dffe99be598852d2f7a4ec4cbd9d9d6 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Mon, 3 Aug 2026 14:09:46 +0200 Subject: [PATCH 4/8] test(react-router): Add e2e assertions for debug ID injection and source map upload The existing react-router e2e suites build real apps but never check how many debug IDs each chunk carries, so they passed throughout both the double-injection bug in #22929 and the premature source map deletion found while fixing it. This app builds against a mock Sentry server and asserts on the build output: exactly one debug ID per client chunk, source maps with real mappings present in the uploaded artifact bundles, and every shipped debug ID backed by an upload. It deliberately routes `sourcemaps` through `unstable_sentryVitePluginOptions`, the config shape that triggered the original report. Verified to fail on both bugs - two debug IDs per chunk for the first, no uploaded chunks to cross-check for the second. Co-Authored-By: Claude Opus 5 (1M context) --- .../react-router-7-sourcemaps/.gitignore | 7 ++ .../app/entry.client.tsx | 12 +++ .../react-router-7-sourcemaps/app/root.tsx | 23 ++++ .../react-router-7-sourcemaps/app/routes.ts | 3 + .../app/routes/home.tsx | 3 + .../react-router-7-sourcemaps/assert-build.ts | 102 ++++++++++++++++++ .../react-router-7-sourcemaps/package.json | 34 ++++++ .../react-router.config.ts | 9 ++ .../start-mock-sentry-server.mjs | 3 + .../react-router-7-sourcemaps/tsconfig.json | 20 ++++ .../react-router-7-sourcemaps/vite.config.ts | 30 ++++++ 11 files changed, 246 insertions(+) create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/app/entry.client.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/app/root.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/app/routes.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/app/routes/home.tsx create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/assert-build.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/package.json create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/react-router.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/start-mock-sentry-server.mjs create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/tsconfig.json create mode 100644 dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/vite.config.ts diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/.gitignore b/dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/.gitignore new file mode 100644 index 000000000000..bd7f8c64b406 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/.gitignore @@ -0,0 +1,7 @@ +/node_modules +/build +.react-router +.tmp_mock_uploads.json +.tmp_chunks +.tmp_build_stdout +.tmp_build_stderr diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/app/entry.client.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/app/entry.client.tsx new file mode 100644 index 000000000000..33cb007f5313 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/app/entry.client.tsx @@ -0,0 +1,12 @@ +import { StrictMode, startTransition } from 'react'; +import { hydrateRoot } from 'react-dom/client'; +import { HydratedRouter } from 'react-router/dom'; + +startTransition(() => { + hydrateRoot( + document, + + + , + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/app/root.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/app/root.tsx new file mode 100644 index 000000000000..c09b53b99d46 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/app/root.tsx @@ -0,0 +1,23 @@ +import { Links, Meta, Outlet, Scripts, ScrollRestoration } from 'react-router'; + +export function Layout({ children }: { children: React.ReactNode }) { + return ( + + + + + + + + + {children} + + + + + ); +} + +export default function App() { + return ; +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/app/routes.ts b/dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/app/routes.ts new file mode 100644 index 000000000000..205ff3ccb9fd --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/app/routes.ts @@ -0,0 +1,3 @@ +import { type RouteConfig, index } from '@react-router/dev/routes'; + +export default [index('routes/home.tsx')] satisfies RouteConfig; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/app/routes/home.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/app/routes/home.tsx new file mode 100644 index 000000000000..d9227c1a3262 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/app/routes/home.tsx @@ -0,0 +1,3 @@ +export default function Home() { + return

Sourcemaps test app

; +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/assert-build.ts b/dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/assert-build.ts new file mode 100644 index 000000000000..0376dbe52bb6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/assert-build.ts @@ -0,0 +1,102 @@ +import * as assert from 'assert/strict'; +import * as fs from 'fs'; +import * as path from 'path'; +import { getArtifactBundles, getDebugIdPairs, getSourcemaps, loadMockServerResults } from '@sentry-internal/test-utils'; + +const CLIENT_ASSETS_DIR = 'build/client/assets'; + +// Both injectors write this assignment, so counting it per file counts injections +// regardless of which one ran. Matching only the bundler plugin's trailing +// `_sentryDebugIdIdentifier` would miss the `sentry-cli` snippet, which omits it. +const DEBUG_ID_ASSIGNMENT = + /_sentryDebugIds\[[^\]]+\]\s*=\s*"([\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12})"/gi; + +function getClientChunks(): string[] { + assert.ok(fs.existsSync(CLIENT_ASSETS_DIR), `Expected ${CLIENT_ASSETS_DIR} to exist. Did the build run?`); + + return fs + .readdirSync(CLIENT_ASSETS_DIR) + .filter(file => file.endsWith('.js')) + .map(file => path.join(CLIENT_ASSETS_DIR, file)); +} + +const chunks = getClientChunks(); +assert.ok(chunks.length > 0, `Expected at least one client chunk in ${CLIENT_ASSETS_DIR}`); + +// 1. Every chunk carries exactly one debug ID. +// +// Two injections per chunk is the failure mode of +// https://github.com/getsentry/sentry-javascript/issues/22929: both snippets run at +// runtime, `applyDebugIds` flattens them to a single filename, and the last one wins - +// which is the CLI's, the one with no uploaded artifact bundle. Frames arrive minified. +const injectedDebugIds = new Map(); + +for (const chunk of chunks) { + const code = fs.readFileSync(chunk, 'utf-8'); + const ids = [...code.matchAll(DEBUG_ID_ASSIGNMENT)].map(match => match[1] as string); + + if (ids.length > 0) { + injectedDebugIds.set(chunk, ids); + } + + assert.ok( + ids.length <= 1, + `Expected at most one debug ID in ${chunk}, found ${ids.length}: ${JSON.stringify([...new Set(ids)])}. ` + + 'More than one means debug IDs were injected twice (Vite plugin *and* sentryOnBuildEnd).', + ); +} + +assert.ok(injectedDebugIds.size > 0, 'Expected at least one client chunk to carry a debug ID'); +console.log(`${injectedDebugIds.size} of ${chunks.length} client chunk(s) carry exactly one debug ID\n`); + +const requests = loadMockServerResults(); +const bundles = getArtifactBundles(requests); +assert.ok(bundles.length > 0, 'Expected at least one uploaded artifact bundle'); + +// 2. Source maps with real content reached Sentry. +// +// The Vite plugin deletes `sourcemaps.filesToDeleteAfterUpload` in a `finally` block that +// runs even when `sourcemaps.disable` is set. Forwarding that option removed the maps +// before `sentryOnBuildEnd` could upload them, leaving nothing to un-minify with. Asserting +// on the upload rather than on disk, because deleting the maps *after* a successful upload +// is the intended behaviour. +const uploadedSourcemaps = getSourcemaps(bundles); +assert.ok(uploadedSourcemaps.length > 0, 'Expected at least one source map in the uploaded artifact bundles'); +assert.ok( + uploadedSourcemaps.some(entry => (entry.sourcemap.mappings?.length ?? 0) > 0), + 'Expected at least one uploaded source map with non-empty mappings', +); +console.log(`${uploadedSourcemaps.length} source map(s) uploaded with content`); + +// 3. The debug IDs that shipped are the ones that were uploaded. +// +// This is what actually breaks un-minification: a chunk can carry a perfectly valid debug +// ID that has no artifact bundle behind it. + +const debugIdPairs = getDebugIdPairs(bundles); +const uploadedDebugIds = new Set(debugIdPairs.map(pair => pair.debugId.toLowerCase())); +assert.ok(uploadedDebugIds.size > 0, 'Expected at least one uploaded JS/source map pair with a debug ID'); + +// Vite emits some assets (e.g. the route manifest) without a source map, so they can never +// be part of an uploaded JS/map pair. Key off the uploaded JS file names instead of the +// maps on disk, which are deleted after a successful upload. +const uploadedJsFiles = new Set(debugIdPairs.map(pair => path.basename(pair.jsUrl))); +let crossCheckedChunks = 0; + +for (const [chunk, ids] of injectedDebugIds) { + if (!uploadedJsFiles.has(path.basename(chunk))) { + continue; + } + + const debugId = (ids[0] as string).toLowerCase(); + assert.ok( + uploadedDebugIds.has(debugId), + `Debug ID ${debugId} in ${chunk} was never uploaded. Uploaded: ${JSON.stringify([...uploadedDebugIds])}`, + ); + crossCheckedChunks++; +} + +assert.ok(crossCheckedChunks > 0, 'Expected at least one uploaded chunk to cross-check debug IDs against'); +console.log(`${crossCheckedChunks} chunk(s) ship a debug ID that was uploaded\n`); + +console.log('All react-router source map assertions passed!'); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/package.json b/dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/package.json new file mode 100644 index 000000000000..83c8d7f02fa8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/package.json @@ -0,0 +1,34 @@ +{ + "name": "react-router-7-sourcemaps", + "version": "0.1.0", + "type": "module", + "private": true, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router": "^7.13.0", + "@react-router/node": "^7.13.0", + "@react-router/serve": "^7.13.0", + "@sentry/react-router": "file:../../packed/sentry-react-router-packed.tgz", + "isbot": "^5.1.17" + }, + "devDependencies": { + "@types/react": "18.3.1", + "@types/react-dom": "18.3.1", + "@types/node": "^20", + "@react-router/dev": "^7.13.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "ts-node": "10.9.1", + "typescript": "^5.6.3", + "vite": "^5.4.11" + }, + "scripts": { + "build": "node start-mock-sentry-server.mjs & SENTRY_URL=http://localhost:3032 react-router build > .tmp_build_stdout 2> .tmp_build_stderr; BUILD_EXIT=$?; kill %1 2>/dev/null; exit $BUILD_EXIT", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm ts-node --esm assert-build.ts" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/react-router.config.ts b/dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/react-router.config.ts new file mode 100644 index 000000000000..de81e63c4e37 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/react-router.config.ts @@ -0,0 +1,9 @@ +import type { Config } from '@react-router/dev/config'; +import { sentryOnBuildEnd } from '@sentry/react-router'; + +export default { + ssr: true, + buildEnd: async ({ viteConfig, reactRouterConfig, buildManifest }) => { + await sentryOnBuildEnd({ viteConfig, reactRouterConfig, buildManifest }); + }, +} satisfies Config; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/start-mock-sentry-server.mjs b/dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/start-mock-sentry-server.mjs new file mode 100644 index 000000000000..cce37a5f9daf --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/start-mock-sentry-server.mjs @@ -0,0 +1,3 @@ +import { startMockSentryServer } from '@sentry-internal/test-utils'; + +startMockSentryServer(); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/tsconfig.json b/dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/tsconfig.json new file mode 100644 index 000000000000..a16df276e8bc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "types": ["node", "vite/client"], + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "rootDirs": [".", "./.react-router/types"], + "baseUrl": ".", + + "esModuleInterop": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true + }, + "include": ["**/*", "**/.server/**/*", "**/.client/**/*", ".react-router/types/**/*"] +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/vite.config.ts b/dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/vite.config.ts new file mode 100644 index 000000000000..f75d14c69f8a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/vite.config.ts @@ -0,0 +1,30 @@ +import { reactRouter } from '@react-router/dev/vite'; +import { sentryReactRouter, type SentryReactRouterBuildOptions } from '@sentry/react-router'; +import { defineConfig } from 'vite'; + +// Deliberately routes `sourcemaps` through `unstable_sentryVitePluginOptions`. That shape +// used to drop the SDK's `sourcemaps.disable: true`, which re-enabled debug ID injection in +// the Vite plugin on top of the injection done by `sentryOnBuildEnd` - two debug IDs per +// chunk, only one of which has an uploaded artifact bundle. +// See https://github.com/getsentry/sentry-javascript/issues/22929 +export const sentryConfig: SentryReactRouterBuildOptions = { + authToken: 'fake-auth-token', + org: 'test-org', + project: 'test-project', + release: { + name: 'test-release', + }, + unstable_sentryVitePluginOptions: { + url: 'http://localhost:3032', + sourcemaps: { + // The maps have to survive until `sentryOnBuildEnd` uploads them, so this asserts + // the option is not forwarded to the Vite plugin (which deletes in a `finally`). + filesToDeleteAfterUpload: ['./build/client/assets/**/*.map'], + }, + }, + debug: true, +}; + +export default defineConfig(config => ({ + plugins: [reactRouter(), sentryReactRouter(sentryConfig, config)], +})); From 8b680b97ce5987d976258aa5c2adfc8c022bc4da Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Mon, 3 Aug 2026 14:53:20 +0200 Subject: [PATCH 5/8] test(react-router): Add @playwright/test to the sourcemaps e2e app The shared E2E job runs its "Install Playwright" step for every test application, so a build-time-only app still needs the dependency present or the job exits 127 before reaching the assertions. Matches what nextjs-sourcemaps does. Co-Authored-By: Claude Opus 5 (1M context) --- .../test-applications/react-router-7-sourcemaps/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/package.json b/dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/package.json index 83c8d7f02fa8..b0b0f91b01c8 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/package.json +++ b/dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/package.json @@ -17,6 +17,7 @@ "@types/react-dom": "18.3.1", "@types/node": "^20", "@react-router/dev": "^7.13.0", + "@playwright/test": "~1.56.0", "@sentry-internal/test-utils": "link:../../../test-utils", "ts-node": "10.9.1", "typescript": "^5.6.3", From 899454b073180001496761b03d9225de3606c24e Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Mon, 3 Aug 2026 15:01:50 +0200 Subject: [PATCH 6/8] test(react-router): Run the sourcemaps assertions with tsx instead of ts-node The app is ESM (`type: module`, required by react-router), so `ts-node` needs `--esm` and still fails with ERR_UNKNOWN_FILE_EXTENSION on CI. nextjs-sourcemaps gets away with plain `ts-node` only because it is CommonJS. tsx handles ESM TypeScript natively and is already the standard for running .ts scripts elsewhere in dev-packages/e2e-tests. Co-Authored-By: Claude Opus 5 (1M context) --- .../test-applications/react-router-7-sourcemaps/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/package.json b/dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/package.json index b0b0f91b01c8..50a1ab877241 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/package.json +++ b/dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/package.json @@ -19,7 +19,7 @@ "@react-router/dev": "^7.13.0", "@playwright/test": "~1.56.0", "@sentry-internal/test-utils": "link:../../../test-utils", - "ts-node": "10.9.1", + "tsx": "^4.23.0", "typescript": "^5.6.3", "vite": "^5.4.11" }, @@ -27,7 +27,7 @@ "build": "node start-mock-sentry-server.mjs & SENTRY_URL=http://localhost:3032 react-router build > .tmp_build_stdout 2> .tmp_build_stderr; BUILD_EXIT=$?; kill %1 2>/dev/null; exit $BUILD_EXIT", "clean": "npx rimraf node_modules pnpm-lock.yaml", "test:build": "pnpm install && pnpm build", - "test:assert": "pnpm ts-node --esm assert-build.ts" + "test:assert": "pnpm tsx assert-build.ts" }, "volta": { "extends": "../../package.json" From 4e3c7860a14e9920f046751e2713c19dc0728529 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Mon, 3 Aug 2026 17:45:43 +0200 Subject: [PATCH 7/8] test(react-router): Assert exactly one debug ID per chunk, not at most one `ids.length <= 1` also passed for chunks with zero debug IDs, and those chunks were then skipped by the `ids.length > 0` guard so nothing downstream checked them. A regression where injection silently missed some client chunks would have gone undetected, even though unresolvable frames are the same user-visible outcome as injecting twice. Verified both directions against a real build: stripping a chunk's snippet now fails with "found 0", adding a second one fails with "found 2". Co-Authored-By: Claude Opus 5 (1M context) --- .../react-router-7-sourcemaps/assert-build.ts | 27 ++++++++++--------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/assert-build.ts b/dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/assert-build.ts index 0376dbe52bb6..970744c09427 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/assert-build.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/assert-build.ts @@ -29,25 +29,26 @@ assert.ok(chunks.length > 0, `Expected at least one client chunk in ${CLIENT_ASS // https://github.com/getsentry/sentry-javascript/issues/22929: both snippets run at // runtime, `applyDebugIds` flattens them to a single filename, and the last one wins - // which is the CLI's, the one with no uploaded artifact bundle. Frames arrive minified. -const injectedDebugIds = new Map(); +const injectedDebugIds = new Map(); for (const chunk of chunks) { const code = fs.readFileSync(chunk, 'utf-8'); const ids = [...code.matchAll(DEBUG_ID_ASSIGNMENT)].map(match => match[1] as string); - if (ids.length > 0) { - injectedDebugIds.set(chunk, ids); - } - - assert.ok( - ids.length <= 1, - `Expected at most one debug ID in ${chunk}, found ${ids.length}: ${JSON.stringify([...new Set(ids)])}. ` + - 'More than one means debug IDs were injected twice (Vite plugin *and* sentryOnBuildEnd).', + // Exactly one, not "at most one": zero would mean injection silently skipped a chunk, + // which leaves its frames unresolvable just as surely as injecting twice does. + assert.equal( + ids.length, + 1, + `Expected exactly one debug ID in ${chunk}, found ${ids.length}: ${JSON.stringify([...new Set(ids)])}. ` + + 'More than one means debug IDs were injected twice (Vite plugin *and* sentryOnBuildEnd); ' + + 'none means injection skipped this chunk.', ); + + injectedDebugIds.set(chunk, ids[0] as string); } -assert.ok(injectedDebugIds.size > 0, 'Expected at least one client chunk to carry a debug ID'); -console.log(`${injectedDebugIds.size} of ${chunks.length} client chunk(s) carry exactly one debug ID\n`); +console.log(`all ${chunks.length} client chunk(s) carry exactly one debug ID\n`); const requests = loadMockServerResults(); const bundles = getArtifactBundles(requests); @@ -83,12 +84,12 @@ assert.ok(uploadedDebugIds.size > 0, 'Expected at least one uploaded JS/source m const uploadedJsFiles = new Set(debugIdPairs.map(pair => path.basename(pair.jsUrl))); let crossCheckedChunks = 0; -for (const [chunk, ids] of injectedDebugIds) { +for (const [chunk, injectedDebugId] of injectedDebugIds) { if (!uploadedJsFiles.has(path.basename(chunk))) { continue; } - const debugId = (ids[0] as string).toLowerCase(); + const debugId = injectedDebugId.toLowerCase(); assert.ok( uploadedDebugIds.has(debugId), `Debug ID ${debugId} in ${chunk} was never uploaded. Uploaded: ${JSON.stringify([...uploadedDebugIds])}`, From 47b95698efb0dfaa50ab29b8d6a6124b261a431b Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Mon, 3 Aug 2026 17:50:56 +0200 Subject: [PATCH 8/8] test(react-router): Use react-router 8 for the sourcemaps e2e app GHSA-qwww-vcr4-c8h2 (high, CSRF bypass in RSC mode) covers react-router >= 7.12.0 < 8.3.0 with no patched 7.x release, so dependency-review rejected the new app's manifest. Existing apps still pin ^7.13.0 but are not re-checked, since dependency-review only inspects the diff. Nothing in these assertions is version-specific - they check debug ID injection and upload, which work the same on 8.x - so the app moves to 8.3.0 and drops the version from its name. Verified on 8.3.0: all 6 chunks carry exactly one debug ID, and reintroducing the trailing spread still fails the assertion with "found 2". Co-Authored-By: Claude Opus 5 (1M context) --- .../.gitignore | 0 .../app/entry.client.tsx | 0 .../app/root.tsx | 0 .../app/routes.ts | 0 .../app/routes/home.tsx | 0 .../assert-build.ts | 0 .../package.json | 20 +++++++++---------- .../react-router.config.ts | 0 .../start-mock-sentry-server.mjs | 0 .../tsconfig.json | 0 .../vite.config.ts | 0 11 files changed, 10 insertions(+), 10 deletions(-) rename dev-packages/e2e-tests/test-applications/{react-router-7-sourcemaps => react-router-sourcemaps}/.gitignore (100%) rename dev-packages/e2e-tests/test-applications/{react-router-7-sourcemaps => react-router-sourcemaps}/app/entry.client.tsx (100%) rename dev-packages/e2e-tests/test-applications/{react-router-7-sourcemaps => react-router-sourcemaps}/app/root.tsx (100%) rename dev-packages/e2e-tests/test-applications/{react-router-7-sourcemaps => react-router-sourcemaps}/app/routes.ts (100%) rename dev-packages/e2e-tests/test-applications/{react-router-7-sourcemaps => react-router-sourcemaps}/app/routes/home.tsx (100%) rename dev-packages/e2e-tests/test-applications/{react-router-7-sourcemaps => react-router-sourcemaps}/assert-build.ts (100%) rename dev-packages/e2e-tests/test-applications/{react-router-7-sourcemaps => react-router-sourcemaps}/package.json (71%) rename dev-packages/e2e-tests/test-applications/{react-router-7-sourcemaps => react-router-sourcemaps}/react-router.config.ts (100%) rename dev-packages/e2e-tests/test-applications/{react-router-7-sourcemaps => react-router-sourcemaps}/start-mock-sentry-server.mjs (100%) rename dev-packages/e2e-tests/test-applications/{react-router-7-sourcemaps => react-router-sourcemaps}/tsconfig.json (100%) rename dev-packages/e2e-tests/test-applications/{react-router-7-sourcemaps => react-router-sourcemaps}/vite.config.ts (100%) diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/.gitignore b/dev-packages/e2e-tests/test-applications/react-router-sourcemaps/.gitignore similarity index 100% rename from dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/.gitignore rename to dev-packages/e2e-tests/test-applications/react-router-sourcemaps/.gitignore diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/app/entry.client.tsx b/dev-packages/e2e-tests/test-applications/react-router-sourcemaps/app/entry.client.tsx similarity index 100% rename from dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/app/entry.client.tsx rename to dev-packages/e2e-tests/test-applications/react-router-sourcemaps/app/entry.client.tsx diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/app/root.tsx b/dev-packages/e2e-tests/test-applications/react-router-sourcemaps/app/root.tsx similarity index 100% rename from dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/app/root.tsx rename to dev-packages/e2e-tests/test-applications/react-router-sourcemaps/app/root.tsx diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/app/routes.ts b/dev-packages/e2e-tests/test-applications/react-router-sourcemaps/app/routes.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/app/routes.ts rename to dev-packages/e2e-tests/test-applications/react-router-sourcemaps/app/routes.ts diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/app/routes/home.tsx b/dev-packages/e2e-tests/test-applications/react-router-sourcemaps/app/routes/home.tsx similarity index 100% rename from dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/app/routes/home.tsx rename to dev-packages/e2e-tests/test-applications/react-router-sourcemaps/app/routes/home.tsx diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/assert-build.ts b/dev-packages/e2e-tests/test-applications/react-router-sourcemaps/assert-build.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/assert-build.ts rename to dev-packages/e2e-tests/test-applications/react-router-sourcemaps/assert-build.ts diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/package.json b/dev-packages/e2e-tests/test-applications/react-router-sourcemaps/package.json similarity index 71% rename from dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/package.json rename to dev-packages/e2e-tests/test-applications/react-router-sourcemaps/package.json index 50a1ab877241..b56410c587cf 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/package.json +++ b/dev-packages/e2e-tests/test-applications/react-router-sourcemaps/package.json @@ -1,27 +1,27 @@ { - "name": "react-router-7-sourcemaps", + "name": "react-router-sourcemaps", "version": "0.1.0", "type": "module", "private": true, "dependencies": { - "react": "^18.3.1", - "react-dom": "^18.3.1", - "react-router": "^7.13.0", - "@react-router/node": "^7.13.0", - "@react-router/serve": "^7.13.0", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-router": "^8.3.0", + "@react-router/node": "^8.3.0", + "@react-router/serve": "^8.3.0", "@sentry/react-router": "file:../../packed/sentry-react-router-packed.tgz", "isbot": "^5.1.17" }, "devDependencies": { - "@types/react": "18.3.1", - "@types/react-dom": "18.3.1", + "@types/react": "19.2.17", + "@types/react-dom": "19.2.3", "@types/node": "^20", - "@react-router/dev": "^7.13.0", + "@react-router/dev": "^8.3.0", "@playwright/test": "~1.56.0", "@sentry-internal/test-utils": "link:../../../test-utils", "tsx": "^4.23.0", "typescript": "^5.6.3", - "vite": "^5.4.11" + "vite": "^7.3.2" }, "scripts": { "build": "node start-mock-sentry-server.mjs & SENTRY_URL=http://localhost:3032 react-router build > .tmp_build_stdout 2> .tmp_build_stderr; BUILD_EXIT=$?; kill %1 2>/dev/null; exit $BUILD_EXIT", diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/react-router.config.ts b/dev-packages/e2e-tests/test-applications/react-router-sourcemaps/react-router.config.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/react-router.config.ts rename to dev-packages/e2e-tests/test-applications/react-router-sourcemaps/react-router.config.ts diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/start-mock-sentry-server.mjs b/dev-packages/e2e-tests/test-applications/react-router-sourcemaps/start-mock-sentry-server.mjs similarity index 100% rename from dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/start-mock-sentry-server.mjs rename to dev-packages/e2e-tests/test-applications/react-router-sourcemaps/start-mock-sentry-server.mjs diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/tsconfig.json b/dev-packages/e2e-tests/test-applications/react-router-sourcemaps/tsconfig.json similarity index 100% rename from dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/tsconfig.json rename to dev-packages/e2e-tests/test-applications/react-router-sourcemaps/tsconfig.json diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/vite.config.ts b/dev-packages/e2e-tests/test-applications/react-router-sourcemaps/vite.config.ts similarity index 100% rename from dev-packages/e2e-tests/test-applications/react-router-7-sourcemaps/vite.config.ts rename to dev-packages/e2e-tests/test-applications/react-router-sourcemaps/vite.config.ts