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)], +}));