Skip to content

Commit b2b5374

Browse files
committed
fix(@angular/build): include source map hashes in optimized chunk hashes
When `sourceMap.scripts` is enabled, `injectDebugIds` appends a `//# debugId=<uuid>` comment derived from the chunk's source map content to each emitted browser JS file after chunk optimization. While esbuild includes source map content when computing output chunk hashes, `optimizeChunks` strips the esbuild hash and relies on Rolldown to compute `[hash]` for optimized chunks. Because Rolldown only hashed the JS code and not the input source maps, a source change that only altered the source map (such as adding or removing type definitions or comments that shifted line numbers) changed the `debugId` bytes in the emitted JS file and its SRI hash in `index.html` without changing the chunk's filename. Implement `augmentChunkHash` in the chunk optimizer's `angular-bundle` plugin to include the input source map hashes when source maps are enabled, and invalidate cached `BuildOutputFile` hashes when `contents` are mutated. Closes #34574
1 parent 47bc9c3 commit b2b5374

3 files changed

Lines changed: 102 additions & 1 deletion

File tree

‎packages/angular/build/src/builders/application/chunk-optimizer.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,21 @@ export async function optimizeChunks(
278278

279279
return result;
280280
},
281+
augmentChunkHash(chunk: { moduleIds: string[] }) {
282+
if (!sourcemap) {
283+
return;
284+
}
285+
286+
let hash = '';
287+
for (const id of chunk.moduleIds) {
288+
const mapHash = maps[id]?.hash;
289+
if (mapHash) {
290+
hash += mapHash;
291+
}
292+
}
293+
294+
return hash || undefined;
295+
},
281296
},
282297
];
283298

‎packages/angular/build/src/builders/application/tests/options/subresource-integrity_spec.ts‎

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { createHash } from 'node:crypto';
1111
import { readdirSync, readFileSync } from 'node:fs';
1212
import { join } from 'node:path';
1313
import { buildApplication } from '../../index';
14+
import { OutputHashing } from '../../schema';
1415
import {
1516
APPLICATION_BUILDER_INFO,
1617
BASE_OPTIONS,
@@ -254,5 +255,82 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
254255
/const scripts = \[\[(?:'[^']+', "module", false, false, "sha384-[^"]+", "anonymous"(?:, )?)+\]\];/,
255256
);
256257
});
258+
259+
it(`updates optimized chunk filename when source map changes cause debugId to change`, async () => {
260+
await harness.writeFiles({
261+
'src/app/shared-label.ts': `
262+
export interface LabelInput {
263+
text: string;
264+
}
265+
export function formatLabel(input: LabelInput): string {
266+
return 'SHARED_LABEL:' + input.text;
267+
}
268+
`,
269+
'src/app/lazy-a.ts': `
270+
import { formatLabel } from './shared-label';
271+
export const a = formatLabel({ text: 'a' });
272+
`,
273+
'src/app/lazy-b.ts': `
274+
import { formatLabel } from './shared-label';
275+
export const b = formatLabel({ text: 'b' });
276+
`,
277+
'src/app/lazy-c.ts': `
278+
export const c = 'c';
279+
`,
280+
'src/main.ts': `
281+
console.log(
282+
import('./app/lazy-a'),
283+
import('./app/lazy-b'),
284+
import('./app/lazy-c'),
285+
);
286+
`,
287+
});
288+
289+
harness.useTarget('build', {
290+
...BASE_OPTIONS,
291+
optimization: true,
292+
outputHashing: OutputHashing.All,
293+
sourceMap: { scripts: true },
294+
subresourceIntegrity: true,
295+
});
296+
297+
const firstBuild = await harness.executeOnce();
298+
expect(firstBuild.result?.success).toBeTrue();
299+
300+
const distDir = workspacePath('dist/browser');
301+
const findSharedChunk = () => {
302+
const files = readdirSync(distDir).filter((f) => f.endsWith('.js'));
303+
for (const file of files) {
304+
const content = readFileSync(join(distDir, file), 'utf-8');
305+
if (content.includes('SHARED_LABEL:')) {
306+
return { file, content };
307+
}
308+
}
309+
throw new Error('Shared label chunk not found');
310+
};
311+
312+
const chunk1 = findSharedChunk();
313+
314+
// Make a type-only change that shifts line numbers in the source map without changing emitted JS code.
315+
await harness.writeFile(
316+
'src/app/shared-label.ts',
317+
`
318+
export interface LabelInput {
319+
text: string;
320+
extra?: number;
321+
}
322+
export function formatLabel(input: LabelInput): string {
323+
return 'SHARED_LABEL:' + input.text;
324+
}
325+
`,
326+
);
327+
328+
const secondBuild = await harness.executeOnce();
329+
expect(secondBuild.result?.success).toBeTrue();
330+
331+
const chunk2 = findSharedChunk();
332+
expect(chunk2.content).not.toBe(chunk1.content);
333+
expect(chunk2.file).not.toBe(chunk1.file);
334+
});
257335
});
258336
});

‎packages/angular/build/src/tools/esbuild/bundler-files.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ export function createOutputFile(
104104
set contents(value: Uint8Array) {
105105
cachedContents = value;
106106
cachedText = null;
107+
cachedHash = null;
107108
},
108109
get text(): string {
109110
cachedText ??= new TextDecoder('utf-8').decode(this.contents);
@@ -134,6 +135,7 @@ export function createOutputFile(
134135
set contents(value: Uint8Array) {
135136
cachedContents = value;
136137
cachedText = null;
138+
cachedHash = null;
137139
},
138140
path,
139141
type,
@@ -160,6 +162,7 @@ export function createOutputFile(
160162
export function convertOutputFile(file: OutputFile, type: BuildOutputFileType): BuildOutputFile {
161163
let { contents: cachedContents } = file;
162164
let cachedText: string | null = null;
165+
let cachedHash: string | null = file.hash;
163166

164167
return {
165168
get contents(): Uint8Array {
@@ -168,8 +171,13 @@ export function convertOutputFile(file: OutputFile, type: BuildOutputFileType):
168171
set contents(value: Uint8Array) {
169172
cachedContents = value;
170173
cachedText = null;
174+
cachedHash = null;
175+
},
176+
get hash(): string {
177+
cachedHash ??= calculateHash(cachedContents);
178+
179+
return cachedHash;
171180
},
172-
hash: file.hash,
173181
path: file.path,
174182
type,
175183
get size(): number {

0 commit comments

Comments
 (0)