Skip to content

Commit 3d47e12

Browse files
authored
fix(bundler-plugins): Inject the release only into chunks that load at startup (#25541)
The Rollup, Vite and webpack plugins added the `SENTRY_RELEASE` snippet to every JS chunk. On apps with many lazily loaded chunks that is a lot of repeated output, and the global only needs to exist before `Sentry.init` runs. The release snippet now goes only into entry chunks and the chunks they reach through static imports. Debug ID and module metadata injection stay per chunk. esbuild already injects per entry point and does not change. A plain `chunk.isEntry` check is not enough: Vite multi-page builds drop pure-import HTML facade chunks and put the app code in non-entry shared chunks. The Rollup plugin walks the chunk graph that `renderChunk` receives, and injects everywhere when no graph is available. The webpack plugin uses `chunk.canBeInitial()`.
1 parent 1130080 commit 3d47e12

6 files changed

Lines changed: 273 additions & 13 deletions

File tree

‎packages/bundler-plugins/src/core/index.ts‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,58 @@ export function shouldSkipCodeInjection(code: string, facadeModuleId: string | n
3737
return false;
3838
}
3939

40+
export interface ChunkGraphNode {
41+
fileName: string;
42+
isEntry?: boolean;
43+
imports?: string[];
44+
}
45+
46+
const initialChunksCache = new WeakMap<Record<string, ChunkGraphNode>, Set<string>>();
47+
48+
function getInitialChunks(chunks: Record<string, ChunkGraphNode>): Set<string> {
49+
const cached = initialChunksCache.get(chunks);
50+
if (cached) {
51+
return cached;
52+
}
53+
54+
const initial = new Set<string>();
55+
const queue = Object.values(chunks).filter(chunk => chunk.isEntry);
56+
57+
for (let chunk = queue.shift(); chunk; chunk = queue.shift()) {
58+
if (initial.has(chunk.fileName)) {
59+
continue;
60+
}
61+
initial.add(chunk.fileName);
62+
for (const imported of chunk.imports ?? []) {
63+
const importedChunk = chunks[imported];
64+
if (importedChunk) {
65+
queue.push(importedChunk);
66+
}
67+
}
68+
}
69+
70+
initialChunksCache.set(chunks, initial);
71+
return initial;
72+
}
73+
74+
/**
75+
* Whether a chunk loads at startup: it is an entry chunk, or an entry chunk
76+
* reaches it through static imports. Chunks that only dynamic imports reach
77+
* load later, after startup code already ran.
78+
*
79+
* Without the chunk graph every chunk counts as initial.
80+
*/
81+
export function isInitialChunk(
82+
chunk: Pick<ChunkGraphNode, 'fileName' | 'isEntry'>,
83+
chunks: Record<string, ChunkGraphNode> | undefined,
84+
): boolean {
85+
if (chunk.isEntry || !chunks) {
86+
return true;
87+
}
88+
89+
return getInitialChunks(chunks).has(chunk.fileName);
90+
}
91+
4092
export { globFiles } from './glob';
4193
export { getCodeInjectionPosition } from './get-code-injection-position';
4294

‎packages/bundler-plugins/src/rollup/index.ts‎

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import type { Options } from '../core';
1+
import type { ChunkGraphNode, Options } from '../core';
22
import {
33
createSentryBuildPluginManager,
44
generateReleaseInjectorCode,
55
generateModuleMetadataInjectorCode,
66
isJsFile,
7+
isInitialChunk,
78
shouldSkipCodeInjection,
89
getDebugIdSnippet,
910
createDebugIdUploadFunction,
@@ -115,7 +116,8 @@ export function _rollupPluginInternal(
115116
const freeGlobalDependencyOnBuildArtifacts = createDependencyOnBuildArtifacts();
116117
const upload = createDebugIdUploadFunction({ sentryBuildPluginManager });
117118
const sourcemapsEnabled = options.sourcemaps?.disable !== true;
118-
const staticInjectionCode = new CodeInjection();
119+
const releaseInjectionCode = new CodeInjection();
120+
const chunkInjectionCode = new CodeInjection();
119121

120122
if (!options.release.inject) {
121123
logger.debug('Release injection disabled via `release.inject` option. Will not inject release.');
@@ -124,7 +126,7 @@ export function _rollupPluginInternal(
124126
'No release name provided. Will not inject release. Please set the `release.name` option to identify your release.',
125127
);
126128
} else {
127-
staticInjectionCode.append(
129+
releaseInjectionCode.append(
128130
generateReleaseInjectorCode({
129131
release: options.release.name,
130132
injectBuildInformation: options._experiments.injectBuildInformation || false,
@@ -133,7 +135,7 @@ export function _rollupPluginInternal(
133135
}
134136

135137
if (Object.keys(bundleMetadata).length > 0) {
136-
staticInjectionCode.append(generateModuleMetadataInjectorCode(bundleMetadata));
138+
chunkInjectionCode.append(generateModuleMetadataInjectorCode(bundleMetadata));
137139
}
138140

139141
const transformAnnotations = options.reactComponentAnnotation?.enabled
@@ -181,9 +183,9 @@ export function _rollupPluginInternal(
181183
function renderChunk(
182184
this: PluginContext | undefined,
183185
code: string,
184-
chunk: { fileName: string; facadeModuleId?: string | null },
186+
chunk: { fileName: string; facadeModuleId?: string | null; isEntry?: boolean },
185187
_?: unknown,
186-
meta?: { magicString?: MagicString },
188+
meta?: { magicString?: MagicString; chunks?: Record<string, ChunkGraphNode> },
187189
): {
188190
code: string;
189191
map?: SourceMap;
@@ -197,7 +199,13 @@ export function _rollupPluginInternal(
197199
return null;
198200
}
199201

200-
const injectCode = staticInjectionCode.clone();
202+
const injectCode = new CodeInjection();
203+
204+
if (isInitialChunk(chunk, meta?.chunks)) {
205+
injectCode.append(releaseInjectionCode);
206+
}
207+
208+
injectCode.append(chunkInjectionCode);
201209

202210
if (sourcemapsEnabled && !hasExistingDebugID(code)) {
203211
const debugId = getDebugIdForChunk(code, !!this?.meta?.rolldownVersion);

‎packages/bundler-plugins/src/webpack/index.ts‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ type WebpackCompilation = {
107107
files: Iterable<string>;
108108
hash?: string;
109109
contentHash?: { javascript?: string };
110+
canBeInitial?: () => boolean;
110111
}>;
111112
outputOptions: {
112113
path?: string;
@@ -198,7 +199,8 @@ function createSentryWebpackPlugin(userOptions: SentryWebpackPluginOptions = {})
198199
}
199200

200201
const sourcemapsEnabled = options.sourcemaps?.disable !== true;
201-
const staticInjectionCode = new CodeInjection();
202+
const releaseInjectionCode = new CodeInjection();
203+
const chunkInjectionCode = new CodeInjection();
202204

203205
if (!options.release.inject) {
204206
logger.debug('Release injection disabled via `release.inject` option. Will not inject release.');
@@ -207,7 +209,7 @@ function createSentryWebpackPlugin(userOptions: SentryWebpackPluginOptions = {})
207209
'No release name provided. Will not inject release. Please set the `release.name` option to identify your release.',
208210
);
209211
} else {
210-
staticInjectionCode.append(
212+
releaseInjectionCode.append(
211213
generateReleaseInjectorCode({
212214
release: options.release.name,
213215
injectBuildInformation: options._experiments.injectBuildInformation || false,
@@ -216,7 +218,7 @@ function createSentryWebpackPlugin(userOptions: SentryWebpackPluginOptions = {})
216218
}
217219

218220
if (Object.keys(bundleMetadata).length > 0) {
219-
staticInjectionCode.append(generateModuleMetadataInjectorCode(bundleMetadata));
221+
chunkInjectionCode.append(generateModuleMetadataInjectorCode(bundleMetadata));
220222
}
221223

222224
const transformAnnotations = options.reactComponentAnnotation?.enabled
@@ -230,7 +232,7 @@ function createSentryWebpackPlugin(userOptions: SentryWebpackPluginOptions = {})
230232
const transformReplace = Object.keys(replacementValues).length > 0;
231233

232234
function addCodeInjection(compiler: WebpackCompiler): void {
233-
if (staticInjectionCode.isEmpty() && !sourcemapsEnabled) {
235+
if (releaseInjectionCode.isEmpty() && chunkInjectionCode.isEmpty() && !sourcemapsEnabled) {
234236
return;
235237
}
236238

@@ -267,7 +269,11 @@ function createSentryWebpackPlugin(userOptions: SentryWebpackPluginOptions = {})
267269

268270
const sourceContents = source.source();
269271
const code = typeof sourceContents === 'string' ? sourceContents : Buffer.from(sourceContents).toString();
270-
const codeToInject = staticInjectionCode.clone();
272+
const codeToInject = new CodeInjection();
273+
if (chunk.canBeInitial?.() ?? true) {
274+
codeToInject.append(releaseInjectionCode);
275+
}
276+
codeToInject.append(chunkInjectionCode);
271277
if (sourcemapsEnabled) {
272278
const hash = chunk.contentHash?.javascript ?? chunk.hash;
273279
codeToInject.append(getDebugIdSnippet(hash ? stringToUUID(hash) : randomUUID()));

‎packages/bundler-plugins/test/core/index.test.ts‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { createComponentNameAnnotateHooks, getDebugIdSnippet } from '../../src/core';
1+
import { createComponentNameAnnotateHooks, getDebugIdSnippet, isInitialChunk } from '../../src/core';
22
import { getOxcParseAstAsync } from '../../src/core/component-annotation-oxc';
33
import { containsOnlyImports } from '../../src/core/utils';
44
import { describe, it, expect, vi } from 'vitest';
@@ -63,6 +63,31 @@ describe('getDebugIdSnippet', () => {
6363
});
6464
});
6565

66+
describe('isInitialChunk', () => {
67+
const chunks = {
68+
'entry.js': { fileName: 'entry.js', isEntry: true, imports: ['shared.js', 'external'] },
69+
'shared.js': { fileName: 'shared.js', imports: ['vendor.js'] },
70+
'vendor.js': { fileName: 'vendor.js', imports: ['shared.js'] },
71+
'route.js': { fileName: 'route.js', imports: ['route-shared.js'] },
72+
'route-shared.js': { fileName: 'route-shared.js', imports: [] },
73+
};
74+
75+
it('marks entry chunks and chunks they reach through static imports as initial', () => {
76+
expect(isInitialChunk(chunks['entry.js'], chunks)).toBe(true);
77+
expect(isInitialChunk(chunks['shared.js'], chunks)).toBe(true);
78+
expect(isInitialChunk(chunks['vendor.js'], chunks)).toBe(true);
79+
});
80+
81+
it('does not mark chunks that only dynamic imports reach as initial', () => {
82+
expect(isInitialChunk(chunks['route.js'], chunks)).toBe(false);
83+
expect(isInitialChunk(chunks['route-shared.js'], chunks)).toBe(false);
84+
});
85+
86+
it('treats every chunk as initial without a chunk graph', () => {
87+
expect(isInitialChunk({ fileName: 'route.js' }, undefined)).toBe(true);
88+
});
89+
});
90+
6691
describe('containsOnlyImports', () => {
6792
describe('should return true (import-only code)', () => {
6893
it.each([
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import * as fs from 'fs';
2+
import * as os from 'os';
3+
import * as path from 'path';
4+
import { rollup } from 'rollup';
5+
import { rolldown } from 'rolldown';
6+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
7+
import { sentryRollupPlugin } from '../../src/rollup';
8+
import { sentryVitePlugin } from '../../src/vite';
9+
10+
const RELEASE_SNIPPET = /SENTRY_RELEASE\s*=\s*\{\s*id:\s*"1\.2\.3"\s*\}/;
11+
const DEBUG_ID_MARKER = /sentry-dbid-[0-9a-f-]{36}/;
12+
13+
function readChunks(outDir: string): Record<string, string> {
14+
const chunks: Record<string, string> = {};
15+
for (const fileName of fs.readdirSync(outDir)) {
16+
if (fileName.endsWith('.js')) {
17+
chunks[fileName] = fs.readFileSync(path.join(outDir, fileName), 'utf8');
18+
}
19+
}
20+
return chunks;
21+
}
22+
23+
function findChunk(chunks: Record<string, string>, content: string): string {
24+
const match = Object.entries(chunks).find(([, code]) => code.includes(content));
25+
expect(match, `no chunk contains ${content}`).toBeDefined();
26+
return match?.[1] ?? '';
27+
}
28+
29+
describe('release injection', () => {
30+
let tmpDir: string;
31+
let entries: string[];
32+
33+
beforeEach(() => {
34+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sentry-rollup-release-injection-'));
35+
entries = [path.join(tmpDir, 'entry.js'), path.join(tmpDir, 'second.js')];
36+
fs.writeFileSync(
37+
entries[0] ?? '',
38+
'import { shared } from "./shared.js";\nconsole.log("entry", shared);\nimport("./route.js");\n',
39+
);
40+
fs.writeFileSync(entries[1] ?? '', 'import { shared } from "./shared.js";\nconsole.log("second", shared);\n');
41+
fs.writeFileSync(path.join(tmpDir, 'shared.js'), 'export const shared = "shared";\nconsole.log(shared);\n');
42+
fs.writeFileSync(
43+
path.join(tmpDir, 'route.js'),
44+
'import { shared } from "./shared.js";\nconsole.log("route", shared);\n',
45+
);
46+
});
47+
48+
afterEach(() => {
49+
fs.rmSync(tmpDir, { recursive: true, force: true });
50+
});
51+
52+
it('injects the release only into chunks that load at startup (rollup)', async () => {
53+
const outDir = path.join(tmpDir, 'dist');
54+
const build = await rollup({
55+
input: entries,
56+
plugins: [sentryRollupPlugin({ telemetry: false, release: { name: '1.2.3', create: false } })],
57+
});
58+
await build.write({ dir: outDir, chunkFileNames: '[name].js' });
59+
60+
const chunks = readChunks(outDir);
61+
expect(Object.keys(chunks)).toHaveLength(4);
62+
63+
const entryChunk = findChunk(chunks, '"entry"');
64+
const secondChunk = findChunk(chunks, '"second"');
65+
const sharedChunk = findChunk(chunks, '"shared"');
66+
const routeChunk = findChunk(chunks, '"route"');
67+
68+
expect(entryChunk).toMatch(RELEASE_SNIPPET);
69+
expect(secondChunk).toMatch(RELEASE_SNIPPET);
70+
expect(sharedChunk).toMatch(RELEASE_SNIPPET);
71+
expect(routeChunk).not.toMatch(RELEASE_SNIPPET);
72+
73+
expect(entryChunk).toMatch(DEBUG_ID_MARKER);
74+
expect(sharedChunk).toMatch(DEBUG_ID_MARKER);
75+
expect(routeChunk).toMatch(DEBUG_ID_MARKER);
76+
});
77+
78+
it('injects the release only into chunks that load at startup (rolldown / vite)', async () => {
79+
const outDir = path.join(tmpDir, 'dist');
80+
const build = await rolldown({
81+
input: entries,
82+
plugins: sentryVitePlugin({ telemetry: false, release: { name: '1.2.3', create: false } }),
83+
});
84+
await build.write({ dir: outDir, chunkFileNames: '[name].js' });
85+
86+
const chunks = readChunks(outDir);
87+
expect(Object.keys(chunks)).toHaveLength(4);
88+
89+
const entryChunk = findChunk(chunks, '"entry"');
90+
const secondChunk = findChunk(chunks, '"second"');
91+
const sharedChunk = findChunk(chunks, '"shared"');
92+
const routeChunk = findChunk(chunks, '"route"');
93+
94+
expect(entryChunk).toMatch(RELEASE_SNIPPET);
95+
expect(secondChunk).toMatch(RELEASE_SNIPPET);
96+
expect(sharedChunk).toMatch(RELEASE_SNIPPET);
97+
expect(routeChunk).not.toMatch(RELEASE_SNIPPET);
98+
99+
expect(entryChunk).toMatch(DEBUG_ID_MARKER);
100+
expect(sharedChunk).toMatch(DEBUG_ID_MARKER);
101+
expect(routeChunk).toMatch(DEBUG_ID_MARKER);
102+
});
103+
});
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import * as fs from 'fs';
2+
import * as os from 'os';
3+
import * as path from 'path';
4+
import { webpack } from 'webpack';
5+
import type { Configuration, Stats } from 'webpack';
6+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
7+
import { sentryWebpackPlugin } from '../../src/webpack/index';
8+
9+
const RELEASE_SNIPPET = /SENTRY_RELEASE\s*=\s*\{\s*id:\s*"1\.2\.3"\s*\}/;
10+
const DEBUG_ID_MARKER = /sentry-dbid-[0-9a-f-]{36}/;
11+
12+
function build(config: Configuration): Promise<Stats> {
13+
return new Promise((resolve, reject) => {
14+
webpack(config, (err, stats) => {
15+
if (err) {
16+
return reject(err);
17+
}
18+
if (!stats || stats.hasErrors()) {
19+
return reject(new Error(stats?.toString() ?? 'no stats'));
20+
}
21+
resolve(stats);
22+
});
23+
});
24+
}
25+
26+
describe('release injection', () => {
27+
let tmpDir: string;
28+
29+
beforeEach(() => {
30+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sentry-webpack-release-injection-'));
31+
fs.writeFileSync(
32+
path.join(tmpDir, 'entry.js'),
33+
'import { shared } from "./shared.js";\nconsole.log("entry", shared);\nimport(/* webpackChunkName: "route" */ "./route.js");\n',
34+
);
35+
fs.writeFileSync(path.join(tmpDir, 'shared.js'), 'export const shared = "shared";\nconsole.log(shared);\n');
36+
fs.writeFileSync(
37+
path.join(tmpDir, 'route.js'),
38+
'import { shared } from "./shared.js";\nconsole.log("route", shared);\n',
39+
);
40+
});
41+
42+
afterEach(() => {
43+
fs.rmSync(tmpDir, { recursive: true, force: true });
44+
});
45+
46+
it('injects the release only into initial chunks', async () => {
47+
const outDir = path.join(tmpDir, 'dist');
48+
49+
await build({
50+
mode: 'production',
51+
context: tmpDir,
52+
entry: './entry.js',
53+
output: { path: outDir, filename: '[name].js', chunkFilename: '[name].js' },
54+
plugins: [sentryWebpackPlugin({ telemetry: false, release: { name: '1.2.3', create: false } })],
55+
});
56+
57+
const mainChunk = fs.readFileSync(path.join(outDir, 'main.js'), 'utf8');
58+
const routeChunk = fs.readFileSync(path.join(outDir, 'route.js'), 'utf8');
59+
60+
expect(mainChunk).toMatch(RELEASE_SNIPPET);
61+
expect(routeChunk).not.toMatch(RELEASE_SNIPPET);
62+
63+
expect(mainChunk).toMatch(DEBUG_ID_MARKER);
64+
expect(routeChunk).toMatch(DEBUG_ID_MARKER);
65+
});
66+
});

0 commit comments

Comments
 (0)