Skip to content
Merged
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/node_modules
/build
.react-router
.tmp_mock_uploads.json
.tmp_chunks
.tmp_build_stdout
.tmp_build_stderr
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { StrictMode, startTransition } from 'react';
import { hydrateRoot } from 'react-dom/client';
import { HydratedRouter } from 'react-router/dom';

startTransition(() => {
hydrateRoot(
document,
<StrictMode>
<HydratedRouter />
</StrictMode>,
);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Links, Meta, Outlet, Scripts, ScrollRestoration } from 'react-router';

export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}

export default function App() {
return <Outlet />;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { type RouteConfig, index } from '@react-router/dev/routes';

export default [index('routes/home.tsx')] satisfies RouteConfig;
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function Home() {
return <h1>Sourcemaps test app</h1>;
}
Original file line number Diff line number Diff line change
@@ -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<string, string>();

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!');
Original file line number Diff line number Diff line change
@@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { startMockSentryServer } from '@sentry-internal/test-utils';

startMockSentryServer();
Original file line number Diff line number Diff line change
@@ -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/**/*"]
}
Original file line number Diff line number Diff line change
@@ -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)],
}));
Loading