From 27e85c741681a70a7a5d1a24b98e72df0e6e93c4 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:53:07 +0200 Subject: [PATCH 1/3] fix(tanstackstart-react): Rewrite source paths of split route modules TanStack Start splits route files into virtual modules such as `index.tsx?tss-serverfn-split`. The uploaded source maps kept these queries and paths relative to the map's directory, so frames did not show the path of the source file. Remove the split queries and make source paths relative to the project root, so frames show paths such as `src/routes/index.tsx`. A user's `sourcemaps.rewriteSources` still takes precedence. --- .../src/vite/sourceMaps.ts | 28 +++++++- .../test/vite/sourceMaps.test.ts | 67 ++++++++++++++++++- 2 files changed, 93 insertions(+), 2 deletions(-) diff --git a/packages/tanstackstart-react/src/vite/sourceMaps.ts b/packages/tanstackstart-react/src/vite/sourceMaps.ts index 817cad432a22..113444db628a 100644 --- a/packages/tanstackstart-react/src/vite/sourceMaps.ts +++ b/packages/tanstackstart-react/src/vite/sourceMaps.ts @@ -1,9 +1,35 @@ import { sentryVitePlugin } from '@sentry/bundler-plugins/vite'; +import * as path from 'node:path'; import type { Plugin, UserConfig } from 'vite'; import type { SentryTanstackStartOptions } from './sentryTanstackStart'; type FilesToDeleteAfterUpload = string | string[] | undefined; +/** TanStack Router and Start split route files into virtual modules, e.g. `index.tsx?tss-serverfn-split`. */ +const TANSTACK_SPLIT_QUERY_REGEX = /\?ts[rs]-[^/]*$/; + +/** Sources such as `virtual:#nitro/runtime` are no file paths. Two letters at least, so `C:` stays a drive letter. */ +const SCHEME_REGEX = /^[a-z][a-z\d+.-]+:/i; + +/** + * Makes source paths relative to the project root and removes TanStack's split queries, so that client and server + * frames show the path of the source file, such as `src/routes/index.tsx`. + * + * Paths in a source map are relative to the map's directory, which the default hook ignores. + * + * @internal Only exported for testing. + */ +export function rewriteTanstackStartSource(source: string, _map: unknown, context?: { mapDir: string }): string { + const sourcePath = source.replace(TANSTACK_SPLIT_QUERY_REGEX, ''); + + if (SCHEME_REGEX.test(sourcePath)) { + return sourcePath; + } + + const absolutePath = path.resolve(context?.mapDir ?? process.cwd(), sourcePath); + return path.relative(process.cwd(), absolutePath).split(path.sep).join('/'); +} + /** * A Sentry plugin for adding the @sentry/bundler-plugins/vite to automatically upload source maps to Sentry. */ @@ -70,7 +96,7 @@ export function makeAddSentryVitePlugin(options: SentryTanstackStartOptions): Pl assets: sourcemaps?.assets, disable: sourcemaps?.disable, ignore: sourcemaps?.ignore, - rewriteSources: sourcemaps?.rewriteSources, + rewriteSources: sourcemaps?.rewriteSources ?? rewriteTanstackStartSource, resolveSourceMap: sourcemaps?.resolveSourceMap, filesToDeleteAfterUpload: filesToDeleteAfterUploadPromise, }, diff --git a/packages/tanstackstart-react/test/vite/sourceMaps.test.ts b/packages/tanstackstart-react/test/vite/sourceMaps.test.ts index e5ea14c95b3e..eb4b3740b658 100644 --- a/packages/tanstackstart-react/test/vite/sourceMaps.test.ts +++ b/packages/tanstackstart-react/test/vite/sourceMaps.test.ts @@ -2,11 +2,13 @@ import type { SentryVitePluginOptions } from '@sentry/bundler-plugins/vite'; import type { UserConfig } from 'vite'; import { mergeConfig } from 'vite'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as path from 'path'; import { getUpdatedSourceMapSettings, makeAddSentryVitePlugin, makeEnableSourceMapsVitePlugin, + rewriteTanstackStartSource, } from '../../src/vite/sourceMaps'; const mockedSentryVitePlugin = { @@ -273,6 +275,16 @@ describe('makeAddSentryVitePlugin()', () => { ); }); + it('rewrites sources with the TanStack Start hook by default', () => { + makeAddSentryVitePlugin({}); + + expect(sentryVitePluginSpy).toHaveBeenCalledWith( + expect.objectContaining({ + sourcemaps: expect.objectContaining({ rewriteSources: rewriteTanstackStartSource }), + }), + ); + }); + it('sets the correct metaFramework in telemetry options', () => { makeAddSentryVitePlugin({ org: 'my-org', @@ -353,3 +365,56 @@ describe('getUpdatedSourceMapSettings', () => { expect(result).toBe('hidden'); }); }); + +describe('rewriteTanstackStartSource', () => { + const projectRoot = path.resolve('/projects/shop'); + + beforeEach(() => { + vi.spyOn(process, 'cwd').mockReturnValue(projectRoot); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it.each([ + { + label: 'a server function split from a route', + source: '../../../src/routes/index.tsx?tss-serverfn-split', + mapDir: '.output/server/_ssr', + expected: 'src/routes/index.tsx', + }, + { + label: 'a route component split for the client', + source: '../../../src/routes/users.$userId.tsx?tsr-split=component', + mapDir: '.output/public/assets', + expected: 'src/routes/users.$userId.tsx', + }, + { + label: 'a dependency', + source: '../../node_modules/.pnpm/h3@2.0.2/node_modules/h3/dist/index.mjs', + mapDir: '.output/server', + expected: 'node_modules/.pnpm/h3@2.0.2/node_modules/h3/dist/index.mjs', + }, + { + label: 'a source that climbs fewer directories than the map is deep', + source: '../shared/format-price.ts', + mapDir: '.output/server/chunks/build', + expected: '.output/server/chunks/shared/format-price.ts', + }, + ])('makes the path of $label relative to the project root', ({ source, mapDir, expected }) => { + expect(rewriteTanstackStartSource(source, {}, { mapDir: path.join(projectRoot, mapDir) })).toBe(expected); + }); + + it('keeps sources that are no file paths', () => { + const mapDir = path.join(projectRoot, '.output/server'); + + expect(rewriteTanstackStartSource('virtual:#nitro/virtual/plugins', {}, { mapDir })).toBe( + 'virtual:#nitro/virtual/plugins', + ); + }); + + it('resolves sources from the project root without a map directory', () => { + expect(rewriteTanstackStartSource('src/routes/index.tsx?tss-serverfn-split', {})).toBe('src/routes/index.tsx'); + }); +}); From 2f5889c11e6873fb2646f2a0faae1566ff69e6a3 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:53:51 +0200 Subject: [PATCH 2/3] fix(tanstackstart-react): Delete only the source maps that Sentry enabled The default `filesToDeleteAfterUpload: ['./**/*.map']` does not enter dot directories like `.output`, so source maps that Sentry enabled stayed in the client build of Nitro apps. Delete the source maps in the output directory of each environment that only has source maps because Sentry enabled them. Keep source maps that the user enabled with `build.sourcemap`, an environment setting or `build.rolldownOptions.output.sourcemap`. Warn on Vite 7, where Sentry only knows `build.outDir`. --- .../src/vite/sourceMaps.ts | 120 ++++++-- .../test/vite/sourceMaps.test.ts | 264 +++++++++++++----- 2 files changed, 290 insertions(+), 94 deletions(-) diff --git a/packages/tanstackstart-react/src/vite/sourceMaps.ts b/packages/tanstackstart-react/src/vite/sourceMaps.ts index 113444db628a..3f39e8acb6e8 100644 --- a/packages/tanstackstart-react/src/vite/sourceMaps.ts +++ b/packages/tanstackstart-react/src/vite/sourceMaps.ts @@ -1,10 +1,16 @@ import { sentryVitePlugin } from '@sentry/bundler-plugins/vite'; import * as path from 'node:path'; -import type { Plugin, UserConfig } from 'vite'; +import type { BuildEnvironmentOptions, Plugin, ResolvedConfig, UserConfig } from 'vite'; import type { SentryTanstackStartOptions } from './sentryTanstackStart'; type FilesToDeleteAfterUpload = string | string[] | undefined; +/** + * Tells the plugin that deletes source maps after the upload whether the source maps of an environment are only + * there because Sentry enabled them. + */ +type SourceMapsEnablerApi = { hasSentryEnabledSourceMaps(environmentName: string): boolean }; + /** TanStack Router and Start split route files into virtual modules, e.g. `index.tsx?tss-serverfn-split`. */ const TANSTACK_SPLIT_QUERY_REGEX = /\?ts[rs]-[^/]*$/; @@ -62,21 +68,27 @@ export function makeAddSentryVitePlugin(options: SentryTanstackStartOptions): Pl name: 'sentry-tanstackstart-files-to-delete-after-upload-plugin', apply: 'build', enforce: 'post', - config(config) { - const userFilesToDelete = sourcemaps?.filesToDeleteAfterUpload; - - // Only auto-delete source maps if the user didn't configure sourcemaps at all - if (typeof userFilesToDelete === 'undefined' && typeof config.build?.sourcemap === 'undefined') { - if (debug) { - // eslint-disable-next-line no-console - console.log( - '[Sentry] Automatically setting `sourcemaps.filesToDeleteAfterUpload: ["./**/*.map"]` to delete generated source maps after they were uploaded to Sentry.', - ); - } - resolveFilesToDeleteAfterUpload?.(['./**/*.map']); - } else { - resolveFilesToDeleteAfterUpload?.(userFilesToDelete); + configResolved(config) { + if (sourcemaps?.filesToDeleteAfterUpload !== undefined) { + resolveFilesToDeleteAfterUpload?.(sourcemaps.filesToDeleteAfterUpload); + return; + } + + if (!isVite8OrLater(this.meta.viteVersion)) { + // eslint-disable-next-line no-console + console.warn( + `[Sentry] TanStack Start requires Vite 8 or later. With an older Vite, Sentry deletes the source maps it generated only in \`build.outDir\` of each environment, so source maps in other output directories can stay in the build output.`, + ); + } + + const filesToDelete = getSentryEnabledSourceMapGlobs(config); + if (debug && filesToDelete.length > 0) { + // eslint-disable-next-line no-console + console.log( + `[Sentry] Automatically setting \`sourcemaps.filesToDeleteAfterUpload: ${JSON.stringify(filesToDelete)}\` to delete generated source maps after they were uploaded to Sentry.`, + ); } + resolveFilesToDeleteAfterUpload?.(filesToDelete); }, }; @@ -116,21 +128,72 @@ export function makeAddSentryVitePlugin(options: SentryTanstackStartOptions): Pl return [configPlugin, ...sentryPlugins]; } +/** + * Globs for the source maps in the output directories that only have source maps because Sentry enabled them. A glob + * such as `./**\/*.map` does not enter dot directories like `.output`, where Nitro puts the client build. + */ +function getSentryEnabledSourceMapGlobs(config: ResolvedConfig): string[] { + const outDirs = Object.entries(config.environments) + .filter(([name]) => hasSentryEnabledSourceMaps(config, name)) + .filter(([, environment]) => !hasOutputSourceMapSetting(environment.build)) + .flatMap(([, environment]) => getOutputDirs(config.root, environment.build)); + + return [...new Set(outDirs)].map(outDir => { + const relativeOutDir = path.relative(process.cwd(), outDir).split(path.sep).join('/'); + return `${relativeOutDir || '.'}/**/*.map`; + }); +} + +/** `output.dir` takes precedence over `build.outDir`. Nitro sets only `output.dir` for its server build. */ +function getOutputDirs(root: string, build: ResolvedConfig['environments'][string]['build']): string[] { + // Vite 7 has no `rolldownOptions`. + const outputs = [build.rolldownOptions?.output ?? {}].flat(); + return outputs.map(output => path.resolve(root, output.dir ?? build.outDir)); +} + +/** `output.sourcemap` of the bundler config overrides `build.sourcemap` in the build output. */ +function hasOutputSourceMapSetting(build: Pick | undefined): boolean { + return [build?.rolldownOptions?.output ?? []].flat().some(output => output.sourcemap !== undefined); +} + +/** Vite tells plugins its version since Vite 7. */ +function isVite8OrLater(viteVersion: string | undefined): boolean { + return Number(viteVersion?.split('.')[0]) >= 8; +} + +function hasSentryEnabledSourceMaps(config: ResolvedConfig, environmentName: string): boolean { + return config.plugins.some(plugin => + (plugin.api as Partial | undefined)?.hasSentryEnabledSourceMaps?.(environmentName), + ); +} + /** * A Sentry plugin for TanStack Start React to enable "hidden" source maps if they are unset. */ export function makeEnableSourceMapsVitePlugin(options: SentryTanstackStartOptions): Plugin[] { + let enabledSourceMaps = false; + let environmentsWithSourceMapSetting: string[] = []; + + const api: SourceMapsEnablerApi = { + hasSentryEnabledSourceMaps: environmentName => + enabledSourceMaps && !environmentsWithSourceMapSetting.includes(environmentName), + }; + return [ { name: 'sentry-tanstackstart-react-source-maps', apply: 'build', enforce: 'post', + api, config(viteConfig) { - return { - build: { - sourcemap: getUpdatedSourceMapSettings(viteConfig, options), - }, - }; + const sourcemap = getUpdatedSourceMapSettings(viteConfig, options); + enabledSourceMaps = sourcemap !== viteConfig.build?.sourcemap; + // The setting of an environment overrides the top-level setting, e.g. `nitro/vite` sets it for its environments. + environmentsWithSourceMapSetting = Object.entries(viteConfig.environments ?? {}) + .filter(([, environment]) => environment?.build?.sourcemap !== undefined) + .map(([name]) => name); + + return { build: { sourcemap } }; }, }, ]; @@ -142,19 +205,19 @@ export function makeEnableSourceMapsVitePlugin(options: SentryTanstackStartOptio * - keep this setting (emit a warning that errors won't be unminified in Sentry) * - We won't upload anything * - * 2. Users enabled source map generation (true, 'hidden', 'inline'). + * 2. Users enabled source map generation (true, 'hidden', 'inline') or set `build.rolldownOptions.output.sourcemap`. * - keep this setting (don't do anything - like deletion - besides uploading) * * 3. Users didn't set source maps generation * - we enable 'hidden' source maps generation - * - configure `filesToDeleteAfterUpload` to delete all .map files (we emit a log about this) + * - configure `filesToDeleteAfterUpload` to delete these .map files after the upload (we emit a log about this) * - * --> only exported for testing + * @internal Only exported for testing. */ export function getUpdatedSourceMapSettings( viteConfig: UserConfig, sentryPluginOptions?: SentryTanstackStartOptions, -): boolean | 'inline' | 'hidden' { +): boolean | 'inline' | 'hidden' | undefined { viteConfig.build = viteConfig.build || {}; const viteUserSourceMapSetting = viteConfig.build?.sourcemap; @@ -183,6 +246,15 @@ export function getUpdatedSourceMapSettings( } return viteUserSourceMapSetting; + } else if (hasOutputSourceMapSetting(viteConfig.build)) { + if (debug) { + // eslint-disable-next-line no-console + console.log( + '[Sentry] We discovered `vite.build.rolldownOptions.output.sourcemap`, which overrides `vite.build.sourcemap`. Sentry will keep this source map setting.', + ); + } + + return undefined; } // If the user did not specify a source map setting, we enable 'hidden' by default diff --git a/packages/tanstackstart-react/test/vite/sourceMaps.test.ts b/packages/tanstackstart-react/test/vite/sourceMaps.test.ts index eb4b3740b658..b545a33d72a9 100644 --- a/packages/tanstackstart-react/test/vite/sourceMaps.test.ts +++ b/packages/tanstackstart-react/test/vite/sourceMaps.test.ts @@ -1,6 +1,6 @@ // @vitest-environment node import type { SentryVitePluginOptions } from '@sentry/bundler-plugins/vite'; -import type { UserConfig } from 'vite'; +import type { Plugin, ResolvedConfig, UserConfig } from 'vite'; import { mergeConfig } from 'vite'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import * as path from 'path'; @@ -10,13 +10,19 @@ import { makeEnableSourceMapsVitePlugin, rewriteTanstackStartSource, } from '../../src/vite/sourceMaps'; +import type { SentryTanstackStartOptions } from '../../src/vite/sentryTanstackStart'; const mockedSentryVitePlugin = { name: 'sentry-vite-debug-id-upload-plugin', writeBundle: vi.fn(), }; -const sentryVitePluginSpy = vi.fn((_options: SentryVitePluginOptions) => [mockedSentryVitePlugin]); +let sentryVitePluginOptions: SentryVitePluginOptions | undefined; + +const sentryVitePluginSpy = vi.fn((options: SentryVitePluginOptions) => { + sentryVitePluginOptions = options; + return [mockedSentryVitePlugin]; +}); vi.mock('@sentry/bundler-plugins/vite', () => ({ sentryVitePlugin: (options: SentryVitePluginOptions) => sentryVitePluginSpy(options), @@ -24,6 +30,7 @@ vi.mock('@sentry/bundler-plugins/vite', () => ({ beforeEach(() => { vi.clearAllMocks(); + sentryVitePluginOptions = undefined; }); describe('makeEnableSourceMapsVitePlugin()', () => { @@ -39,6 +46,36 @@ describe('makeEnableSourceMapsVitePlugin()', () => { expect(sourceMapsConfigPlugins).toHaveLength(1); }); + it.each([ + { label: 'the user set no source maps', userConfig: {}, expected: { client: true, ssr: true } }, + { + label: 'the user set `build.sourcemap`', + userConfig: { build: { sourcemap: true } }, + expected: { client: false, ssr: false }, + }, + { + label: 'the user set the source maps of an environment', + userConfig: { environments: { client: { build: { sourcemap: true } } } }, + expected: { client: false, ssr: true }, + }, + { + label: 'the user set `build.rolldownOptions.output.sourcemap`', + userConfig: { build: { rolldownOptions: { output: { sourcemap: true } } } }, + expected: { client: false, ssr: false }, + }, + ] as const)( + 'tells which environments only have source maps because of Sentry when $label', + ({ userConfig, expected }) => { + const plugin = makeEnableSourceMapsVitePlugin({})[0]!; + (plugin.config as (config: UserConfig) => UserConfig)(structuredClone(userConfig) as UserConfig); + + expect({ + client: plugin.api.hasSentryEnabledSourceMaps('client'), + ssr: plugin.api.hasSentryEnabledSourceMaps('ssr'), + }).toEqual(expected); + }, + ); + it.each([undefined, false, true, 'hidden', 'inline'] as const)( 'preserves user config without duplicating arrays when sourcemap is %s', sourcemap => { @@ -97,15 +134,14 @@ describe('makeAddSentryVitePlugin()', () => { }); // The filesToDeleteAfterUpload is now a Promise that resolves when the config hook runs - const calledOptions = sentryVitePluginSpy.mock.calls[0]?.[0]; - expect(calledOptions?.sourcemaps?.filesToDeleteAfterUpload).toBeInstanceOf(Promise); + expect(sentryVitePluginOptions?.sourcemaps?.filesToDeleteAfterUpload).toBeInstanceOf(Promise); - // Trigger the config hook to resolve the Promise + // Trigger the configResolved hook to resolve the Promise const configPlugin = plugins.find(p => p.name === 'sentry-tanstackstart-files-to-delete-after-upload-plugin'); - (configPlugin?.config as (config: UserConfig) => void)({}); + (configPlugin?.configResolved as (config: ResolvedConfig) => void)({} as ResolvedConfig); // Verify the Promise resolves to the user-specified value - const resolvedFilesToDelete = await calledOptions?.sourcemaps?.filesToDeleteAfterUpload; + const resolvedFilesToDelete = await sentryVitePluginOptions?.sourcemaps?.filesToDeleteAfterUpload; expect(resolvedFilesToDelete).toEqual(['baz/*.js']); expect(sentryVitePluginSpy).toHaveBeenCalledWith( @@ -171,89 +207,161 @@ describe('makeAddSentryVitePlugin()', () => { expect(configPlugin).toBeDefined(); expect(configPlugin?.apply).toBe('build'); expect(configPlugin?.enforce).toBe('post'); - expect(typeof configPlugin?.config).toBe('function'); + expect(typeof configPlugin?.configResolved).toBe('function'); }); - it('uses default filesToDeleteAfterUpload when not specified', async () => { - const plugins = makeAddSentryVitePlugin({ - org: 'my-org', - authToken: 'my-token', - }); - - // The filesToDeleteAfterUpload is now a Promise - const calledOptions = sentryVitePluginSpy.mock.calls[0]?.[0]; - expect(calledOptions?.sourcemaps?.filesToDeleteAfterUpload).toBeInstanceOf(Promise); - - // Trigger the config hook to resolve the Promise (with no sourcemap configured) - const configPlugin = plugins.find(p => p.name === 'sentry-tanstackstart-files-to-delete-after-upload-plugin'); - (configPlugin?.config as (config: UserConfig) => void)({}); + describe('default filesToDeleteAfterUpload', () => { + type TestEnvironment = { + consumer: 'client' | 'server'; + outDir: string; + outputDir?: string; + outputSourceMap?: boolean; + }; + + const NITRO_ENVIRONMENTS: Record = { + client: { consumer: 'client', outDir: '.output/public' }, + // Nitro sets the output directory of its server build with `output.dir` only. + nitro: { consumer: 'server', outDir: 'dist', outputDir: '.output/server' }, + }; + + /** A plugin that enabled the source maps of the given environments, like the Sentry plugins that enable them. */ + function sourceMapsEnabler(environmentNames: string[]): Plugin { + return { + name: 'source-maps-enabler', + api: { hasSentryEnabledSourceMaps: (environmentName: string) => environmentNames.includes(environmentName) }, + }; + } - // Verify the Promise resolves to the default value - const resolvedFilesToDelete = await calledOptions?.sourcemaps?.filesToDeleteAfterUpload; - expect(resolvedFilesToDelete).toEqual(['./**/*.map']); - }); + function resolveFilesToDelete( + environments: Record, + { + sentryEnabledEnvironments = Object.keys(environments), + options = {}, + viteVersion = '8.3.1', + }: { sentryEnabledEnvironments?: string[]; options?: SentryTanstackStartOptions; viteVersion?: string } = {}, + ): Promise { + const isVite7 = viteVersion.startsWith('7.'); + const plugins = makeAddSentryVitePlugin(options); + const config = { + root: process.cwd(), + plugins: [...plugins, sourceMapsEnabler(sentryEnabledEnvironments)], + environments: Object.fromEntries( + Object.entries(environments).map(([name, { consumer, outDir, outputDir, outputSourceMap }]) => [ + name, + { + consumer, + build: { + outDir, + ...(!isVite7 && { rolldownOptions: { output: { dir: outputDir, sourcemap: outputSourceMap } } }), + }, + }, + ]), + ), + } as unknown as ResolvedConfig; + + const configPlugin = plugins.find(p => p.name === 'sentry-tanstackstart-files-to-delete-after-upload-plugin'); + (configPlugin?.configResolved as (this: unknown, config: ResolvedConfig) => void).call( + { meta: { viteVersion } }, + config, + ); + + return Promise.resolve(sentryVitePluginOptions?.sourcemaps?.filesToDeleteAfterUpload); + } - it('does not auto-delete when user configured build.sourcemap', async () => { - const plugins = makeAddSentryVitePlugin({ - org: 'my-org', - authToken: 'my-token', + it.each([ + { + label: 'a Nitro build', + environments: NITRO_ENVIRONMENTS, + expected: ['.output/public/**/*.map', '.output/server/**/*.map'], + }, + { + label: 'a build without Nitro', + environments: { + client: { consumer: 'client', outDir: 'dist/client' }, + ssr: { consumer: 'server', outDir: 'dist/server' }, + }, + expected: ['dist/client/**/*.map', 'dist/server/**/*.map'], + }, + { + label: 'environments that share an output directory once', + environments: { + client: { consumer: 'client', outDir: 'dist' }, + ssr: { consumer: 'server', outDir: 'dist' }, + }, + expected: ['dist/**/*.map'], + }, + ] as const)('deletes the source maps in each output directory of $label', async ({ environments, expected }) => { + await expect(resolveFilesToDelete(environments)).resolves.toEqual(expected); }); - // The filesToDeleteAfterUpload is a Promise - const calledOptions = sentryVitePluginSpy.mock.calls[0]?.[0]; - expect(calledOptions?.sourcemaps?.filesToDeleteAfterUpload).toBeInstanceOf(Promise); + it.each([ + { label: 'the client', sentryEnabledEnvironments: ['nitro'], expected: ['.output/server/**/*.map'] }, + { label: 'the Nitro server', sentryEnabledEnvironments: ['client'], expected: ['.output/public/**/*.map'] }, + { label: 'any environment', sentryEnabledEnvironments: [], expected: [] }, + ])( + 'keeps the source maps of $label when Sentry did not enable them', + async ({ sentryEnabledEnvironments, expected }) => { + await expect(resolveFilesToDelete(NITRO_ENVIRONMENTS, { sentryEnabledEnvironments })).resolves.toEqual( + expected, + ); + }, + ); - // Trigger the config hook with sourcemap configured by user - const configPlugin = plugins.find(p => p.name === 'sentry-tanstackstart-files-to-delete-after-upload-plugin'); - (configPlugin?.config as (config: UserConfig) => void)({ build: { sourcemap: true } }); + it.each([true, false])( + 'keeps the source maps of an environment that sets `output.sourcemap: %s`', + async outputSourceMap => { + const environments = { + ...NITRO_ENVIRONMENTS, + client: { ...NITRO_ENVIRONMENTS.client!, outputSourceMap }, + }; - // Verify the Promise resolves to undefined (no auto-delete) - const resolvedFilesToDelete = await calledOptions?.sourcemaps?.filesToDeleteAfterUpload; - expect(resolvedFilesToDelete).toBeUndefined(); - }); + await expect(resolveFilesToDelete(environments)).resolves.toEqual(['.output/server/**/*.map']); + }, + ); - it('logs auto-delete message when user did not configure sourcemap', () => { - const plugins = makeAddSentryVitePlugin({ - org: 'my-org', - authToken: 'my-token', - debug: true, + it('warns and deletes the source maps in `build.outDir` with Vite 7', async () => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const environments = { + client: { consumer: 'client', outDir: 'dist/client' }, + ssr: { consumer: 'server', outDir: 'dist/server' }, + } as const; + + await expect(resolveFilesToDelete(environments, { viteVersion: '7.3.2' })).resolves.toEqual([ + 'dist/client/**/*.map', + 'dist/server/**/*.map', + ]); + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('TanStack Start requires Vite 8 or later.')); + consoleWarnSpy.mockRestore(); }); - const configPlugin = plugins.find(p => p.name === 'sentry-tanstackstart-files-to-delete-after-upload-plugin'); - expect(configPlugin).toBeDefined(); + it('does not warn with Vite 8', async () => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + await resolveFilesToDelete(NITRO_ENVIRONMENTS); - // Simulate config hook with no sourcemap configured - if (configPlugin?.config) { - (configPlugin.config as (config: UserConfig) => void)({}); - } + expect(consoleWarnSpy).not.toHaveBeenCalled(); + consoleWarnSpy.mockRestore(); + }); - expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Automatically setting')); + it('logs the default in debug mode', async () => { + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - consoleSpy.mockRestore(); - }); + await resolveFilesToDelete(NITRO_ENVIRONMENTS, { options: { debug: true } }); - it('does not log auto-delete message when user configured sourcemap', () => { - const plugins = makeAddSentryVitePlugin({ - org: 'my-org', - authToken: 'my-token', - debug: true, + expect(consoleSpy).toHaveBeenCalledWith( + '[Sentry] Automatically setting `sourcemaps.filesToDeleteAfterUpload: [".output/public/**/*.map",".output/server/**/*.map"]` to delete generated source maps after they were uploaded to Sentry.', + ); + consoleSpy.mockRestore(); }); - const configPlugin = plugins.find(p => p.name === 'sentry-tanstackstart-files-to-delete-after-upload-plugin'); - expect(configPlugin).toBeDefined(); + it('does not log the default when Sentry enabled no source maps', async () => { + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + await resolveFilesToDelete(NITRO_ENVIRONMENTS, { sentryEnabledEnvironments: [], options: { debug: true } }); - // Simulate config hook with sourcemap configured - if (configPlugin?.config) { - (configPlugin.config as (config: UserConfig) => void)({ build: { sourcemap: true } }); - } - - expect(consoleSpy).not.toHaveBeenCalledWith(expect.stringContaining('Automatically setting')); - - consoleSpy.mockRestore(); + expect(consoleSpy).not.toHaveBeenCalledWith(expect.stringContaining('Automatically setting')); + consoleSpy.mockRestore(); + }); }); it('passes rewriteSources to the vite plugin', () => { @@ -359,6 +467,22 @@ describe('getUpdatedSourceMapSettings', () => { expect(result).toBe(expected); }); + it.each([true, false, 'hidden'] as const)( + 'should keep `build.sourcemap` unset when `build.rolldownOptions.output.sourcemap` is %s', + sourcemap => { + const result = getUpdatedSourceMapSettings( + { build: { rolldownOptions: { output: { sourcemap } } } }, + { debug: true }, + ); + + expect(result).toBeUndefined(); + // eslint-disable-next-line no-console + expect(console.log).toHaveBeenCalledWith( + '[Sentry] We discovered `vite.build.rolldownOptions.output.sourcemap`, which overrides `vite.build.sourcemap`. Sentry will keep this source map setting.', + ); + }, + ); + it('should set sourcemap to hidden when not configured', () => { const result = getUpdatedSourceMapSettings({}); From a8059fa8ad83484e15a875ba3ad03aa588f1bf8a Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Fri, 16 Oct 2026 13:35:08 +0200 Subject: [PATCH 3/3] fix(tanstackstart-react): Log when Sentry deletes no source maps In debug mode, log that no source maps are deleted after the upload when Sentry did not enable source map generation for the build. Before, debug mode logged nothing in this case. --- packages/tanstackstart-react/src/vite/sourceMaps.ts | 6 ++++-- packages/tanstackstart-react/test/vite/sourceMaps.test.ts | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/tanstackstart-react/src/vite/sourceMaps.ts b/packages/tanstackstart-react/src/vite/sourceMaps.ts index 0da8126574cc..f84c52692899 100644 --- a/packages/tanstackstart-react/src/vite/sourceMaps.ts +++ b/packages/tanstackstart-react/src/vite/sourceMaps.ts @@ -82,10 +82,12 @@ export function makeAddSentryVitePlugin(options: SentryTanstackStartOptions): Pl } const filesToDelete = getSentryEnabledSourceMapGlobs(config); - if (debug && filesToDelete.length > 0) { + if (debug) { // eslint-disable-next-line no-console console.log( - `[Sentry] Automatically setting \`sourcemaps.filesToDeleteAfterUpload: ${JSON.stringify(filesToDelete)}\` to delete generated source maps after they were uploaded to Sentry.`, + filesToDelete.length > 0 + ? `[Sentry] Automatically setting \`sourcemaps.filesToDeleteAfterUpload: ${JSON.stringify(filesToDelete)}\` to delete generated source maps after they were uploaded to Sentry.` + : '[Sentry] No source maps are deleted after the upload, because Sentry did not enable source map generation for this build.', ); } resolveFilesToDeleteAfterUpload?.(filesToDelete); diff --git a/packages/tanstackstart-react/test/vite/sourceMaps.test.ts b/packages/tanstackstart-react/test/vite/sourceMaps.test.ts index b545a33d72a9..0f41d8d261ef 100644 --- a/packages/tanstackstart-react/test/vite/sourceMaps.test.ts +++ b/packages/tanstackstart-react/test/vite/sourceMaps.test.ts @@ -354,12 +354,14 @@ describe('makeAddSentryVitePlugin()', () => { consoleSpy.mockRestore(); }); - it('does not log the default when Sentry enabled no source maps', async () => { + it('logs that it deletes no source maps when Sentry enabled none', async () => { const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); await resolveFilesToDelete(NITRO_ENVIRONMENTS, { sentryEnabledEnvironments: [], options: { debug: true } }); - expect(consoleSpy).not.toHaveBeenCalledWith(expect.stringContaining('Automatically setting')); + expect(consoleSpy).toHaveBeenCalledWith( + '[Sentry] No source maps are deleted after the upload, because Sentry did not enable source map generation for this build.', + ); consoleSpy.mockRestore(); }); });