Is there an existing issue for this?
How do you use Sentry?
Sentry Saas (sentry.io)
Which SDK are you using?
@sentry/react-router
SDK Version
10.56.0
Framework Version
React Router 7.17.0
Link to Sentry event
N/A
Reproduction Example/SDK Setup
#19890 fixed the double debug ID injection in #19874 by setting sourcemaps: { disable: true } on the underlying Vite plugin. That fix is bypassed by a trailing spread in the same object literal, so any user who passes a sourcemaps key through unstable_sentryVitePluginOptions lands back in the exact failure mode of #19874 — on current versions.
makeCustomSentryVitePlugins.ts, unchanged on main today:
const sentryVitePlugins = sentryVitePlugin({
// ...
release: {
...unstable_sentryVitePluginOptions?.release,
...release,
},
// will be handled in buildEnd hook
sourcemaps: {
disable: true,
...unstable_sentryVitePluginOptions?.sourcemaps,
},
...unstable_sentryVitePluginOptions, // <-- re-assigns `sourcemaps` wholesale
})
The inner merge is fine — it preserves disable: true unless the user explicitly sets sourcemaps.disable. The trailing ...unstable_sentryVitePluginOptions then replaces the whole sourcemaps key with the user's raw object, dropping disable entirely. Object spread replaces at the top level; it does not deep-merge.
disable is then absent → undefined, and the gate in @sentry/rollup-plugin is a strict comparison:
const sourcemapsEnabled = options.sourcemaps?.disable !== true; // undefined !== true
// ...
if (sourcemapsEnabled && !hasExistingDebugID(code)) {
const debugId = stringToUUID(code);
injectCode.append(getDebugIdSnippet(debugId));
}
So the Vite plugin injects debug IDs again, and #19874's chain replays in full.
The config that triggers it is not exotic — this is the shape epic-stack ships, and it's a widely-copied React Router 7 starter:
const sentryConfig: SentryReactRouterBuildOptions = {
authToken: process.env.SENTRY_AUTH_TOKEN,
org: process.env.SENTRY_ORG,
project: process.env.SENTRY_PROJECT,
unstable_sentryVitePluginOptions: {
release: { name: process.env.COMMIT_SHA, setCommits: { auto: true } },
sourcemaps: {
filesToDeleteAfterUpload: ['./build/**/*.map'], // <-- enough to trigger it
},
},
}
Steps to Reproduce
The grep repro from #19874 reproduces this too, with sourcemaps.filesToDeleteAfterUpload added to unstable_sentryVitePluginOptions:
SENTRY_AUTH_TOKEN=fake npx react-router build
grep -oE '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' \
build/client/assets/entry.client-*.js | sort | uniq -c
→ two different UUIDs in one file.
Isolating it from the build, driving makeCustomSentryVitePlugins directly and running renderChunk over a sample chunk (@sentry/react-router@10.56.0, @sentry/rollup-plugin@5.3.0):
const { makeCustomSentryVitePlugins } =
require('@sentry/react-router/build/cjs/vite/makeCustomSentryVitePlugins.js')
const SAMPLE = '"use strict";\nconsole.log("hello from a chunk");\n'
const run = async (label, options) => {
const plugins = await makeCustomSentryVitePlugins(options)
let injected = 0
for (const p of plugins) {
if (typeof p.renderChunk !== 'function') continue
const out = await p.renderChunk.call(p, SAMPLE,
{ fileName: 'entry.client-abc123.js', facadeModuleId: '/app/entry.client.tsx' }, {}, {})
const code = typeof out?.code === 'string' ? out.code : (out?.code?.toString?.() ?? '')
injected += (code.match(/_sentryDebugIds\[[^\]]+\]\s*=\s*"([0-9a-f-]{36})"/g) || []).length
}
console.log(label, '->', injected)
}
Results:
=== epic-stack config (sourcemaps inside unstable_) ===
resolved sourcemaps option : {"filesToDeleteAfterUpload":["./build/**/*.map"]}
sourcemaps.disable : undefined
debug ids injected by plugin: 1 <-- should be 0
=== top-level fields only ===
resolved sourcemaps option : {"disable":true}
sourcemaps.disable : true
debug ids injected by plugin: 0
=== workaround from #19874 (sourcemaps.disable: true in unstable_) ===
resolved sourcemaps option : {"disable":true,"filesToDeleteAfterUpload":["./build/**/*.map"]}
sourcemaps.disable : true
debug ids injected by plugin: 0
Expected Result
sourcemaps.disable = true — which the react-router integration sets deliberately, since injection and upload are handled by sentryOnBuildEnd — should not be silently discarded by passing an unrelated key such as filesToDeleteAfterUpload. Each chunk should carry exactly one debug ID, matching the uploaded artifact.
Actual Result
Two debug IDs per chunk, and the one that wins at runtime is guaranteed to be the one Sentry has no artifact for:
- Client build — the Vite plugin injects debug ID A, uploads chunks + maps under A, then its
finally runs deleteArtifacts() against the user's filesToDeleteAfterUpload glob and deletes every client .map.
buildEnd — sentryOnBuildEnd runs sentry-cli sourcemaps inject <buildDirectory>. It finds chunks with no adjacent .map, so it appends a second debug ID B, with no map to write it into and none to upload.
- Runtime — both snippets execute. They sit at different lines, so they produce different
new Error().stack keys and both entries live in _sentryDebugIds. applyDebugIds then flattens that to filename → debugId, and since both stacks resolve to the same filename, the later insertion wins. The CLI's snippet is at the bottom of the file, so B ships in debug_meta.images — and B is the one with no artifact bundle.
Result: every client frame arrives minified, js_no_source. Server frames are unaffected.
Two things make this hard to notice, which is probably why #20042 looked theoretical:
- The upload succeeds and logs success — under A, the ID that loses.
deleteArtifacts() does run, so the maps are cleaned up as intended and the build output looks correct.
You only find out when a client-side error reaches Sentry and the frames are minified. We ran this way for months.
Additional Context
A fix already exists: #20042 ("fix(react-router): preserve sourcemaps.disable override") did exactly this and was closed unmerged on 2026-04-20, with:
Yeah, I'm not sure why the clanker was concerned about that. No one's been complaining about it, and the bug has stayed closed, so I'm (even more) convinced you're right.
Filing this as the missing real-world report: we hit it in production on a React Router 7 app, with minified client frames in Sentry and both debug IDs visible in the deployed bundle.
Note the trailing spread also defeats the two other field-wise merges above it. release, reactComponentAnnotation and _metaOptions are each merged carefully and then overwritten by the raw unstable_ object whenever the same key is present — so a top-level release is silently discarded in favour of an unstable_.release. Moving the spread above the explicit keys fixes all four at once.
Related: #19874 (same symptom, different cause, fixed in 10.47.0), #15106 / #15109 (the Next.js equivalent).
Workarounds, for anyone who lands here
Either move everything to the supported top-level fields (release supports name and setCommits, so nothing is lost) and drop sourcemaps so sentryOnBuildEnd uses its own default of `${reactRouterConfig.buildDirectory}/**/*.map`:
const sentryConfig: SentryReactRouterBuildOptions = {
authToken: process.env.SENTRY_AUTH_TOKEN,
org: process.env.SENTRY_ORG,
project: process.env.SENTRY_PROJECT,
release: { name: process.env.COMMIT_SHA, setCommits: { auto: true } },
}
…or, if you need unstable_sentryVitePluginOptions.sourcemaps, carry disable: true in your own object so the trailing spread re-supplies it.
Is there an existing issue for this?
How do you use Sentry?
Sentry Saas (sentry.io)
Which SDK are you using?
@sentry/react-routerSDK Version
10.56.0
Framework Version
React Router 7.17.0
Link to Sentry event
N/A
Reproduction Example/SDK Setup
#19890fixed the double debug ID injection in #19874 by settingsourcemaps: { disable: true }on the underlying Vite plugin. That fix is bypassed by a trailing spread in the same object literal, so any user who passes asourcemapskey throughunstable_sentryVitePluginOptionslands back in the exact failure mode of #19874 — on current versions.makeCustomSentryVitePlugins.ts, unchanged onmaintoday:The inner merge is fine — it preserves
disable: trueunless the user explicitly setssourcemaps.disable. The trailing...unstable_sentryVitePluginOptionsthen replaces the wholesourcemapskey with the user's raw object, droppingdisableentirely. Object spread replaces at the top level; it does not deep-merge.disableis then absent →undefined, and the gate in@sentry/rollup-pluginis a strict comparison:So the Vite plugin injects debug IDs again, and #19874's chain replays in full.
The config that triggers it is not exotic — this is the shape epic-stack ships, and it's a widely-copied React Router 7 starter:
Steps to Reproduce
The grep repro from #19874 reproduces this too, with
sourcemaps.filesToDeleteAfterUploadadded tounstable_sentryVitePluginOptions:→ two different UUIDs in one file.
Isolating it from the build, driving
makeCustomSentryVitePluginsdirectly and runningrenderChunkover a sample chunk (@sentry/react-router@10.56.0,@sentry/rollup-plugin@5.3.0):Results:
Expected Result
sourcemaps.disable = true— which the react-router integration sets deliberately, since injection and upload are handled bysentryOnBuildEnd— should not be silently discarded by passing an unrelated key such asfilesToDeleteAfterUpload. Each chunk should carry exactly one debug ID, matching the uploaded artifact.Actual Result
Two debug IDs per chunk, and the one that wins at runtime is guaranteed to be the one Sentry has no artifact for:
finallyrunsdeleteArtifacts()against the user'sfilesToDeleteAfterUploadglob and deletes every client.map.buildEnd—sentryOnBuildEndrunssentry-cli sourcemaps inject <buildDirectory>. It finds chunks with no adjacent.map, so it appends a second debug ID B, with no map to write it into and none to upload.new Error().stackkeys and both entries live in_sentryDebugIds.applyDebugIdsthen flattens that to filename → debugId, and since both stacks resolve to the same filename, the later insertion wins. The CLI's snippet is at the bottom of the file, so B ships indebug_meta.images— and B is the one with no artifact bundle.Result: every client frame arrives minified,
js_no_source. Server frames are unaffected.Two things make this hard to notice, which is probably why #20042 looked theoretical:
deleteArtifacts()does run, so the maps are cleaned up as intended and the build output looks correct.You only find out when a client-side error reaches Sentry and the frames are minified. We ran this way for months.
Additional Context
A fix already exists: #20042 ("fix(react-router): preserve sourcemaps.disable override") did exactly this and was closed unmerged on 2026-04-20, with:
Filing this as the missing real-world report: we hit it in production on a React Router 7 app, with minified client frames in Sentry and both debug IDs visible in the deployed bundle.
Note the trailing spread also defeats the two other field-wise merges above it.
release,reactComponentAnnotationand_metaOptionsare each merged carefully and then overwritten by the rawunstable_object whenever the same key is present — so a top-levelreleaseis silently discarded in favour of anunstable_.release. Moving the spread above the explicit keys fixes all four at once.Related: #19874 (same symptom, different cause, fixed in 10.47.0), #15106 / #15109 (the Next.js equivalent).
Workarounds, for anyone who lands here
Either move everything to the supported top-level fields (
releasesupportsnameandsetCommits, so nothing is lost) and dropsourcemapssosentryOnBuildEnduses its own default of`${reactRouterConfig.buildDirectory}/**/*.map`:…or, if you need
unstable_sentryVitePluginOptions.sourcemaps, carrydisable: truein your own object so the trailing spread re-supplies it.