Repository navigation
Description
Is there an existing issue for this?
- I have checked for existing issues
- I have reviewed the documentation
- I am using the latest SDK release
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
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.- Runtime — both snippets execute. They sit at different lines, so they produce different
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:
- 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.
Metadata
Metadata
Assignees
Projects
- StatusShow more project fieldsWaiting for: Product Owner
Activity
linear-code commentedon Aug 1, 2026
JS-3246
fix(react-router): Preserve `sourcemaps.disable` when `unstable_sentr…
chargome commentedon Aug 3, 2026
@lasseklovstad Thanks for reporting! We'll ship the fix this week.
feat(react-router)!: Remove unstable_sentryVitePluginOptions (#23374)