Skip to content

Commit d08a8ed

Browse files
nicohrubectimfishcodex
authored
fix(v10/bundler-plugins): Inject the release only into chunks that load at startup (#25695)
Backport of: #25541 ## Differences to the original PR - Applied webpack's initial-chunk check in v10's `webpack4and5.ts` BannerPlugin callback instead of v11's webpack-only implementation. This retains webpack 4 compatibility; release injection is restricted using the same `chunk.canBeInitial()` check, while debug IDs and module metadata remain per chunk. - Kept the v10 core-test imports, which do not include v11's Oxc annotation helper. - Added a `max-lines` lint suppression to the Rollup implementation because this change takes the existing file over v10's 300-line limit. - Guarded the Rolldown regression on Node 18 and moved its import inside the test, matching v10's existing test pattern. The Rollup regression still runs on Node 18. --------- Co-authored-by: Tim Fish <tim@timfish.uk> Co-authored-by: GPT-6 <codex@openai.com>
1 parent ad0225f commit d08a8ed

6 files changed

Lines changed: 280 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
@@ -84,6 +84,58 @@ export function shouldSkipCodeInjection(code: string, facadeModuleId: string | n
8484
return false;
8585
}
8686

87+
export interface ChunkGraphNode {
88+
fileName: string;
89+
isEntry?: boolean;
90+
imports?: string[];
91+
}
92+
93+
const initialChunksCache = new WeakMap<Record<string, ChunkGraphNode>, Set<string>>();
94+
95+
function getInitialChunks(chunks: Record<string, ChunkGraphNode>): Set<string> {
96+
const cached = initialChunksCache.get(chunks);
97+
if (cached) {
98+
return cached;
99+
}
100+
101+
const initial = new Set<string>();
102+
const queue = Object.values(chunks).filter(chunk => chunk.isEntry);
103+
104+
for (let chunk = queue.shift(); chunk; chunk = queue.shift()) {
105+
if (initial.has(chunk.fileName)) {
106+
continue;
107+
}
108+
initial.add(chunk.fileName);
109+
for (const imported of chunk.imports ?? []) {
110+
const importedChunk = chunks[imported];
111+
if (importedChunk) {
112+
queue.push(importedChunk);
113+
}
114+
}
115+
}
116+
117+
initialChunksCache.set(chunks, initial);
118+
return initial;
119+
}
120+
121+
/**
122+
* Whether a chunk loads at startup: it is an entry chunk, or an entry chunk
123+
* reaches it through static imports. Chunks that only dynamic imports reach
124+
* load later, after startup code already ran.
125+
*
126+
* Without the chunk graph every chunk counts as initial.
127+
*/
128+
export function isInitialChunk(
129+
chunk: Pick<ChunkGraphNode, 'fileName' | 'isEntry'>,
130+
chunks: Record<string, ChunkGraphNode> | undefined,
131+
): boolean {
132+
if (chunk.isEntry || !chunks) {
133+
return true;
134+
}
135+
136+
return getInitialChunks(chunks).has(chunk.fileName);
137+
}
138+
87139
export { globFiles } from './glob';
88140

89141
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type

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

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1-
import type { Options } from '../core';
1+
/* oxlint-disable max-lines */
2+
3+
import type { ChunkGraphNode, Options } from '../core';
24
import {
35
createSentryBuildPluginManager,
46
generateReleaseInjectorCode,
57
generateModuleMetadataInjectorCode,
68
isJsFile,
9+
isInitialChunk,
710
shouldSkipCodeInjection,
811
getDebugIdSnippet,
912
stringToUUID,
@@ -141,7 +144,8 @@ export function _rollupPluginInternal(
141144
const freeGlobalDependencyOnBuildArtifacts = createDependencyOnBuildArtifacts();
142145
const upload = createDebugIdUploadFunction({ sentryBuildPluginManager });
143146
const sourcemapsEnabled = options.sourcemaps?.disable !== true;
144-
const staticInjectionCode = new CodeInjection();
147+
const releaseInjectionCode = new CodeInjection();
148+
const chunkInjectionCode = new CodeInjection();
145149

146150
if (!options.release.inject) {
147151
logger.debug('Release injection disabled via `release.inject` option. Will not inject release.');
@@ -150,7 +154,7 @@ export function _rollupPluginInternal(
150154
'No release name provided. Will not inject release. Please set the `release.name` option to identify your release.',
151155
);
152156
} else {
153-
staticInjectionCode.append(
157+
releaseInjectionCode.append(
154158
generateReleaseInjectorCode({
155159
release: options.release.name,
156160
injectBuildInformation: options._experiments.injectBuildInformation || false,
@@ -159,7 +163,7 @@ export function _rollupPluginInternal(
159163
}
160164

161165
if (Object.keys(bundleMetadata).length > 0) {
162-
staticInjectionCode.append(generateModuleMetadataInjectorCode(bundleMetadata));
166+
chunkInjectionCode.append(generateModuleMetadataInjectorCode(bundleMetadata));
163167
}
164168

165169
const transformAnnotations = options.reactComponentAnnotation?.enabled
@@ -239,9 +243,9 @@ export function _rollupPluginInternal(
239243

240244
function renderChunk(
241245
code: string,
242-
chunk: { fileName: string; facadeModuleId?: string | null },
246+
chunk: { fileName: string; facadeModuleId?: string | null; isEntry?: boolean },
243247
_?: unknown,
244-
meta?: { magicString?: MagicString },
248+
meta?: { magicString?: MagicString; chunks?: Record<string, ChunkGraphNode> },
245249
): {
246250
code: string;
247251
map?: SourceMap;
@@ -255,7 +259,13 @@ export function _rollupPluginInternal(
255259
return null;
256260
}
257261

258-
const injectCode = staticInjectionCode.clone();
262+
const injectCode = new CodeInjection();
263+
264+
if (isInitialChunk(chunk, meta?.chunks)) {
265+
injectCode.append(releaseInjectionCode);
266+
}
267+
268+
injectCode.append(chunkInjectionCode);
259269

260270
if (sourcemapsEnabled && !hasExistingDebugID(code)) {
261271
const debugId = stringToUUID(code); // generate a deterministic debug ID

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

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ try {
4141
interface BannerPluginCallbackArg {
4242
chunk?: {
4343
hash?: string;
44+
canBeInitial?: () => boolean;
4445
contentHash?: {
4546
javascript?: string;
4647
};
@@ -208,7 +209,8 @@ export function sentryWebpackPluginFactory({
208209
}
209210

210211
const sourcemapsEnabled = options.sourcemaps?.disable !== true;
211-
const staticInjectionCode = new CodeInjection();
212+
const releaseInjectionCode = new CodeInjection();
213+
const chunkInjectionCode = new CodeInjection();
212214

213215
if (!options.release.inject) {
214216
logger.debug('Release injection disabled via `release.inject` option. Will not inject release.');
@@ -217,7 +219,7 @@ export function sentryWebpackPluginFactory({
217219
'No release name provided. Will not inject release. Please set the `release.name` option to identify your release.',
218220
);
219221
} else {
220-
staticInjectionCode.append(
222+
releaseInjectionCode.append(
221223
generateReleaseInjectorCode({
222224
release: options.release.name,
223225
injectBuildInformation: options._experiments.injectBuildInformation || false,
@@ -226,7 +228,7 @@ export function sentryWebpackPluginFactory({
226228
}
227229

228230
if (Object.keys(bundleMetadata).length > 0) {
229-
staticInjectionCode.append(generateModuleMetadataInjectorCode(bundleMetadata));
231+
chunkInjectionCode.append(generateModuleMetadataInjectorCode(bundleMetadata));
230232
}
231233

232234
const transformAnnotations = options.reactComponentAnnotation?.enabled
@@ -249,7 +251,7 @@ export function sentryWebpackPluginFactory({
249251
const DefinePlugin = compiler?.webpack?.DefinePlugin || UnsafeDefinePlugin;
250252

251253
// Add BannerPlugin for code injection (release, metadata, debug IDs)
252-
if (!staticInjectionCode.isEmpty() || sourcemapsEnabled) {
254+
if (!releaseInjectionCode.isEmpty() || !chunkInjectionCode.isEmpty() || sourcemapsEnabled) {
253255
if (!BannerPlugin) {
254256
logger.warn(
255257
'BannerPlugin is not available. Skipping code injection. This usually means webpack is not properly configured.',
@@ -261,7 +263,11 @@ export function sentryWebpackPluginFactory({
261263
raw: true,
262264
include: /\.(js|ts|jsx|tsx|mjs|cjs)(\?[^?]*)?(#[^#]*)?$/,
263265
banner: (arg?: BannerPluginCallbackArg) => {
264-
const codeToInject = staticInjectionCode.clone();
266+
const codeToInject = new CodeInjection();
267+
if (arg?.chunk?.canBeInitial?.() ?? true) {
268+
codeToInject.append(releaseInjectionCode);
269+
}
270+
codeToInject.append(chunkInjectionCode);
265271
if (sourcemapsEnabled) {
266272
const hash = arg?.chunk?.contentHash?.javascript ?? arg?.chunk?.hash;
267273
const debugId = 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 { containsOnlyImports } from '../../src/core/utils';
33
import { describe, it, expect } from 'vitest';
44

@@ -38,6 +38,31 @@ describe('getDebugIdSnippet', () => {
3838
});
3939
});
4040

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