From 560f3721d7c1812570c5e4dd577069cd2860f14f Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Thu, 12 Nov 2026 12:01:07 +0000 Subject: [PATCH] feat(tanstackstart-react): Delete source maps through the bundler plugin instead of globs The SDK turns on hidden source maps for the client build and for Nitro's server build. It now asks the bundler plugin to remove them through `deleteSourcemapsAfterUpload` and `sdkEnabledSourceMaps`, which receive the Vite environment, instead of building `filesToDeleteAfterUpload` globs from the resolved output directories. As before, the maps are also removed when no upload runs. The maps of the Nitro `ssr` service stay, because the SDK's own Nitro plugin reads them after the build. A user's `filesToDeleteAfterUpload` is passed through and runs as well. Maps the user turned on with `build.sourcemap` or Nitro's options are kept. Maps of an output with its own `sourcemap` are removed when the SDK turned on source maps for its environment. --- .../tanstackstart-react-sourcemaps/.gitignore | 1 + .../assert-build.ts | 30 +- .../assert-no-upload.ts | 16 ++ .../package.json | 13 +- .../vite.config.ts | 26 +- .../tanstackstart-react/vite.config.ts | 22 +- .../src/vite/nitroSourceMaps.ts | 32 +-- .../src/vite/sentryTanstackStart.ts | 21 +- .../src/vite/sourceMaps.ts | 143 +++------- .../test/vite/nitroSourceMaps.test.ts | 31 +- .../test/vite/sentryTanstackStart.test.ts | 26 +- .../test/vite/sourceMapScenarios.test.ts | 169 +++++++---- .../test/vite/sourceMaps.test.ts | 267 ++++++------------ 13 files changed, 364 insertions(+), 433 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/assert-no-upload.ts diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/.gitignore b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/.gitignore index 4187ae735082..634fe9c9c6d9 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/.gitignore +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/.gitignore @@ -9,3 +9,4 @@ src/routeTree.gen.ts .tmp_chunks .tmp_build_stdout .tmp_build_stderr +.tmp_debug_ids_without_source_map diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/assert-build.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/assert-build.ts index b0070359e46f..1abc1b7fc93c 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/assert-build.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/assert-build.ts @@ -24,12 +24,6 @@ const SERVER_OUTPUT = path.join('.output', 'server'); const CLIENT_MARKER = 'SOURCEMAP_MARKER_CLIENT'; const SERVER_MARKER = 'SOURCEMAP_MARKER_SERVER'; -const DEBUG_ID_PATTERN = /sentry-dbid-([\da-f-]{36})/gi; - -function readDebugIds(file: string): string[] { - return [...fs.readFileSync(file, 'utf8').matchAll(DEBUG_ID_PATTERN)].map(match => (match[1] as string).toLowerCase()); -} - function emittedJsFiles(dir: string): string[] { return fs .readdirSync(dir, { recursive: true, withFileTypes: true }) @@ -37,22 +31,13 @@ function emittedJsFiles(dir: string): string[] { .map(entry => path.join(entry.parentPath, entry.name)); } -/** In debug mode, the Sentry plugin logs each file it deletes after the upload. */ -const deletedFiles = new Set( - [...fs.readFileSync('.tmp_build_stdout', 'utf8').matchAll(/Deleting asset after upload: (.+)$/gm)].map(match => - path.relative(fs.realpathSync('.'), match[1] as string), - ), +/** Written by the build, see `recordChunksWithoutSourceMap` in `vite.config.ts`. */ +const debugIdsWithoutSourceMap = new Set( + fs.existsSync('.tmp_debug_ids_without_source_map') + ? fs.readFileSync('.tmp_debug_ids_without_source_map', 'utf8').split('\n').filter(Boolean) + : [], ); -/** A chunk without mappable source, such as Nitro's runtime, gets a debug ID but no source map to upload. */ -function debugIdsOfChunksWithoutSourceMap(dir: string): Set { - return new Set( - emittedJsFiles(dir) - .filter(file => !fs.existsSync(`${file}.map`) && !deletedFiles.has(`${file}.map`)) - .flatMap(readDebugIds), - ); -} - function sourceMapsIn(dir: string): string[] { return fs .readdirSync(dir, { recursive: true, withFileTypes: true }) @@ -116,8 +101,9 @@ for (const outputDir of [CLIENT_OUTPUT, SERVER_OUTPUT]) { const injectedDebugIds = findInjectedDebugIds({ outputDir }); assert.ok(injectedDebugIds.length > 0, `Expected debug IDs to be injected into ${outputDir}`); - const exemptDebugIds = debugIdsOfChunksWithoutSourceMap(outputDir); - const unuploaded = injectedDebugIds.filter(debugId => !uploadedDebugIds.has(debugId) && !exemptDebugIds.has(debugId)); + const unuploaded = injectedDebugIds.filter( + debugId => !uploadedDebugIds.has(debugId) && !debugIdsWithoutSourceMap.has(debugId), + ); assert.deepEqual(unuploaded, [], `Expected every debug ID in ${outputDir} to have an uploaded source map`); // A file of Nitro's output can carry the debug ID of the SSR chunk it was built from. diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/assert-no-upload.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/assert-no-upload.ts new file mode 100644 index 000000000000..78aff6a1288d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/assert-no-upload.ts @@ -0,0 +1,16 @@ +import * as assert from 'assert/strict'; +import * as path from 'path'; +import { findSourceMapFiles, findSourceMappingUrlComments } from '@sentry-internal/test-utils'; + +// Without an auth token nothing is uploaded, but the SDK still asks the bundler plugin to remove the +// source maps it turned on for the client and the Nitro server. +for (const outputDir of [path.join('.output', 'public'), path.join('.output', 'server')]) { + assert.deepEqual(findSourceMapFiles({ outputDir }), [], `Expected no source maps in ${outputDir}`); + assert.deepEqual( + findSourceMappingUrlComments({ outputDir }), + [], + `Expected no sourceMappingURL comments in ${outputDir}`, + ); +} + +console.log('All source map assertions passed!'); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/package.json b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/package.json index 1dd5050c0372..6e3c471f0441 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/package.json +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/package.json @@ -7,7 +7,9 @@ "build": "node start-mock-sentry-server.mjs & vite build > .tmp_build_stdout 2> .tmp_build_stderr; BUILD_EXIT=$?; kill %1 2>/dev/null; if [ $BUILD_EXIT -ne 0 ]; then cat .tmp_build_stdout; cat .tmp_build_stderr >&2; fi; exit $BUILD_EXIT", "clean": "npx rimraf node_modules pnpm-lock.yaml .output", "test:build": "pnpm install && pnpm build", - "test:assert": "pnpm ts-node --script-mode assert-build.ts" + "test:build:no-upload": "pnpm install && E2E_NO_UPLOAD=1 pnpm build", + "test:assert": "pnpm ts-node --script-mode assert-build.ts", + "test:assert:no-upload": "pnpm ts-node --script-mode assert-no-upload.ts" }, "dependencies": { "@sentry/tanstackstart-react": "file:../../packed/sentry-tanstackstart-react-packed.tgz", @@ -29,6 +31,13 @@ "vite": "^8.3.0" }, "sentryTest": { - "optional": true + "optional": true, + "optionalVariants": [ + { + "build-command": "pnpm test:build:no-upload", + "assert-command": "pnpm test:assert:no-upload", + "label": "tanstackstart-react-sourcemaps (no upload)" + } + ] } } diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/vite.config.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/vite.config.ts index b43b2441b453..48eac51727b6 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/vite.config.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react-sourcemaps/vite.config.ts @@ -2,16 +2,40 @@ import { sentryTanstackStart } from '@sentry/tanstackstart-react/vite'; import { tanstackStart } from '@tanstack/react-start/plugin/vite'; import viteReact from '@vitejs/plugin-react-swc'; import { nitro } from 'nitro/vite'; +import * as fs from 'node:fs'; import { defineConfig } from 'vite'; +// A chunk without mappable source, such as Nitro's runtime, gets a debug ID but no source map to +// upload. Recorded before the Sentry plugin removes the maps in its own `generateBundle` hook. +function recordChunksWithoutSourceMap() { + return { + name: 'record-chunks-without-source-map', + apply: 'build' as const, + generateBundle: { + order: 'pre' as const, + handler(_options: unknown, bundle: Record) { + for (const output of Object.values(bundle)) { + if (output.type !== 'chunk' || !output.code || bundle[`${output.fileName}.map`]) { + continue; + } + for (const match of output.code.matchAll(/sentry-dbid-([\da-f-]{36})/gi)) { + fs.appendFileSync('.tmp_debug_ids_without_source_map', `${match[1]!.toLowerCase()}\n`); + } + } + }, + }, + }; +} + export default defineConfig({ plugins: [ tanstackStart(), nitro(), viteReact(), + recordChunksWithoutSourceMap(), sentryTanstackStart({ sentryUrl: 'http://localhost:3032', - authToken: 'fake-auth-token', + authToken: process.env.E2E_NO_UPLOAD ? undefined : 'fake-auth-token', org: 'test-org', project: 'test-project', release: { name: 'test-release' }, diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/vite.config.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/vite.config.ts index 9883b1add4b3..7a9ee28363f6 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/vite.config.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/vite.config.ts @@ -7,21 +7,25 @@ import { nitro } from 'nitro/vite'; import { sentryTanstackStart } from '@sentry/tanstackstart-react/vite'; // Rolldown emits no map for chunks without mappable source (e.g. Vite's preload helper), so their debug -// IDs can't be uploaded. Recorded here because the SDK deletes all maps after the upload. +// IDs can't be uploaded. Recorded here because the Sentry plugin removes the maps from the bundle in its +// own `generateBundle` hook. function recordChunksWithoutSourceMap() { return { name: 'record-chunks-without-source-map', apply: 'build' as const, enforce: 'post' as const, - writeBundle(_options: unknown, bundle: Record) { - for (const output of Object.values(bundle)) { - if (output.type !== 'chunk' || !output.code || bundle[`${output.fileName}.map`]) { - continue; + generateBundle: { + order: 'pre' as const, + handler(_options: unknown, bundle: Record) { + for (const output of Object.values(bundle)) { + if (output.type !== 'chunk' || !output.code || bundle[`${output.fileName}.map`]) { + continue; + } + for (const match of output.code.matchAll(/sentry-dbid-([\da-f-]{36})/gi)) { + fs.appendFileSync('.tmp_debug_ids_without_source_map', `${match[1]!.toLowerCase()}\n`); + } } - for (const match of output.code.matchAll(/sentry-dbid-([\da-f-]{36})/gi)) { - fs.appendFileSync('.tmp_debug_ids_without_source_map', `${match[1]!.toLowerCase()}\n`); - } - } + }, }, }; } diff --git a/packages/tanstackstart-react/src/vite/nitroSourceMaps.ts b/packages/tanstackstart-react/src/vite/nitroSourceMaps.ts index 478c426f9ecb..8d64f130928b 100644 --- a/packages/tanstackstart-react/src/vite/nitroSourceMaps.ts +++ b/packages/tanstackstart-react/src/vite/nitroSourceMaps.ts @@ -1,9 +1,10 @@ import { readFile } from 'node:fs/promises'; import * as path from 'node:path'; import type { BuildEnvironmentOptions, Plugin, ResolvedConfig } from 'vite'; +import type { SourceMapState } from './sourceMaps'; /** `nitro/vite` builds the final server in an environment with this name. */ -const NITRO_ENVIRONMENT = 'nitro'; +export const NITRO_ENVIRONMENT = 'nitro'; /** The environment Vite passes to `applyToEnvironment`. */ export type PluginEnvironment = Parameters>[0]; @@ -27,12 +28,6 @@ type Nitro = { }; }; -/** - * Tells the plugin that deletes source maps after the upload whether the source maps of an environment are only - * there because Sentry enabled them. - */ -export type SourceMapsEnablerApi = { hasSentryEnabledSourceMaps(environmentName: string): boolean }; - /** * Whether Nitro bundles the output of this environment again. With `nitro/vite`, every server environment besides * `nitro` is a Nitro service, such as TanStack Start's `ssr` environment. @@ -49,11 +44,12 @@ export function isNitroService(environment: PluginEnvironment): boolean { * Keeps the sources in the source maps of Nitro's server build. Enables these source maps, unless a Nitro config * source or `build.rolldownOptions.output.sourcemap` of the Vite config sets them, or the Vite config disables them. */ -export function makeEnableNitroSourceMapsPlugin({ debug }: { debug?: boolean }): Plugin & { - api: SourceMapsEnablerApi; +export function makeEnableNitroSourceMapsPlugin( + { debug }: { debug?: boolean }, + state: SourceMapState, +): Plugin & { nitro: { name: string; setup: (nitro: Nitro) => void }; } { - let enabledSourceMaps = false; let viteConfigPreventsSourceMaps = false; return { @@ -61,10 +57,8 @@ export function makeEnableNitroSourceMapsPlugin({ debug }: { debug?: boolean }): apply: 'build', // The `config` hook must run before the one of `nitro/vite`, which runs the Nitro modules. enforce: 'pre', - api: { - hasSentryEnabledSourceMaps: environmentName => enabledSourceMaps && environmentName === NITRO_ENVIRONMENT, - }, config(viteConfig) { + state.sdkEnabledNitroSourceMaps = false; // Nitro overrides `build.sourcemap` for its environments, but `false` shows that the user wants no source maps. viteConfigPreventsSourceMaps = viteConfig.build?.sourcemap === false || hasOutputSourceMapSetting(viteConfig.build); @@ -84,7 +78,7 @@ export function makeEnableNitroSourceMapsPlugin({ debug }: { debug?: boolean }): } nitro.options.sourcemap = 'hidden'; - enabledSourceMaps = true; + state.sdkEnabledNitroSourceMaps = true; logDebug(debug, "Enabled source map generation for the Nitro server build with `nitro.sourcemap: 'hidden'`."); }, }, @@ -118,7 +112,7 @@ export function makeComposeNitroServiceSourceMapsPlugin(): Plugin { } /** The output directories of the environments that Nitro bundles again. Empty in a build without Nitro. */ -export function getNitroServiceOutDirs(config: ResolvedConfig): string[] { +function getNitroServiceOutDirs(config: ResolvedConfig): string[] { if (!(NITRO_ENVIRONMENT in config.environments)) { return []; } @@ -140,9 +134,13 @@ function isInDirectory(file: string, directory: string): boolean { /** `output.sourcemap` of the bundler config overrides `build.sourcemap` in the build output. */ export function hasOutputSourceMapSetting( - build: Pick | undefined, + build: Pick | undefined, ): boolean { - return [build?.rolldownOptions?.output ?? []].flat().some(output => output.sourcemap !== undefined); + // Vite 8 still accepts `rollupOptions` in the user config. + // oxlint-disable-next-line typescript/no-deprecated + return [build?.rolldownOptions?.output ?? [], build?.rollupOptions?.output ?? []] + .flat() + .some(output => output.sourcemap !== undefined); } /** In a Nitro config, `output.sourcemap` of the bundler config overrides `sourcemap` in the build output. */ diff --git a/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts b/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts index d542af5be045..1434712beaf0 100644 --- a/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts +++ b/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts @@ -5,7 +5,7 @@ import { makeAutoInstrumentMiddlewarePlugin } from './autoInstrumentMiddleware'; import { makeComposeNitroServiceSourceMapsPlugin, makeEnableNitroSourceMapsPlugin } from './nitroSourceMaps'; import { makeRoutePatternPlugin } from './routePatterns'; import { makeServerInstrumentationPlugin } from './serverInstrumentation'; -import { makeAddSentryVitePlugin, makeEnableSourceMapsVitePlugin } from './sourceMaps'; +import { createSourceMapState, makeAddSentryVitePlugin, makeEnableSourceMapsVitePlugin } from './sourceMaps'; import type { TunnelRouteOptions } from './tunnelRoute'; import { makeServerEntryTunnelPlugin, makeTunnelBasePathPlugin, makeTunnelRoutePlugin } from './tunnelRoute'; @@ -115,18 +115,23 @@ export function sentryTanstackStart(options: SentryTanstackStartOptions = {}): P // (CommonJS) deps via `ssr.noExternal`, which the `vite dev` SSR module runner can't evaluate. plugins.push(sentryOrchestrionPlugin({ buildTimeInstrumentation: options.buildTimeInstrumentation })); - plugins.push(...makeAddSentryVitePlugin(options), makeComposeNitroServiceSourceMapsPlugin()); + // The bundler plugin decides on the source map setting in its own `config` hook, so the plugins that enable source + // maps come first. + const sourceMapState = createSourceMapState(); + const sourceMapsDisabled = options.sourcemaps?.disable === true || options.sourcemaps?.disable === 'disable-upload'; + if (!sourceMapsDisabled) { + plugins.push( + ...makeEnableSourceMapsVitePlugin(options, sourceMapState), + makeEnableNitroSourceMapsPlugin(options, sourceMapState), + ); + } + + plugins.push(...makeAddSentryVitePlugin(options, sourceMapState), makeComposeNitroServiceSourceMapsPlugin()); // middleware auto-instrumentation if (options.autoInstrumentMiddleware !== false) { plugins.push(makeAutoInstrumentMiddlewarePlugin({ enabled: true, debug: options.debug })); } - // source maps - const sourceMapsDisabled = options.sourcemaps?.disable === true || options.sourcemaps?.disable === 'disable-upload'; - if (!sourceMapsDisabled) { - plugins.push(...makeEnableSourceMapsVitePlugin(options), makeEnableNitroSourceMapsPlugin(options)); - } - return plugins; } diff --git a/packages/tanstackstart-react/src/vite/sourceMaps.ts b/packages/tanstackstart-react/src/vite/sourceMaps.ts index c263011142ae..e6376c68bc63 100644 --- a/packages/tanstackstart-react/src/vite/sourceMaps.ts +++ b/packages/tanstackstart-react/src/vite/sourceMaps.ts @@ -1,17 +1,14 @@ import { sentryVitePlugin } from '@sentry/bundler-plugins/vite'; import * as path from 'node:path'; -import type { Plugin, ResolvedConfig, UserConfig } from 'vite'; +import type { Plugin, UserConfig } from 'vite'; import { - getNitroServiceOutDirs, hasOutputSourceMapSetting, isNitroService, + NITRO_ENVIRONMENT, type PluginEnvironment, - type SourceMapsEnablerApi, } from './nitroSourceMaps'; 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]-[^/]*$/; @@ -37,10 +34,37 @@ export function rewriteTanstackStartSource(source: string, _map: unknown, contex return path.relative(process.cwd(), absolutePath).split(path.sep).join('/'); } +/** Shared by the source map plugins of one build. */ +export type SourceMapState = { + /** The SDK set the top-level `build.sourcemap` to `'hidden'`. */ + sdkEnabledSourceMaps: boolean; + /** The SDK set the `sourcemap` option of Nitro to `'hidden'`. */ + sdkEnabledNitroSourceMaps: boolean; +}; + +export function createSourceMapState(): SourceMapState { + return { sdkEnabledSourceMaps: false, sdkEnabledNitroSourceMaps: false }; +} + +/** + * Whether the SDK turned on the source maps that the bundler plugin decides on. The plugin passes the name of an + * environment with its own `build.sourcemap`, and `undefined` for the top-level setting. With `nitro/vite`, the `ssr` + * service has its own setting too: Nitro reads its maps after the build, so they stay. + */ +function hasSdkEnabledSourceMaps(state: SourceMapState, environment: string | undefined): boolean { + if (environment === undefined) { + return state.sdkEnabledSourceMaps; + } + return environment === NITRO_ENVIRONMENT && state.sdkEnabledNitroSourceMaps; +} + /** * A Sentry plugin for adding the @sentry/bundler-plugins/vite to automatically upload source maps to Sentry. */ -export function makeAddSentryVitePlugin(options: SentryTanstackStartOptions): Plugin[] { +export function makeAddSentryVitePlugin( + options: SentryTanstackStartOptions, + state: SourceMapState = createSourceMapState(), +): Plugin[] { const { applicationKey, authToken, @@ -59,42 +83,6 @@ export function makeAddSentryVitePlugin(options: SentryTanstackStartOptions): Pl reactComponentAnnotation, } = options; - // defer resolving the filesToDeleteAfterUpload until we got access to the Vite config - let resolveFilesToDeleteAfterUpload: ((value: FilesToDeleteAfterUpload) => void) | undefined; - const filesToDeleteAfterUploadPromise = new Promise(resolve => { - resolveFilesToDeleteAfterUpload = resolve; - }); - - const configPlugin: Plugin = { - name: 'sentry-tanstackstart-files-to-delete-after-upload-plugin', - apply: 'build', - enforce: 'post', - 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) { - // eslint-disable-next-line no-console - console.log( - 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); - }, - }; - const sentryPlugins = sentryVitePlugin({ applicationKey, authToken: authToken ?? process.env.SENTRY_AUTH_TOKEN, @@ -113,7 +101,11 @@ export function makeAddSentryVitePlugin(options: SentryTanstackStartOptions): Pl ignore: sourcemaps?.ignore, rewriteSources: sourcemaps?.rewriteSources ?? rewriteTanstackStartSource, resolveSourceMap: sourcemaps?.resolveSourceMap, - filesToDeleteAfterUpload: filesToDeleteAfterUploadPromise, + filesToDeleteAfterUpload: sourcemaps?.filesToDeleteAfterUpload, + // Called when the bundler plugin decides, after the SDK's enable plugins ran. + deleteSourcemapsAfterUpload: environment => + sourcemaps?.deleteSourcemapsAfterUpload ?? (hasSdkEnabledSourceMaps(state, environment) || undefined), + sdkEnabledSourceMaps: environment => hasSdkEnabledSourceMaps(state, environment), }, reactComponentAnnotation: { enabled: reactComponentAnnotation?.enabled ?? undefined, @@ -130,76 +122,27 @@ export function makeAddSentryVitePlugin(options: SentryTanstackStartOptions): Pl // Nitro bundles the output of its services again. Debug IDs from a service would end up in Nitro's output and point // to the service's source maps instead of Nitro's. - const scopedSentryPlugins = sentryPlugins.map(plugin => ({ + return sentryPlugins.map(plugin => ({ ...plugin, applyToEnvironment: (environment: PluginEnvironment) => !isNitroService(environment), })); - - return [configPlugin, ...scopedSentryPlugins]; -} - -/** - * 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 serviceOutDirs = getNitroServiceOutDirs(config); - const outDirs = Object.entries(config.environments) - .filter(([name]) => hasSentryEnabledSourceMaps(config, name)) - .filter(([, environment]) => !hasOutputSourceMapSetting(environment.build)) - .flatMap(([, environment]) => getOutputDirs(config.root, environment.build)) - .filter(outDir => !serviceOutDirs.includes(outDir)); - - 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)); -} - -/** 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), - }; - +export function makeEnableSourceMapsVitePlugin( + options: SentryTanstackStartOptions, + state: SourceMapState = createSourceMapState(), +): Plugin[] { return [ { name: 'sentry-tanstackstart-react-source-maps', apply: 'build', enforce: 'post', - api, config(viteConfig) { 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); - + state.sdkEnabledSourceMaps = sourcemap !== viteConfig.build?.sourcemap; return { build: { sourcemap } }; }, }, @@ -217,7 +160,7 @@ export function makeEnableSourceMapsVitePlugin(options: SentryTanstackStartOptio * * 3. Users didn't set source maps generation * - we enable 'hidden' source maps generation - * - configure `filesToDeleteAfterUpload` to delete these .map files after the upload (we emit a log about this) + * - pass `deleteSourcemapsAfterUpload: true`, so the bundler plugin removes these .map files from the build output * * @internal Only exported for testing. */ @@ -268,7 +211,7 @@ export function getUpdatedSourceMapSettings( if (debug) { // eslint-disable-next-line no-console console.log( - `[Sentry] Enabled source map generation in the build options with \`${settingKey}: 'hidden'\`. The source maps will be deleted after they were uploaded to Sentry.`, + `[Sentry] Enabled source map generation in the build options with \`${settingKey}: 'hidden'\`. The source maps will be removed from the build output.`, ); } diff --git a/packages/tanstackstart-react/test/vite/nitroSourceMaps.test.ts b/packages/tanstackstart-react/test/vite/nitroSourceMaps.test.ts index c3deb2e93e57..edd57132fefc 100644 --- a/packages/tanstackstart-react/test/vite/nitroSourceMaps.test.ts +++ b/packages/tanstackstart-react/test/vite/nitroSourceMaps.test.ts @@ -10,6 +10,7 @@ import { makeEnableNitroSourceMapsPlugin, type PluginEnvironment, } from '../../src/vite/nitroSourceMaps'; +import { createSourceMapState } from '../../src/vite/sourceMaps'; const SSR_OUT_DIR = 'node_modules/.nitro/vite/services/ssr'; @@ -69,31 +70,31 @@ describe('Nitro source map config', () => { layers: NitroConfig[] | undefined, { debug, viteConfig = {} }: { debug?: boolean; viteConfig?: UserConfig } = {}, ) { - const plugin = makeEnableNitroSourceMapsPlugin({ debug }); + const state = createSourceMapState(); + const plugin = makeEnableNitroSourceMapsPlugin({ debug }, state); (plugin.config as (config: UserConfig) => void)(viteConfig); const nitro = { options: { sourcemap: false, experimental: {}, _c12: layers && { layers: layers.map(config => ({ config })) } }, }; plugin.nitro.setup(nitro); - return { plugin, nitroOptions: { sourcemap: nitro.options.sourcemap, experimental: nitro.options.experimental } }; + return { state, nitroOptions: { sourcemap: nitro.options.sourcemap, experimental: nitro.options.experimental } }; } it('enables hidden source maps with their sources when no config source sets them', () => { - const { plugin, nitroOptions } = setup([{}, {}]); + const { state, nitroOptions } = setup([{}, {}]); expect(nitroOptions).toEqual({ sourcemap: 'hidden', experimental: { sourcemapMinify: false } }); - expect(plugin.api.hasSentryEnabledSourceMaps('nitro')).toBe(true); - expect(plugin.api.hasSentryEnabledSourceMaps('client')).toBe(false); + expect(state.sdkEnabledNitroSourceMaps).toBe(true); }); it.each([false, true, 'hidden'])( 'keeps the setting `sourcemap: %s` of a config source, with the sources', sourcemap => { - const { plugin, nitroOptions } = setup([{}, { sourcemap }]); + const { state, nitroOptions } = setup([{}, { sourcemap }]); expect(nitroOptions).toEqual({ sourcemap: false, experimental: { sourcemapMinify: false } }); - expect(plugin.api.hasSentryEnabledSourceMaps('nitro')).toBe(false); + expect(state.sdkEnabledNitroSourceMaps).toBe(false); }, ); @@ -101,34 +102,34 @@ describe('Nitro source map config', () => { { label: '`rollupConfig.output.sourcemap`', layer: { rollupConfig: { output: { sourcemap: true } } } }, { label: '`rolldownConfig.output.sourcemap`', layer: { rolldownConfig: { output: { sourcemap: false } } } }, ])('keeps the source maps when a config source sets $label', ({ layer }) => { - const { plugin, nitroOptions } = setup([{}, layer]); + const { state, nitroOptions } = setup([{}, layer]); expect(nitroOptions).toEqual({ sourcemap: false, experimental: { sourcemapMinify: false } }); - expect(plugin.api.hasSentryEnabledSourceMaps('nitro')).toBe(false); + expect(state.sdkEnabledNitroSourceMaps).toBe(false); }); it.each([ { label: '`build.sourcemap: false`', build: { sourcemap: false } }, { label: '`build.rolldownOptions.output.sourcemap`', build: { rolldownOptions: { output: { sourcemap: true } } } }, ])('keeps the source maps when the Vite config sets $label', ({ build }) => { - const { plugin, nitroOptions } = setup([{}], { viteConfig: { build } }); + const { state, nitroOptions } = setup([{}], { viteConfig: { build } }); expect(nitroOptions).toEqual({ sourcemap: false, experimental: { sourcemapMinify: false } }); - expect(plugin.api.hasSentryEnabledSourceMaps('nitro')).toBe(false); + expect(state.sdkEnabledNitroSourceMaps).toBe(false); }); it('enables source maps when the Vite config sets `build.sourcemap: true`, which Nitro overrides', () => { - const { plugin, nitroOptions } = setup([{}], { viteConfig: { build: { sourcemap: true } } }); + const { state, nitroOptions } = setup([{}], { viteConfig: { build: { sourcemap: true } } }); expect(nitroOptions).toEqual({ sourcemap: 'hidden', experimental: { sourcemapMinify: false } }); - expect(plugin.api.hasSentryEnabledSourceMaps('nitro')).toBe(true); + expect(state.sdkEnabledNitroSourceMaps).toBe(true); }); it('does not enable source maps when Nitro does not expose its config sources', () => { - const { plugin, nitroOptions } = setup(undefined); + const { state, nitroOptions } = setup(undefined); expect(nitroOptions).toEqual({ sourcemap: false, experimental: { sourcemapMinify: false } }); - expect(plugin.api.hasSentryEnabledSourceMaps('nitro')).toBe(false); + expect(state.sdkEnabledNitroSourceMaps).toBe(false); }); it('logs that it enabled source maps in debug mode', () => { diff --git a/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts b/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts index 60091f7d93d4..cb9225b326f3 100644 --- a/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts +++ b/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts @@ -5,13 +5,6 @@ import { sentryTanstackStart, type SentryTanstackStartOptions } from '../../src/ import { makeServerInstrumentationPlugin } from '../../src/vite/serverInstrumentation'; import { makeServerEntryTunnelPlugin, makeTunnelRoutePlugin } from '../../src/vite/tunnelRoute'; -const mockSourceMapsConfigPlugin: Plugin = { - name: 'sentry-tanstackstart-files-to-delete-after-upload-plugin', - apply: 'build', - enforce: 'pre', - config: vi.fn(), -}; - const mockSentryVitePlugin: Plugin = { name: 'sentry-vite-debug-id-upload-plugin', writeBundle: vi.fn(), @@ -84,7 +77,8 @@ vi.mock('../../src/vite/routePatterns', () => ({ })); vi.mock('../../src/vite/sourceMaps', () => ({ - makeAddSentryVitePlugin: vi.fn(() => [mockSourceMapsConfigPlugin, mockSentryVitePlugin]), + createSourceMapState: vi.fn(() => ({ sdkEnabledSourceMaps: false, sdkEnabledNitroSourceMaps: false })), + makeAddSentryVitePlugin: vi.fn(() => [mockSentryVitePlugin]), makeEnableSourceMapsVitePlugin: vi.fn(() => [mockEnableSourceMapsPlugin]), })); @@ -127,11 +121,10 @@ describe('sentryTanstackStart()', () => { mockServerEntryTunnelPlugin, mockServerInstrumentationPlugin, mockOrchestrionPlugin, - mockSourceMapsConfigPlugin, - mockSentryVitePlugin, - mockComposeNitroServiceSourceMapsPlugin, mockEnableSourceMapsPlugin, mockEnableNitroSourceMapsPlugin, + mockSentryVitePlugin, + mockComposeNitroServiceSourceMapsPlugin, ]); }); @@ -171,7 +164,6 @@ describe('sentryTanstackStart()', () => { mockServerEntryTunnelPlugin, mockServerInstrumentationPlugin, mockOrchestrionPlugin, - mockSourceMapsConfigPlugin, mockSentryVitePlugin, mockComposeNitroServiceSourceMapsPlugin, ]); @@ -189,7 +181,6 @@ describe('sentryTanstackStart()', () => { mockServerEntryTunnelPlugin, mockServerInstrumentationPlugin, mockOrchestrionPlugin, - mockSourceMapsConfigPlugin, mockSentryVitePlugin, mockComposeNitroServiceSourceMapsPlugin, ]); @@ -207,11 +198,10 @@ describe('sentryTanstackStart()', () => { mockServerEntryTunnelPlugin, mockServerInstrumentationPlugin, mockOrchestrionPlugin, - mockSourceMapsConfigPlugin, - mockSentryVitePlugin, - mockComposeNitroServiceSourceMapsPlugin, mockEnableSourceMapsPlugin, mockEnableNitroSourceMapsPlugin, + mockSentryVitePlugin, + mockComposeNitroServiceSourceMapsPlugin, ]); }); }); @@ -226,7 +216,6 @@ describe('sentryTanstackStart()', () => { mockServerEntryTunnelPlugin, mockServerInstrumentationPlugin, mockOrchestrionPlugin, - mockSourceMapsConfigPlugin, mockSentryVitePlugin, mockComposeNitroServiceSourceMapsPlugin, mockMiddlewarePlugin, @@ -245,7 +234,6 @@ describe('sentryTanstackStart()', () => { mockServerEntryTunnelPlugin, mockServerInstrumentationPlugin, mockOrchestrionPlugin, - mockSourceMapsConfigPlugin, mockSentryVitePlugin, mockComposeNitroServiceSourceMapsPlugin, mockMiddlewarePlugin, @@ -264,7 +252,6 @@ describe('sentryTanstackStart()', () => { mockServerEntryTunnelPlugin, mockServerInstrumentationPlugin, mockOrchestrionPlugin, - mockSourceMapsConfigPlugin, mockSentryVitePlugin, mockComposeNitroServiceSourceMapsPlugin, ]); @@ -300,7 +287,6 @@ describe('sentryTanstackStart()', () => { mockTunnelRoutePlugin, mockServerInstrumentationPlugin, mockOrchestrionPlugin, - mockSourceMapsConfigPlugin, mockSentryVitePlugin, mockComposeNitroServiceSourceMapsPlugin, mockMiddlewarePlugin, diff --git a/packages/tanstackstart-react/test/vite/sourceMapScenarios.test.ts b/packages/tanstackstart-react/test/vite/sourceMapScenarios.test.ts index 7effcf3012f7..d43b6129acfe 100644 --- a/packages/tanstackstart-react/test/vite/sourceMapScenarios.test.ts +++ b/packages/tanstackstart-react/test/vite/sourceMapScenarios.test.ts @@ -1,14 +1,14 @@ // @vitest-environment node -import type { SentryVitePluginOptions } from '@sentry/bundler-plugins/vite'; -import type { BuildEnvironmentOptions, Plugin, ResolvedConfig, UserConfig } from 'vite'; +import type { SentryVitePluginInternalOptions } from '@sentry/bundler-plugins/vite'; +import type { BuildEnvironmentOptions, Plugin, UserConfig } from 'vite'; import { mergeConfig } from 'vite'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { sentryTanstackStart } from '../../src/vite/sentryTanstackStart'; +import { sentryTanstackStart, type SentryTanstackStartOptions } from '../../src/vite/sentryTanstackStart'; -let sentryVitePluginOptions: SentryVitePluginOptions | undefined; +let sentryVitePluginOptions: SentryVitePluginInternalOptions | undefined; vi.mock('@sentry/bundler-plugins/vite', () => ({ - sentryVitePlugin: (options: SentryVitePluginOptions) => { + sentryVitePlugin: (options: SentryVitePluginInternalOptions) => { sentryVitePluginOptions = options; return []; }, @@ -26,32 +26,44 @@ type NitroModulePlugin = Plugin & { nitro: { setup(nitro: unknown): void } }; const ENVIRONMENT_NAMES = ['client', 'ssr', 'nitro'] as const; -function findPlugin(plugins: Plugin[], name: string): T { - const plugin = plugins.find(plugin => plugin.name === name); - expect(plugin).toBeDefined(); - return plugin as T; -} - function callConfigHook(plugin: Plugin, config: UserConfig): UserConfig { return (plugin.config as (config: UserConfig) => UserConfig | undefined)(config) ?? {}; } /** The source map setting that applies to the output of an environment. */ function getOutputSourceMapSetting(build: BuildEnvironmentOptions): unknown { - const output = build.rolldownOptions?.output; - return (Array.isArray(output) ? undefined : output?.sourcemap) ?? build.sourcemap; + return getOwnOutputSourceMapSetting(build) ?? build.sourcemap; +} + +/** Like the bundler plugin, reads `rolldownOptions` and falls back to `rollupOptions`. */ +function getOwnOutputSourceMapSetting(build: BuildEnvironmentOptions): unknown { + const output = (build.rolldownOptions ?? build.rollupOptions)?.output; + return Array.isArray(output) ? undefined : output?.sourcemap; } /** * Runs Sentry's source map plugins in the order of a build with `nitro/vite`, which configures Nitro between the * `config` hooks of the Sentry plugins with `enforce: 'pre'` and `enforce: 'post'`. */ -async function build({ vite = {}, nitro: nitroOptions = {} }: { vite?: UserConfig; nitro?: NitroOptions }) { - const plugins = sentryTanstackStart({}); - const nitroPlugin = findPlugin(plugins, 'sentry-tanstackstart-nitro-source-maps'); +function build({ + vite = {}, + nitro: nitroOptions = {}, + options = {}, +}: { + vite?: UserConfig; + nitro?: NitroOptions; + options?: SentryTanstackStartOptions; +}) { + const plugins = sentryTanstackStart(options); + // Not added when source maps are disabled. + const nitroPlugin = plugins.find(plugin => plugin.name === 'sentry-tanstackstart-nitro-source-maps') as + | NitroModulePlugin + | undefined; const viteConfig = structuredClone(vite); - callConfigHook(nitroPlugin, viteConfig); + if (nitroPlugin) { + callConfigHook(nitroPlugin, viteConfig); + } const nitro = { options: { @@ -60,7 +72,7 @@ async function build({ vite = {}, nitro: nitroOptions = {} }: { vite?: UserConfi _c12: { layers: [{ config: nitroOptions }] }, }, }; - nitroPlugin.nitro.setup(nitro); + nitroPlugin?.nitro.setup(nitro); const nitroOutput = { ...nitroOptions.rollupConfig?.output, ...nitroOptions.rolldownConfig?.output }; const configWithNitro: UserConfig = mergeConfig(viteConfig, { @@ -75,37 +87,49 @@ async function build({ vite = {}, nitro: nitroOptions = {} }: { vite?: UserConfi }, }, }); - const userConfig: UserConfig = mergeConfig( - configWithNitro, - callConfigHook(findPlugin(plugins, 'sentry-tanstackstart-react-source-maps'), configWithNitro), - ); + const enablePlugin = plugins.find(plugin => plugin.name === 'sentry-tanstackstart-react-source-maps'); + const userConfig: UserConfig = enablePlugin + ? mergeConfig(configWithNitro, callConfigHook(enablePlugin, configWithNitro)) + : configWithNitro; const environments = Object.fromEntries( ENVIRONMENT_NAMES.map(name => [ name, - { - consumer: name === 'client' ? 'client' : 'server', - build: mergeConfig({ outDir: 'dist', ...userConfig.build }, userConfig.environments?.[name]?.build ?? {}), - }, + mergeConfig({ outDir: 'dist', ...userConfig.build }, userConfig.environments?.[name]?.build ?? {}), ]), - ); - const resolvedConfig = { root: process.cwd(), plugins, environments } as unknown as ResolvedConfig; - const deletionPlugin = findPlugin(plugins, 'sentry-tanstackstart-files-to-delete-after-upload-plugin'); - (deletionPlugin.configResolved as (this: unknown, config: ResolvedConfig) => void).call( - { meta: { viteVersion: '8.3.4' } }, - resolvedConfig, - ); + ) as Record<(typeof ENVIRONMENT_NAMES)[number], BuildEnvironmentOptions>; return { - client: getOutputSourceMapSetting(environments.client!.build), - ssr: getOutputSourceMapSetting(environments.ssr!.build), - nitro: getOutputSourceMapSetting(environments.nitro!.build), - filesToDelete: await sentryVitePluginOptions?.sourcemaps?.filesToDeleteAfterUpload, + client: getOutputSourceMapSetting(environments.client), + ssr: getOutputSourceMapSetting(environments.ssr), + nitro: getOutputSourceMapSetting(environments.nitro), + deleted: ENVIRONMENT_NAMES.filter(name => isDeletedByBundlerPlugin(userConfig, name, environments[name])), }; } -const CLIENT_MAPS = '.output/public/**/*.map'; -const SERVER_MAPS = '.output/server/**/*.map'; +/** + * Mirrors the decision of the bundler plugin: it passes the name of an environment with its own `build.sourcemap` to + * the function options. It removes the maps of an output with its own `sourcemap` if they are hidden, or if + * `sdkEnabledSourceMaps` is true and they are not inline. + */ +function isDeletedByBundlerPlugin(userConfig: UserConfig, name: string, build: BuildEnvironmentOptions): boolean { + const environment = userConfig.environments?.[name]?.build?.sourcemap !== undefined ? name : undefined; + const { deleteSourcemapsAfterUpload, sdkEnabledSourceMaps } = sentryVitePluginOptions?.sourcemaps ?? {}; + const deleteOption = + typeof deleteSourcemapsAfterUpload === 'function' + ? deleteSourcemapsAfterUpload(environment) + : deleteSourcemapsAfterUpload; + const sdkEnabled = + typeof sdkEnabledSourceMaps === 'function' ? sdkEnabledSourceMaps(environment) : sdkEnabledSourceMaps; + const outputSourceMap = getOwnOutputSourceMapSetting(build); + if (deleteOption !== true) { + return false; + } + if (outputSourceMap === undefined) { + return build.sourcemap !== false; + } + return outputSourceMap === 'hidden' || (sdkEnabled === true && outputSourceMap === true); +} describe('source map scenarios of a build with Nitro', () => { beforeEach(() => { @@ -118,72 +142,107 @@ describe('source map scenarios of a build with Nitro', () => { vi.restoreAllMocks(); }); - it.each<{ label: string; vite?: UserConfig; nitro?: NitroOptions; expected: Awaited> }>([ + it.each<{ label: string; vite?: UserConfig; nitro?: NitroOptions; expected: ReturnType }>([ { label: 'nothing', - expected: { client: 'hidden', ssr: 'hidden', nitro: 'hidden', filesToDelete: [CLIENT_MAPS, SERVER_MAPS] }, + expected: { client: 'hidden', ssr: 'hidden', nitro: 'hidden', deleted: ['client', 'nitro'] }, }, { label: 'nitro({ sourcemap: true })', nitro: { sourcemap: true }, - expected: { client: 'hidden', ssr: true, nitro: true, filesToDelete: [CLIENT_MAPS] }, + expected: { client: 'hidden', ssr: true, nitro: true, deleted: ['client'] }, }, { label: 'nitro({ sourcemap: false })', nitro: { sourcemap: false }, - expected: { client: 'hidden', ssr: false, nitro: false, filesToDelete: [CLIENT_MAPS] }, + expected: { client: 'hidden', ssr: false, nitro: false, deleted: ['client'] }, }, { label: 'nitro({ rollupConfig: { output: { sourcemap: true } } })', nitro: { rollupConfig: { output: { sourcemap: true } } }, - expected: { client: 'hidden', ssr: false, nitro: true, filesToDelete: [CLIENT_MAPS] }, + expected: { client: 'hidden', ssr: false, nitro: true, deleted: ['client'] }, }, { label: 'nitro({ rolldownConfig: { output: { sourcemap: true } } })', nitro: { rolldownConfig: { output: { sourcemap: true } } }, - expected: { client: 'hidden', ssr: false, nitro: true, filesToDelete: [CLIENT_MAPS] }, + expected: { client: 'hidden', ssr: false, nitro: true, deleted: ['client'] }, }, { label: 'build.sourcemap: true', vite: { build: { sourcemap: true } }, - expected: { client: true, ssr: 'hidden', nitro: 'hidden', filesToDelete: [SERVER_MAPS] }, + expected: { client: true, ssr: 'hidden', nitro: 'hidden', deleted: ['nitro'] }, }, { label: "build.sourcemap: 'hidden'", vite: { build: { sourcemap: 'hidden' } }, - expected: { client: 'hidden', ssr: 'hidden', nitro: 'hidden', filesToDelete: [SERVER_MAPS] }, + expected: { client: 'hidden', ssr: 'hidden', nitro: 'hidden', deleted: ['nitro'] }, }, { label: 'build.sourcemap: false', vite: { build: { sourcemap: false } }, - expected: { client: false, ssr: false, nitro: false, filesToDelete: [] }, + expected: { client: false, ssr: false, nitro: false, deleted: [] }, }, { label: 'environments.client.build.sourcemap: true', vite: { environments: { client: { build: { sourcemap: true } } } }, - expected: { client: true, ssr: 'hidden', nitro: 'hidden', filesToDelete: [SERVER_MAPS] }, + expected: { client: true, ssr: 'hidden', nitro: 'hidden', deleted: ['nitro'] }, }, { label: 'environments.client.build.sourcemap: false', vite: { environments: { client: { build: { sourcemap: false } } } }, - expected: { client: false, ssr: 'hidden', nitro: 'hidden', filesToDelete: [SERVER_MAPS] }, + expected: { client: false, ssr: 'hidden', nitro: 'hidden', deleted: ['nitro'] }, }, { label: 'build.rolldownOptions.output.sourcemap: true', vite: { build: { rolldownOptions: { output: { sourcemap: true } } } }, - expected: { client: true, ssr: true, nitro: true, filesToDelete: [] }, + expected: { client: true, ssr: true, nitro: true, deleted: [] }, }, { label: 'build.rolldownOptions.output.sourcemap: false', vite: { build: { rolldownOptions: { output: { sourcemap: false } } } }, - expected: { client: false, ssr: false, nitro: false, filesToDelete: [] }, + expected: { client: false, ssr: false, nitro: false, deleted: [] }, }, { label: 'environments.client.build.rolldownOptions.output.sourcemap: true', vite: { environments: { client: { build: { rolldownOptions: { output: { sourcemap: true } } } } } }, - expected: { client: true, ssr: 'hidden', nitro: 'hidden', filesToDelete: [SERVER_MAPS] }, + expected: { client: true, ssr: 'hidden', nitro: 'hidden', deleted: ['client', 'nitro'] }, + }, + { + label: "environments.client.build.rolldownOptions.output.sourcemap: 'hidden'", + vite: { environments: { client: { build: { rolldownOptions: { output: { sourcemap: 'hidden' } } } } } }, + expected: { client: 'hidden', ssr: 'hidden', nitro: 'hidden', deleted: ['client', 'nitro'] }, + }, + { + label: 'environments.client.build.rollupOptions.output.sourcemap: true', + vite: { environments: { client: { build: { rollupOptions: { output: { sourcemap: true } } } } } }, + expected: { client: true, ssr: 'hidden', nitro: 'hidden', deleted: ['client', 'nitro'] }, + }, + { + label: "environments.client.build.rollupOptions.output.sourcemap: 'hidden'", + vite: { environments: { client: { build: { rollupOptions: { output: { sourcemap: 'hidden' } } } } } }, + expected: { client: 'hidden', ssr: 'hidden', nitro: 'hidden', deleted: ['client', 'nitro'] }, + }, + ])('deletes the expected source maps when the user sets $label', scenario => { + expect(build(scenario)).toEqual(scenario.expected); + }); + + it.each<{ label: string; options: SentryTanstackStartOptions; expected: ReturnType }>([ + { + label: "sourcemaps.disable: 'disable-upload'", + options: { sourcemaps: { disable: 'disable-upload' } }, + expected: { client: undefined, ssr: false, nitro: false, deleted: [] }, + }, + { + label: 'sourcemaps.deleteSourcemapsAfterUpload: false', + options: { sourcemaps: { deleteSourcemapsAfterUpload: false } }, + expected: { client: 'hidden', ssr: 'hidden', nitro: 'hidden', deleted: [] }, + }, + { + label: 'sourcemaps.filesToDeleteAfterUpload', + options: { sourcemaps: { filesToDeleteAfterUpload: ['.output/**/*.map'] } }, + expected: { client: 'hidden', ssr: 'hidden', nitro: 'hidden', deleted: ['client', 'nitro'] }, }, - ])('keeps the source maps of the user and deletes the others when the user sets $label', async scenario => { - await expect(build(scenario)).resolves.toEqual(scenario.expected); + ])('follows the Sentry option $label', ({ options, expected }) => { + expect(build({ options })).toEqual(expected); }); }); diff --git a/packages/tanstackstart-react/test/vite/sourceMaps.test.ts b/packages/tanstackstart-react/test/vite/sourceMaps.test.ts index 12ccae09beb2..92283de4c46c 100644 --- a/packages/tanstackstart-react/test/vite/sourceMaps.test.ts +++ b/packages/tanstackstart-react/test/vite/sourceMaps.test.ts @@ -1,31 +1,31 @@ // @vitest-environment node -import type { SentryVitePluginOptions } from '@sentry/bundler-plugins/vite'; -import type { Plugin, ResolvedConfig, UserConfig } from 'vite'; +import type { SentryVitePluginInternalOptions } from '@sentry/bundler-plugins/vite'; +import type { UserConfig } from 'vite'; import { mergeConfig } from 'vite'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import * as path from 'path'; import { + createSourceMapState, getUpdatedSourceMapSettings, makeAddSentryVitePlugin, 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(), }; -let sentryVitePluginOptions: SentryVitePluginOptions | undefined; +let sentryVitePluginOptions: SentryVitePluginInternalOptions | undefined; -const sentryVitePluginSpy = vi.fn((options: SentryVitePluginOptions) => { +const sentryVitePluginSpy = vi.fn((options: SentryVitePluginInternalOptions) => { sentryVitePluginOptions = options; return [mockedSentryVitePlugin]; }); vi.mock('@sentry/bundler-plugins/vite', () => ({ - sentryVitePlugin: (options: SentryVitePluginOptions) => sentryVitePluginSpy(options), + sentryVitePlugin: (options: SentryVitePluginInternalOptions) => sentryVitePluginSpy(options), })); beforeEach(() => { @@ -47,34 +47,32 @@ describe('makeEnableSourceMapsVitePlugin()', () => { }); it.each([ - { label: 'the user set no source maps', userConfig: {}, expected: { client: true, ssr: true } }, + { label: 'the user set no source maps', userConfig: {}, expected: true }, + { label: 'the user set `build.sourcemap`', userConfig: { build: { sourcemap: true } }, expected: false }, + { label: 'the user set `build.sourcemap: false`', userConfig: { build: { sourcemap: false } }, expected: false }, { - label: 'the user set `build.sourcemap`', - userConfig: { build: { sourcemap: true } }, - expected: { client: false, ssr: false }, + label: 'the user set `build.rolldownOptions.output.sourcemap`', + userConfig: { build: { rolldownOptions: { output: { sourcemap: true } } } }, + expected: false, }, { - label: 'the user set the source maps of an environment', - userConfig: { environments: { client: { build: { sourcemap: true } } } }, - expected: { client: false, ssr: true }, + label: "an environment output sets `sourcemap: 'hidden'`", + userConfig: { environments: { client: { build: { rolldownOptions: { output: { sourcemap: 'hidden' } } } } } }, + expected: true, }, { - label: 'the user set `build.rolldownOptions.output.sourcemap`', - userConfig: { build: { rolldownOptions: { output: { sourcemap: true } } } }, - expected: { client: false, ssr: false }, + label: 'an environment output sets `sourcemap: true`', + userConfig: { environments: { client: { build: { rollupOptions: { output: { sourcemap: true } } } } } }, + expected: true, }, - ] 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); + ] as const)('records whether it enabled the source maps when $label', ({ userConfig, expected }) => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const state = { ...createSourceMapState(), sdkEnabledSourceMaps: !expected }; + const plugin = makeEnableSourceMapsVitePlugin({}, state)[0]!; + (plugin.config as (config: UserConfig) => UserConfig)(structuredClone(userConfig) as UserConfig); - expect({ - client: plugin.api.hasSentryEnabledSourceMaps('client'), - ssr: plugin.api.hasSentryEnabledSourceMaps('ssr'), - }).toEqual(expected); - }, - ); + expect(state.sdkEnabledSourceMaps).toBe(expected); + }); it.each([undefined, false, true, 'hidden', 'inline'] as const)( 'preserves user config without duplicating arrays when sourcemap is %s', @@ -106,9 +104,9 @@ describe('makeEnableSourceMapsVitePlugin()', () => { }); describe('makeAddSentryVitePlugin()', () => { - it('passes user-specified vite plugin options to vite plugin', async () => { + it('passes user-specified vite plugin options to vite plugin', () => { const errorHandler = vi.fn(); - const plugins = makeAddSentryVitePlugin({ + makeAddSentryVitePlugin({ org: 'my-org', authToken: 'my-token', sentryUrl: 'https://custom.sentry.io', @@ -133,16 +131,7 @@ describe('makeAddSentryVitePlugin()', () => { }, }); - // The filesToDeleteAfterUpload is now a Promise that resolves when the config hook runs - expect(sentryVitePluginOptions?.sourcemaps?.filesToDeleteAfterUpload).toBeInstanceOf(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?.configResolved as (config: ResolvedConfig) => void)({} as ResolvedConfig); - - // Verify the Promise resolves to the user-specified value - const resolvedFilesToDelete = await sentryVitePluginOptions?.sourcemaps?.filesToDeleteAfterUpload; - expect(resolvedFilesToDelete).toEqual(['baz/*.js']); + expect(sentryVitePluginOptions?.sourcemaps?.filesToDeleteAfterUpload).toEqual(['baz/*.js']); expect(sentryVitePluginSpy).toHaveBeenCalledWith( expect.objectContaining({ @@ -195,21 +184,6 @@ describe('makeAddSentryVitePlugin()', () => { ); }); - it('returns Sentry Vite plugins and config plugin', () => { - const plugins = makeAddSentryVitePlugin({ - org: 'my-org', - authToken: 'my-token', - }); - - expect(plugins.length).toBeGreaterThanOrEqual(2); - - const configPlugin = plugins.find(p => p.name === 'sentry-tanstackstart-files-to-delete-after-upload-plugin'); - expect(configPlugin).toBeDefined(); - expect(configPlugin?.apply).toBe('build'); - expect(configPlugin?.enforce).toBe('post'); - expect(typeof configPlugin?.configResolved).toBe('function'); - }); - it.each([ { name: 'client', consumer: 'client', expected: true }, { name: 'ssr', consumer: 'server', expected: false }, @@ -227,160 +201,85 @@ describe('makeAddSentryVitePlugin()', () => { }, ); - describe('default filesToDeleteAfterUpload', () => { - type TestEnvironment = { - consumer: 'client' | 'server'; - outDir: string; - outputDir?: string; - outputSourceMap?: boolean; - }; - - const NITRO_ENVIRONMENTS: Record = { - client: { consumer: 'client', outDir: '.output/public' }, - ssr: { consumer: 'server', outDir: 'node_modules/.nitro/vite/services/ssr' }, - // 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 { + describe('deleteSourcemapsAfterUpload', () => { + /** Calls the function options like the bundler plugin, which passes an environment with its own setting. */ + function decide(environment?: string) { + const { deleteSourcemapsAfterUpload, sdkEnabledSourceMaps } = sentryVitePluginOptions?.sourcemaps ?? {}; return { - name: 'source-maps-enabler', - api: { hasSentryEnabledSourceMaps: (environmentName: string) => environmentNames.includes(environmentName) }, + delete: + typeof deleteSourcemapsAfterUpload === 'function' + ? deleteSourcemapsAfterUpload(environment) + : deleteSourcemapsAfterUpload, + sdkEnabled: + typeof sdkEnabledSourceMaps === 'function' ? sdkEnabledSourceMaps(environment) : sdkEnabledSourceMaps, }; } - 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, - ); + it('reads the state when the bundler plugin decides, not when the plugin is created', () => { + const state = createSourceMapState(); + makeAddSentryVitePlugin({}, state); - return Promise.resolve(sentryVitePluginOptions?.sourcemaps?.filesToDeleteAfterUpload); - } + expect(decide()).toEqual({ delete: undefined, sdkEnabled: false }); + state.sdkEnabledSourceMaps = true; + expect(decide()).toEqual({ delete: true, sdkEnabled: true }); + }); it.each([ { - label: 'a Nitro build, but not in the Nitro service output', - environments: NITRO_ENVIRONMENTS, - expected: ['.output/public/**/*.map', '.output/server/**/*.map'], + label: 'the SDK enabled the top-level source maps', + state: { sdkEnabledSourceMaps: true, sdkEnabledNitroSourceMaps: false }, + expected: { top: true, ssr: undefined, nitro: undefined }, }, { - 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: 'the SDK enabled the Nitro source maps', + state: { sdkEnabledSourceMaps: false, sdkEnabledNitroSourceMaps: true }, + expected: { top: undefined, ssr: undefined, nitro: true }, }, { - label: 'environments that share an output directory once', - environments: { - client: { consumer: 'client', outDir: 'dist' }, - ssr: { consumer: 'server', outDir: 'dist' }, - }, - expected: ['dist/**/*.map'], + label: 'the SDK enabled both', + state: { sdkEnabledSourceMaps: true, sdkEnabledNitroSourceMaps: true }, + expected: { top: true, ssr: undefined, nitro: true }, }, - ] as const)('deletes the source maps in each output directory of $label', async ({ environments, expected }) => { - await expect(resolveFilesToDelete(environments)).resolves.toEqual(expected); - }); - - 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, - ); - }, - ); - - 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 }, - }; - - await expect(resolveFilesToDelete(environments)).resolves.toEqual(['.output/server/**/*.map']); + { + label: 'the user enabled all source maps', + state: { sdkEnabledSourceMaps: false, sdkEnabledNitroSourceMaps: false }, + expected: { top: undefined, ssr: undefined, nitro: undefined }, }, - ); + ])('asks for deletion of the source maps the SDK enabled when $label', ({ state, expected }) => { + makeAddSentryVitePlugin({}, state); - 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(); + expect({ top: decide().delete, ssr: decide('ssr').delete, nitro: decide('nitro').delete }).toEqual(expected); + expect({ + top: decide().sdkEnabled, + ssr: decide('ssr').sdkEnabled, + nitro: decide('nitro').sdkEnabled, + }).toEqual({ top: expected.top === true, ssr: false, nitro: expected.nitro === true }); }); - it('does not warn with Vite 8', async () => { - const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - await resolveFilesToDelete(NITRO_ENVIRONMENTS); + // A client environment with its own setting was set by the user, because the SDK sets only the top level. + it('keeps the source maps of an environment that the user configured', () => { + makeAddSentryVitePlugin({}, { sdkEnabledSourceMaps: true, sdkEnabledNitroSourceMaps: true }); - expect(consoleWarnSpy).not.toHaveBeenCalled(); - consoleWarnSpy.mockRestore(); + expect(decide('client')).toEqual({ delete: undefined, sdkEnabled: false }); }); - it('logs the default in debug mode', async () => { - const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - - await resolveFilesToDelete(NITRO_ENVIRONMENTS, { options: { 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.', + it.each([true, false])('passes a user-set `deleteSourcemapsAfterUpload: %s` through', value => { + makeAddSentryVitePlugin( + { sourcemaps: { deleteSourcemapsAfterUpload: value } }, + { sdkEnabledSourceMaps: !value, sdkEnabledNitroSourceMaps: !value }, ); - consoleSpy.mockRestore(); - }); - - 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([decide().delete, decide('ssr').delete, decide('nitro').delete]).toEqual([value, value, value]); + }); - expect(consoleSpy).toHaveBeenCalledWith( - '[Sentry] No source maps are deleted after the upload, because Sentry did not enable source map generation for this build.', + it('passes a user-set filesToDeleteAfterUpload through and keeps its own deletion', () => { + makeAddSentryVitePlugin( + { sourcemaps: { filesToDeleteAfterUpload: ['dist/**/*.map'] } }, + { sdkEnabledSourceMaps: true, sdkEnabledNitroSourceMaps: true }, ); - consoleSpy.mockRestore(); + + expect(sentryVitePluginOptions?.sourcemaps?.filesToDeleteAfterUpload).toEqual(['dist/**/*.map']); + expect([decide().delete, decide('ssr').delete, decide('nitro').delete]).toEqual([true, undefined, true]); }); });