From b2b5374bff23afd5c7e6cdb4bbfc672aca08434c Mon Sep 17 00:00:00 2001 From: Alan Agius <17563226+alan-agius4@users.noreply.github.com> Date: Thu, 29 Oct 2026 08:26:34 +0000 Subject: [PATCH] fix(@angular/build): include source map hashes in optimized chunk hashes When `sourceMap.scripts` is enabled, `injectDebugIds` appends a `//# debugId=` 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 --- .../builders/application/chunk-optimizer.ts | 15 ++++ .../options/subresource-integrity_spec.ts | 78 +++++++++++++++++++ .../build/src/tools/esbuild/bundler-files.ts | 10 ++- 3 files changed, 102 insertions(+), 1 deletion(-) diff --git a/packages/angular/build/src/builders/application/chunk-optimizer.ts b/packages/angular/build/src/builders/application/chunk-optimizer.ts index 7941ebe1f6a7..ab9efda1e2fc 100644 --- a/packages/angular/build/src/builders/application/chunk-optimizer.ts +++ b/packages/angular/build/src/builders/application/chunk-optimizer.ts @@ -278,6 +278,21 @@ export async function optimizeChunks( return result; }, + augmentChunkHash(chunk: { moduleIds: string[] }) { + if (!sourcemap) { + return; + } + + let hash = ''; + for (const id of chunk.moduleIds) { + const mapHash = maps[id]?.hash; + if (mapHash) { + hash += mapHash; + } + } + + return hash || undefined; + }, }, ]; diff --git a/packages/angular/build/src/builders/application/tests/options/subresource-integrity_spec.ts b/packages/angular/build/src/builders/application/tests/options/subresource-integrity_spec.ts index d3319b099491..8b74d88421b5 100644 --- a/packages/angular/build/src/builders/application/tests/options/subresource-integrity_spec.ts +++ b/packages/angular/build/src/builders/application/tests/options/subresource-integrity_spec.ts @@ -11,6 +11,7 @@ import { createHash } from 'node:crypto'; import { readdirSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { buildApplication } from '../../index'; +import { OutputHashing } from '../../schema'; import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, @@ -254,5 +255,82 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { /const scripts = \[\[(?:'[^']+', "module", false, false, "sha384-[^"]+", "anonymous"(?:, )?)+\]\];/, ); }); + + it(`updates optimized chunk filename when source map changes cause debugId to change`, async () => { + await harness.writeFiles({ + 'src/app/shared-label.ts': ` + export interface LabelInput { + text: string; + } + export function formatLabel(input: LabelInput): string { + return 'SHARED_LABEL:' + input.text; + } + `, + 'src/app/lazy-a.ts': ` + import { formatLabel } from './shared-label'; + export const a = formatLabel({ text: 'a' }); + `, + 'src/app/lazy-b.ts': ` + import { formatLabel } from './shared-label'; + export const b = formatLabel({ text: 'b' }); + `, + 'src/app/lazy-c.ts': ` + export const c = 'c'; + `, + 'src/main.ts': ` + console.log( + import('./app/lazy-a'), + import('./app/lazy-b'), + import('./app/lazy-c'), + ); + `, + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + optimization: true, + outputHashing: OutputHashing.All, + sourceMap: { scripts: true }, + subresourceIntegrity: true, + }); + + const firstBuild = await harness.executeOnce(); + expect(firstBuild.result?.success).toBeTrue(); + + const distDir = workspacePath('dist/browser'); + const findSharedChunk = () => { + const files = readdirSync(distDir).filter((f) => f.endsWith('.js')); + for (const file of files) { + const content = readFileSync(join(distDir, file), 'utf-8'); + if (content.includes('SHARED_LABEL:')) { + return { file, content }; + } + } + throw new Error('Shared label chunk not found'); + }; + + const chunk1 = findSharedChunk(); + + // Make a type-only change that shifts line numbers in the source map without changing emitted JS code. + await harness.writeFile( + 'src/app/shared-label.ts', + ` + export interface LabelInput { + text: string; + extra?: number; + } + export function formatLabel(input: LabelInput): string { + return 'SHARED_LABEL:' + input.text; + } + `, + ); + + const secondBuild = await harness.executeOnce(); + expect(secondBuild.result?.success).toBeTrue(); + + const chunk2 = findSharedChunk(); + expect(chunk2.content).not.toBe(chunk1.content); + expect(chunk2.file).not.toBe(chunk1.file); + }); }); }); diff --git a/packages/angular/build/src/tools/esbuild/bundler-files.ts b/packages/angular/build/src/tools/esbuild/bundler-files.ts index af4b04ad9e56..f69835ab746b 100644 --- a/packages/angular/build/src/tools/esbuild/bundler-files.ts +++ b/packages/angular/build/src/tools/esbuild/bundler-files.ts @@ -104,6 +104,7 @@ export function createOutputFile( set contents(value: Uint8Array) { cachedContents = value; cachedText = null; + cachedHash = null; }, get text(): string { cachedText ??= new TextDecoder('utf-8').decode(this.contents); @@ -134,6 +135,7 @@ export function createOutputFile( set contents(value: Uint8Array) { cachedContents = value; cachedText = null; + cachedHash = null; }, path, type, @@ -160,6 +162,7 @@ export function createOutputFile( export function convertOutputFile(file: OutputFile, type: BuildOutputFileType): BuildOutputFile { let { contents: cachedContents } = file; let cachedText: string | null = null; + let cachedHash: string | null = file.hash; return { get contents(): Uint8Array { @@ -168,8 +171,13 @@ export function convertOutputFile(file: OutputFile, type: BuildOutputFileType): set contents(value: Uint8Array) { cachedContents = value; cachedText = null; + cachedHash = null; + }, + get hash(): string { + cachedHash ??= calculateHash(cachedContents); + + return cachedHash; }, - hash: file.hash, path: file.path, type, get size(): number {