diff --git a/dev-packages/e2e-tests/test-applications/react-router-sourcemaps/.gitignore b/dev-packages/e2e-tests/test-applications/react-router-sourcemaps/.gitignore
new file mode 100644
index 000000000000..bd7f8c64b406
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-sourcemaps/.gitignore
@@ -0,0 +1,7 @@
+/node_modules
+/build
+.react-router
+.tmp_mock_uploads.json
+.tmp_chunks
+.tmp_build_stdout
+.tmp_build_stderr
diff --git a/dev-packages/e2e-tests/test-applications/react-router-sourcemaps/app/entry.client.tsx b/dev-packages/e2e-tests/test-applications/react-router-sourcemaps/app/entry.client.tsx
new file mode 100644
index 000000000000..33cb007f5313
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-sourcemaps/app/entry.client.tsx
@@ -0,0 +1,12 @@
+import { StrictMode, startTransition } from 'react';
+import { hydrateRoot } from 'react-dom/client';
+import { HydratedRouter } from 'react-router/dom';
+
+startTransition(() => {
+ hydrateRoot(
+ document,
+
+
+ ,
+ );
+});
diff --git a/dev-packages/e2e-tests/test-applications/react-router-sourcemaps/app/root.tsx b/dev-packages/e2e-tests/test-applications/react-router-sourcemaps/app/root.tsx
new file mode 100644
index 000000000000..c09b53b99d46
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-sourcemaps/app/root.tsx
@@ -0,0 +1,23 @@
+import { Links, Meta, Outlet, Scripts, ScrollRestoration } from 'react-router';
+
+export function Layout({ children }: { children: React.ReactNode }) {
+ return (
+
+
+
+
+
+
+
+
+ {children}
+
+
+
+
+ );
+}
+
+export default function App() {
+ return ;
+}
diff --git a/dev-packages/e2e-tests/test-applications/react-router-sourcemaps/app/routes.ts b/dev-packages/e2e-tests/test-applications/react-router-sourcemaps/app/routes.ts
new file mode 100644
index 000000000000..205ff3ccb9fd
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-sourcemaps/app/routes.ts
@@ -0,0 +1,3 @@
+import { type RouteConfig, index } from '@react-router/dev/routes';
+
+export default [index('routes/home.tsx')] satisfies RouteConfig;
diff --git a/dev-packages/e2e-tests/test-applications/react-router-sourcemaps/app/routes/home.tsx b/dev-packages/e2e-tests/test-applications/react-router-sourcemaps/app/routes/home.tsx
new file mode 100644
index 000000000000..d9227c1a3262
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-sourcemaps/app/routes/home.tsx
@@ -0,0 +1,3 @@
+export default function Home() {
+ return Sourcemaps test app
;
+}
diff --git a/dev-packages/e2e-tests/test-applications/react-router-sourcemaps/assert-build.ts b/dev-packages/e2e-tests/test-applications/react-router-sourcemaps/assert-build.ts
new file mode 100644
index 000000000000..970744c09427
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-sourcemaps/assert-build.ts
@@ -0,0 +1,103 @@
+import * as assert from 'assert/strict';
+import * as fs from 'fs';
+import * as path from 'path';
+import { getArtifactBundles, getDebugIdPairs, getSourcemaps, loadMockServerResults } from '@sentry-internal/test-utils';
+
+const CLIENT_ASSETS_DIR = 'build/client/assets';
+
+// Both injectors write this assignment, so counting it per file counts injections
+// regardless of which one ran. Matching only the bundler plugin's trailing
+// `_sentryDebugIdIdentifier` would miss the `sentry-cli` snippet, which omits it.
+const DEBUG_ID_ASSIGNMENT =
+ /_sentryDebugIds\[[^\]]+\]\s*=\s*"([\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12})"/gi;
+
+function getClientChunks(): string[] {
+ assert.ok(fs.existsSync(CLIENT_ASSETS_DIR), `Expected ${CLIENT_ASSETS_DIR} to exist. Did the build run?`);
+
+ return fs
+ .readdirSync(CLIENT_ASSETS_DIR)
+ .filter(file => file.endsWith('.js'))
+ .map(file => path.join(CLIENT_ASSETS_DIR, file));
+}
+
+const chunks = getClientChunks();
+assert.ok(chunks.length > 0, `Expected at least one client chunk in ${CLIENT_ASSETS_DIR}`);
+
+// 1. Every chunk carries exactly one debug ID.
+//
+// Two injections per chunk is the failure mode of
+// https://github.com/getsentry/sentry-javascript/issues/22929: both snippets run at
+// runtime, `applyDebugIds` flattens them to a single filename, and the last one wins -
+// which is the CLI's, the one with no uploaded artifact bundle. Frames arrive minified.
+const injectedDebugIds = new Map();
+
+for (const chunk of chunks) {
+ const code = fs.readFileSync(chunk, 'utf-8');
+ const ids = [...code.matchAll(DEBUG_ID_ASSIGNMENT)].map(match => match[1] as string);
+
+ // Exactly one, not "at most one": zero would mean injection silently skipped a chunk,
+ // which leaves its frames unresolvable just as surely as injecting twice does.
+ assert.equal(
+ ids.length,
+ 1,
+ `Expected exactly one debug ID in ${chunk}, found ${ids.length}: ${JSON.stringify([...new Set(ids)])}. ` +
+ 'More than one means debug IDs were injected twice (Vite plugin *and* sentryOnBuildEnd); ' +
+ 'none means injection skipped this chunk.',
+ );
+
+ injectedDebugIds.set(chunk, ids[0] as string);
+}
+
+console.log(`all ${chunks.length} client chunk(s) carry exactly one debug ID\n`);
+
+const requests = loadMockServerResults();
+const bundles = getArtifactBundles(requests);
+assert.ok(bundles.length > 0, 'Expected at least one uploaded artifact bundle');
+
+// 2. Source maps with real content reached Sentry.
+//
+// The Vite plugin deletes `sourcemaps.filesToDeleteAfterUpload` in a `finally` block that
+// runs even when `sourcemaps.disable` is set. Forwarding that option removed the maps
+// before `sentryOnBuildEnd` could upload them, leaving nothing to un-minify with. Asserting
+// on the upload rather than on disk, because deleting the maps *after* a successful upload
+// is the intended behaviour.
+const uploadedSourcemaps = getSourcemaps(bundles);
+assert.ok(uploadedSourcemaps.length > 0, 'Expected at least one source map in the uploaded artifact bundles');
+assert.ok(
+ uploadedSourcemaps.some(entry => (entry.sourcemap.mappings?.length ?? 0) > 0),
+ 'Expected at least one uploaded source map with non-empty mappings',
+);
+console.log(`${uploadedSourcemaps.length} source map(s) uploaded with content`);
+
+// 3. The debug IDs that shipped are the ones that were uploaded.
+//
+// This is what actually breaks un-minification: a chunk can carry a perfectly valid debug
+// ID that has no artifact bundle behind it.
+
+const debugIdPairs = getDebugIdPairs(bundles);
+const uploadedDebugIds = new Set(debugIdPairs.map(pair => pair.debugId.toLowerCase()));
+assert.ok(uploadedDebugIds.size > 0, 'Expected at least one uploaded JS/source map pair with a debug ID');
+
+// Vite emits some assets (e.g. the route manifest) without a source map, so they can never
+// be part of an uploaded JS/map pair. Key off the uploaded JS file names instead of the
+// maps on disk, which are deleted after a successful upload.
+const uploadedJsFiles = new Set(debugIdPairs.map(pair => path.basename(pair.jsUrl)));
+let crossCheckedChunks = 0;
+
+for (const [chunk, injectedDebugId] of injectedDebugIds) {
+ if (!uploadedJsFiles.has(path.basename(chunk))) {
+ continue;
+ }
+
+ const debugId = injectedDebugId.toLowerCase();
+ assert.ok(
+ uploadedDebugIds.has(debugId),
+ `Debug ID ${debugId} in ${chunk} was never uploaded. Uploaded: ${JSON.stringify([...uploadedDebugIds])}`,
+ );
+ crossCheckedChunks++;
+}
+
+assert.ok(crossCheckedChunks > 0, 'Expected at least one uploaded chunk to cross-check debug IDs against');
+console.log(`${crossCheckedChunks} chunk(s) ship a debug ID that was uploaded\n`);
+
+console.log('All react-router source map assertions passed!');
diff --git a/dev-packages/e2e-tests/test-applications/react-router-sourcemaps/package.json b/dev-packages/e2e-tests/test-applications/react-router-sourcemaps/package.json
new file mode 100644
index 000000000000..b56410c587cf
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-sourcemaps/package.json
@@ -0,0 +1,35 @@
+{
+ "name": "react-router-sourcemaps",
+ "version": "0.1.0",
+ "type": "module",
+ "private": true,
+ "dependencies": {
+ "react": "^19.2.7",
+ "react-dom": "^19.2.7",
+ "react-router": "^8.3.0",
+ "@react-router/node": "^8.3.0",
+ "@react-router/serve": "^8.3.0",
+ "@sentry/react-router": "file:../../packed/sentry-react-router-packed.tgz",
+ "isbot": "^5.1.17"
+ },
+ "devDependencies": {
+ "@types/react": "19.2.17",
+ "@types/react-dom": "19.2.3",
+ "@types/node": "^20",
+ "@react-router/dev": "^8.3.0",
+ "@playwright/test": "~1.56.0",
+ "@sentry-internal/test-utils": "link:../../../test-utils",
+ "tsx": "^4.23.0",
+ "typescript": "^5.6.3",
+ "vite": "^7.3.2"
+ },
+ "scripts": {
+ "build": "node start-mock-sentry-server.mjs & SENTRY_URL=http://localhost:3032 react-router build > .tmp_build_stdout 2> .tmp_build_stderr; BUILD_EXIT=$?; kill %1 2>/dev/null; exit $BUILD_EXIT",
+ "clean": "npx rimraf node_modules pnpm-lock.yaml",
+ "test:build": "pnpm install && pnpm build",
+ "test:assert": "pnpm tsx assert-build.ts"
+ },
+ "volta": {
+ "extends": "../../package.json"
+ }
+}
diff --git a/dev-packages/e2e-tests/test-applications/react-router-sourcemaps/react-router.config.ts b/dev-packages/e2e-tests/test-applications/react-router-sourcemaps/react-router.config.ts
new file mode 100644
index 000000000000..de81e63c4e37
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-sourcemaps/react-router.config.ts
@@ -0,0 +1,9 @@
+import type { Config } from '@react-router/dev/config';
+import { sentryOnBuildEnd } from '@sentry/react-router';
+
+export default {
+ ssr: true,
+ buildEnd: async ({ viteConfig, reactRouterConfig, buildManifest }) => {
+ await sentryOnBuildEnd({ viteConfig, reactRouterConfig, buildManifest });
+ },
+} satisfies Config;
diff --git a/dev-packages/e2e-tests/test-applications/react-router-sourcemaps/start-mock-sentry-server.mjs b/dev-packages/e2e-tests/test-applications/react-router-sourcemaps/start-mock-sentry-server.mjs
new file mode 100644
index 000000000000..cce37a5f9daf
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-sourcemaps/start-mock-sentry-server.mjs
@@ -0,0 +1,3 @@
+import { startMockSentryServer } from '@sentry-internal/test-utils';
+
+startMockSentryServer();
diff --git a/dev-packages/e2e-tests/test-applications/react-router-sourcemaps/tsconfig.json b/dev-packages/e2e-tests/test-applications/react-router-sourcemaps/tsconfig.json
new file mode 100644
index 000000000000..a16df276e8bc
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-sourcemaps/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "compilerOptions": {
+ "lib": ["DOM", "DOM.Iterable", "ES2022"],
+ "types": ["node", "vite/client"],
+ "target": "ES2022",
+ "module": "ES2022",
+ "moduleResolution": "bundler",
+ "jsx": "react-jsx",
+ "rootDirs": [".", "./.react-router/types"],
+ "baseUrl": ".",
+
+ "esModuleInterop": true,
+ "verbatimModuleSyntax": true,
+ "noEmit": true,
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+ "strict": true
+ },
+ "include": ["**/*", "**/.server/**/*", "**/.client/**/*", ".react-router/types/**/*"]
+}
diff --git a/dev-packages/e2e-tests/test-applications/react-router-sourcemaps/vite.config.ts b/dev-packages/e2e-tests/test-applications/react-router-sourcemaps/vite.config.ts
new file mode 100644
index 000000000000..f75d14c69f8a
--- /dev/null
+++ b/dev-packages/e2e-tests/test-applications/react-router-sourcemaps/vite.config.ts
@@ -0,0 +1,30 @@
+import { reactRouter } from '@react-router/dev/vite';
+import { sentryReactRouter, type SentryReactRouterBuildOptions } from '@sentry/react-router';
+import { defineConfig } from 'vite';
+
+// Deliberately routes `sourcemaps` through `unstable_sentryVitePluginOptions`. That shape
+// used to drop the SDK's `sourcemaps.disable: true`, which re-enabled debug ID injection in
+// the Vite plugin on top of the injection done by `sentryOnBuildEnd` - two debug IDs per
+// chunk, only one of which has an uploaded artifact bundle.
+// See https://github.com/getsentry/sentry-javascript/issues/22929
+export const sentryConfig: SentryReactRouterBuildOptions = {
+ authToken: 'fake-auth-token',
+ org: 'test-org',
+ project: 'test-project',
+ release: {
+ name: 'test-release',
+ },
+ unstable_sentryVitePluginOptions: {
+ url: 'http://localhost:3032',
+ sourcemaps: {
+ // The maps have to survive until `sentryOnBuildEnd` uploads them, so this asserts
+ // the option is not forwarded to the Vite plugin (which deletes in a `finally`).
+ filesToDeleteAfterUpload: ['./build/client/assets/**/*.map'],
+ },
+ },
+ debug: true,
+};
+
+export default defineConfig(config => ({
+ plugins: [reactRouter(), sentryReactRouter(sentryConfig, config)],
+}));