Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
export default function Home() {
return <h1>Sourcemaps test app</h1>;
}
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
import { startMockSentryServer } from '@sentry-internal/test-utils';

startMockSentryServer();
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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