From f1dfe34300097c041e5ce365206930f49da4fd3b Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Mon, 19 Oct 2026 20:07:56 +0100 Subject: [PATCH] fix(bundler-plugins): Inject the release only into chunks that load at startup --- packages/bundler-plugins/src/core/index.ts | 52 +++++++++ packages/bundler-plugins/src/rollup/index.ts | 22 ++-- packages/bundler-plugins/src/webpack/index.ts | 16 ++- .../bundler-plugins/test/core/index.test.ts | 27 ++++- .../test/rollup/release-injection.test.ts | 103 ++++++++++++++++++ .../test/webpack/release-injection.test.ts | 66 +++++++++++ 6 files changed, 273 insertions(+), 13 deletions(-) create mode 100644 packages/bundler-plugins/test/rollup/release-injection.test.ts create mode 100644 packages/bundler-plugins/test/webpack/release-injection.test.ts diff --git a/packages/bundler-plugins/src/core/index.ts b/packages/bundler-plugins/src/core/index.ts index fd645190fb51..4edc5ebbed91 100644 --- a/packages/bundler-plugins/src/core/index.ts +++ b/packages/bundler-plugins/src/core/index.ts @@ -37,6 +37,58 @@ export function shouldSkipCodeInjection(code: string, facadeModuleId: string | n return false; } +export interface ChunkGraphNode { + fileName: string; + isEntry?: boolean; + imports?: string[]; +} + +const initialChunksCache = new WeakMap, Set>(); + +function getInitialChunks(chunks: Record): Set { + const cached = initialChunksCache.get(chunks); + if (cached) { + return cached; + } + + const initial = new Set(); + const queue = Object.values(chunks).filter(chunk => chunk.isEntry); + + for (let chunk = queue.shift(); chunk; chunk = queue.shift()) { + if (initial.has(chunk.fileName)) { + continue; + } + initial.add(chunk.fileName); + for (const imported of chunk.imports ?? []) { + const importedChunk = chunks[imported]; + if (importedChunk) { + queue.push(importedChunk); + } + } + } + + initialChunksCache.set(chunks, initial); + return initial; +} + +/** + * Whether a chunk loads at startup: it is an entry chunk, or an entry chunk + * reaches it through static imports. Chunks that only dynamic imports reach + * load later, after startup code already ran. + * + * Without the chunk graph every chunk counts as initial. + */ +export function isInitialChunk( + chunk: Pick, + chunks: Record | undefined, +): boolean { + if (chunk.isEntry || !chunks) { + return true; + } + + return getInitialChunks(chunks).has(chunk.fileName); +} + export { globFiles } from './glob'; export { getCodeInjectionPosition } from './get-code-injection-position'; diff --git a/packages/bundler-plugins/src/rollup/index.ts b/packages/bundler-plugins/src/rollup/index.ts index b3cc6861f55e..227f162eae8f 100644 --- a/packages/bundler-plugins/src/rollup/index.ts +++ b/packages/bundler-plugins/src/rollup/index.ts @@ -1,9 +1,10 @@ -import type { Options } from '../core'; +import type { ChunkGraphNode, Options } from '../core'; import { createSentryBuildPluginManager, generateReleaseInjectorCode, generateModuleMetadataInjectorCode, isJsFile, + isInitialChunk, shouldSkipCodeInjection, getDebugIdSnippet, createDebugIdUploadFunction, @@ -115,7 +116,8 @@ export function _rollupPluginInternal( const freeGlobalDependencyOnBuildArtifacts = createDependencyOnBuildArtifacts(); const upload = createDebugIdUploadFunction({ sentryBuildPluginManager }); const sourcemapsEnabled = options.sourcemaps?.disable !== true; - const staticInjectionCode = new CodeInjection(); + const releaseInjectionCode = new CodeInjection(); + const chunkInjectionCode = new CodeInjection(); if (!options.release.inject) { logger.debug('Release injection disabled via `release.inject` option. Will not inject release.'); @@ -124,7 +126,7 @@ export function _rollupPluginInternal( 'No release name provided. Will not inject release. Please set the `release.name` option to identify your release.', ); } else { - staticInjectionCode.append( + releaseInjectionCode.append( generateReleaseInjectorCode({ release: options.release.name, injectBuildInformation: options._experiments.injectBuildInformation || false, @@ -133,7 +135,7 @@ export function _rollupPluginInternal( } if (Object.keys(bundleMetadata).length > 0) { - staticInjectionCode.append(generateModuleMetadataInjectorCode(bundleMetadata)); + chunkInjectionCode.append(generateModuleMetadataInjectorCode(bundleMetadata)); } const transformAnnotations = options.reactComponentAnnotation?.enabled @@ -181,9 +183,9 @@ export function _rollupPluginInternal( function renderChunk( this: PluginContext | undefined, code: string, - chunk: { fileName: string; facadeModuleId?: string | null }, + chunk: { fileName: string; facadeModuleId?: string | null; isEntry?: boolean }, _?: unknown, - meta?: { magicString?: MagicString }, + meta?: { magicString?: MagicString; chunks?: Record }, ): { code: string; map?: SourceMap; @@ -197,7 +199,13 @@ export function _rollupPluginInternal( return null; } - const injectCode = staticInjectionCode.clone(); + const injectCode = new CodeInjection(); + + if (isInitialChunk(chunk, meta?.chunks)) { + injectCode.append(releaseInjectionCode); + } + + injectCode.append(chunkInjectionCode); if (sourcemapsEnabled && !hasExistingDebugID(code)) { const debugId = getDebugIdForChunk(code, !!this?.meta?.rolldownVersion); diff --git a/packages/bundler-plugins/src/webpack/index.ts b/packages/bundler-plugins/src/webpack/index.ts index a86f81894821..84600ff47634 100644 --- a/packages/bundler-plugins/src/webpack/index.ts +++ b/packages/bundler-plugins/src/webpack/index.ts @@ -107,6 +107,7 @@ type WebpackCompilation = { files: Iterable; hash?: string; contentHash?: { javascript?: string }; + canBeInitial?: () => boolean; }>; outputOptions: { path?: string; @@ -198,7 +199,8 @@ function createSentryWebpackPlugin(userOptions: SentryWebpackPluginOptions = {}) } const sourcemapsEnabled = options.sourcemaps?.disable !== true; - const staticInjectionCode = new CodeInjection(); + const releaseInjectionCode = new CodeInjection(); + const chunkInjectionCode = new CodeInjection(); if (!options.release.inject) { logger.debug('Release injection disabled via `release.inject` option. Will not inject release.'); @@ -207,7 +209,7 @@ function createSentryWebpackPlugin(userOptions: SentryWebpackPluginOptions = {}) 'No release name provided. Will not inject release. Please set the `release.name` option to identify your release.', ); } else { - staticInjectionCode.append( + releaseInjectionCode.append( generateReleaseInjectorCode({ release: options.release.name, injectBuildInformation: options._experiments.injectBuildInformation || false, @@ -216,7 +218,7 @@ function createSentryWebpackPlugin(userOptions: SentryWebpackPluginOptions = {}) } if (Object.keys(bundleMetadata).length > 0) { - staticInjectionCode.append(generateModuleMetadataInjectorCode(bundleMetadata)); + chunkInjectionCode.append(generateModuleMetadataInjectorCode(bundleMetadata)); } const transformAnnotations = options.reactComponentAnnotation?.enabled @@ -230,7 +232,7 @@ function createSentryWebpackPlugin(userOptions: SentryWebpackPluginOptions = {}) const transformReplace = Object.keys(replacementValues).length > 0; function addCodeInjection(compiler: WebpackCompiler): void { - if (staticInjectionCode.isEmpty() && !sourcemapsEnabled) { + if (releaseInjectionCode.isEmpty() && chunkInjectionCode.isEmpty() && !sourcemapsEnabled) { return; } @@ -267,7 +269,11 @@ function createSentryWebpackPlugin(userOptions: SentryWebpackPluginOptions = {}) const sourceContents = source.source(); const code = typeof sourceContents === 'string' ? sourceContents : Buffer.from(sourceContents).toString(); - const codeToInject = staticInjectionCode.clone(); + const codeToInject = new CodeInjection(); + if (chunk.canBeInitial?.() ?? true) { + codeToInject.append(releaseInjectionCode); + } + codeToInject.append(chunkInjectionCode); if (sourcemapsEnabled) { const hash = chunk.contentHash?.javascript ?? chunk.hash; codeToInject.append(getDebugIdSnippet(hash ? stringToUUID(hash) : randomUUID())); diff --git a/packages/bundler-plugins/test/core/index.test.ts b/packages/bundler-plugins/test/core/index.test.ts index d5387fab9da7..3a0593bc4ec7 100644 --- a/packages/bundler-plugins/test/core/index.test.ts +++ b/packages/bundler-plugins/test/core/index.test.ts @@ -1,4 +1,4 @@ -import { createComponentNameAnnotateHooks, getDebugIdSnippet } from '../../src/core'; +import { createComponentNameAnnotateHooks, getDebugIdSnippet, isInitialChunk } from '../../src/core'; import { getOxcParseAstAsync } from '../../src/core/component-annotation-oxc'; import { containsOnlyImports } from '../../src/core/utils'; import { describe, it, expect, vi } from 'vitest'; @@ -63,6 +63,31 @@ describe('getDebugIdSnippet', () => { }); }); +describe('isInitialChunk', () => { + const chunks = { + 'entry.js': { fileName: 'entry.js', isEntry: true, imports: ['shared.js', 'external'] }, + 'shared.js': { fileName: 'shared.js', imports: ['vendor.js'] }, + 'vendor.js': { fileName: 'vendor.js', imports: ['shared.js'] }, + 'route.js': { fileName: 'route.js', imports: ['route-shared.js'] }, + 'route-shared.js': { fileName: 'route-shared.js', imports: [] }, + }; + + it('marks entry chunks and chunks they reach through static imports as initial', () => { + expect(isInitialChunk(chunks['entry.js'], chunks)).toBe(true); + expect(isInitialChunk(chunks['shared.js'], chunks)).toBe(true); + expect(isInitialChunk(chunks['vendor.js'], chunks)).toBe(true); + }); + + it('does not mark chunks that only dynamic imports reach as initial', () => { + expect(isInitialChunk(chunks['route.js'], chunks)).toBe(false); + expect(isInitialChunk(chunks['route-shared.js'], chunks)).toBe(false); + }); + + it('treats every chunk as initial without a chunk graph', () => { + expect(isInitialChunk({ fileName: 'route.js' }, undefined)).toBe(true); + }); +}); + describe('containsOnlyImports', () => { describe('should return true (import-only code)', () => { it.each([ diff --git a/packages/bundler-plugins/test/rollup/release-injection.test.ts b/packages/bundler-plugins/test/rollup/release-injection.test.ts new file mode 100644 index 000000000000..990f3415f65a --- /dev/null +++ b/packages/bundler-plugins/test/rollup/release-injection.test.ts @@ -0,0 +1,103 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { rollup } from 'rollup'; +import { rolldown } from 'rolldown'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { sentryRollupPlugin } from '../../src/rollup'; +import { sentryVitePlugin } from '../../src/vite'; + +const RELEASE_SNIPPET = /SENTRY_RELEASE\s*=\s*\{\s*id:\s*"1\.2\.3"\s*\}/; +const DEBUG_ID_MARKER = /sentry-dbid-[0-9a-f-]{36}/; + +function readChunks(outDir: string): Record { + const chunks: Record = {}; + for (const fileName of fs.readdirSync(outDir)) { + if (fileName.endsWith('.js')) { + chunks[fileName] = fs.readFileSync(path.join(outDir, fileName), 'utf8'); + } + } + return chunks; +} + +function findChunk(chunks: Record, content: string): string { + const match = Object.entries(chunks).find(([, code]) => code.includes(content)); + expect(match, `no chunk contains ${content}`).toBeDefined(); + return match?.[1] ?? ''; +} + +describe('release injection', () => { + let tmpDir: string; + let entries: string[]; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sentry-rollup-release-injection-')); + entries = [path.join(tmpDir, 'entry.js'), path.join(tmpDir, 'second.js')]; + fs.writeFileSync( + entries[0] ?? '', + 'import { shared } from "./shared.js";\nconsole.log("entry", shared);\nimport("./route.js");\n', + ); + fs.writeFileSync(entries[1] ?? '', 'import { shared } from "./shared.js";\nconsole.log("second", shared);\n'); + fs.writeFileSync(path.join(tmpDir, 'shared.js'), 'export const shared = "shared";\nconsole.log(shared);\n'); + fs.writeFileSync( + path.join(tmpDir, 'route.js'), + 'import { shared } from "./shared.js";\nconsole.log("route", shared);\n', + ); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('injects the release only into chunks that load at startup (rollup)', async () => { + const outDir = path.join(tmpDir, 'dist'); + const build = await rollup({ + input: entries, + plugins: [sentryRollupPlugin({ telemetry: false, release: { name: '1.2.3', create: false } })], + }); + await build.write({ dir: outDir, chunkFileNames: '[name].js' }); + + const chunks = readChunks(outDir); + expect(Object.keys(chunks)).toHaveLength(4); + + const entryChunk = findChunk(chunks, '"entry"'); + const secondChunk = findChunk(chunks, '"second"'); + const sharedChunk = findChunk(chunks, '"shared"'); + const routeChunk = findChunk(chunks, '"route"'); + + expect(entryChunk).toMatch(RELEASE_SNIPPET); + expect(secondChunk).toMatch(RELEASE_SNIPPET); + expect(sharedChunk).toMatch(RELEASE_SNIPPET); + expect(routeChunk).not.toMatch(RELEASE_SNIPPET); + + expect(entryChunk).toMatch(DEBUG_ID_MARKER); + expect(sharedChunk).toMatch(DEBUG_ID_MARKER); + expect(routeChunk).toMatch(DEBUG_ID_MARKER); + }); + + it('injects the release only into chunks that load at startup (rolldown / vite)', async () => { + const outDir = path.join(tmpDir, 'dist'); + const build = await rolldown({ + input: entries, + plugins: sentryVitePlugin({ telemetry: false, release: { name: '1.2.3', create: false } }), + }); + await build.write({ dir: outDir, chunkFileNames: '[name].js' }); + + const chunks = readChunks(outDir); + expect(Object.keys(chunks)).toHaveLength(4); + + const entryChunk = findChunk(chunks, '"entry"'); + const secondChunk = findChunk(chunks, '"second"'); + const sharedChunk = findChunk(chunks, '"shared"'); + const routeChunk = findChunk(chunks, '"route"'); + + expect(entryChunk).toMatch(RELEASE_SNIPPET); + expect(secondChunk).toMatch(RELEASE_SNIPPET); + expect(sharedChunk).toMatch(RELEASE_SNIPPET); + expect(routeChunk).not.toMatch(RELEASE_SNIPPET); + + expect(entryChunk).toMatch(DEBUG_ID_MARKER); + expect(sharedChunk).toMatch(DEBUG_ID_MARKER); + expect(routeChunk).toMatch(DEBUG_ID_MARKER); + }); +}); diff --git a/packages/bundler-plugins/test/webpack/release-injection.test.ts b/packages/bundler-plugins/test/webpack/release-injection.test.ts new file mode 100644 index 000000000000..55ee52f5a944 --- /dev/null +++ b/packages/bundler-plugins/test/webpack/release-injection.test.ts @@ -0,0 +1,66 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { webpack } from 'webpack'; +import type { Configuration, Stats } from 'webpack'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { sentryWebpackPlugin } from '../../src/webpack/index'; + +const RELEASE_SNIPPET = /SENTRY_RELEASE\s*=\s*\{\s*id:\s*"1\.2\.3"\s*\}/; +const DEBUG_ID_MARKER = /sentry-dbid-[0-9a-f-]{36}/; + +function build(config: Configuration): Promise { + return new Promise((resolve, reject) => { + webpack(config, (err, stats) => { + if (err) { + return reject(err); + } + if (!stats || stats.hasErrors()) { + return reject(new Error(stats?.toString() ?? 'no stats')); + } + resolve(stats); + }); + }); +} + +describe('release injection', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sentry-webpack-release-injection-')); + fs.writeFileSync( + path.join(tmpDir, 'entry.js'), + 'import { shared } from "./shared.js";\nconsole.log("entry", shared);\nimport(/* webpackChunkName: "route" */ "./route.js");\n', + ); + fs.writeFileSync(path.join(tmpDir, 'shared.js'), 'export const shared = "shared";\nconsole.log(shared);\n'); + fs.writeFileSync( + path.join(tmpDir, 'route.js'), + 'import { shared } from "./shared.js";\nconsole.log("route", shared);\n', + ); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('injects the release only into initial chunks', async () => { + const outDir = path.join(tmpDir, 'dist'); + + await build({ + mode: 'production', + context: tmpDir, + entry: './entry.js', + output: { path: outDir, filename: '[name].js', chunkFilename: '[name].js' }, + plugins: [sentryWebpackPlugin({ telemetry: false, release: { name: '1.2.3', create: false } })], + }); + + const mainChunk = fs.readFileSync(path.join(outDir, 'main.js'), 'utf8'); + const routeChunk = fs.readFileSync(path.join(outDir, 'route.js'), 'utf8'); + + expect(mainChunk).toMatch(RELEASE_SNIPPET); + expect(routeChunk).not.toMatch(RELEASE_SNIPPET); + + expect(mainChunk).toMatch(DEBUG_ID_MARKER); + expect(routeChunk).toMatch(DEBUG_ID_MARKER); + }); +});