diff --git a/packages/angular/build/src/builders/application/index-html-generator.ts b/packages/angular/build/src/builders/application/index-html-generator.ts index d89eadf8b9b0..a1c9cbcc9b30 100644 --- a/packages/angular/build/src/builders/application/index-html-generator.ts +++ b/packages/angular/build/src/builders/application/index-html-generator.ts @@ -91,8 +91,15 @@ export async function generateIndexHtml( let chunksIntegrity: ReadonlyMap | undefined; if (subresourceIntegrity) { const integrity = new Map(); + // Entry points and module preload hints carry their own integrity attribute. Any other + // initial chunk (e.g. beyond the module preload limit) is only covered by the import map. + const hinted = new Set(hints.map(({ url }) => url)); for (const file of browserOutputFiles) { - if (!file.path.endsWith('.js') || initialFiles.has(file.path)) { + if ( + !file.path.endsWith('.js') || + initialFiles.get(file.path)?.entrypoint || + hinted.has(file.path) + ) { continue; } const hash = createHash('sha384').update(file.contents).digest('base64'); diff --git a/packages/angular/build/src/builders/application/index-html-generator_spec.ts b/packages/angular/build/src/builders/application/index-html-generator_spec.ts new file mode 100644 index 000000000000..db5ed3a2425f --- /dev/null +++ b/packages/angular/build/src/builders/application/index-html-generator_spec.ts @@ -0,0 +1,105 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import assert from 'node:assert'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { + BuildOutputFileType, + InitialFileRecord, + createOutputFile, +} from '../../tools/esbuild/bundler-files'; +import { generateIndexHtml } from './index-html-generator'; +import { NormalizedApplicationBuildOptions } from './options'; + +describe('generateIndexHtml', () => { + let tempDir: string; + + beforeEach(async () => { + const baseTmpDir = process.env['TEST_TMPDIR']; + assert(baseTmpDir, 'TEST_TMPDIR is not set'); + tempDir = await mkdtemp(join(baseTmpDir, 'index-html-generator-spec-')); + await writeFile( + join(tempDir, 'index.html'), + '', + ); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + function createOptions( + subresourceIntegrity: boolean, + preloadInitial = true, + ): NormalizedApplicationBuildOptions { + return { + indexHtmlOptions: { + input: join(tempDir, 'index.html'), + insertionOrder: [['main.js', true]], + preloadInitial, + }, + subresourceIntegrity, + baseHref: '/', + security: {}, + } as unknown as NormalizedApplicationBuildOptions; + } + + it('should add integrity metadata for every initial chunk when there are more than the module preload limit', async () => { + const chunkCount = 15; + const chunks = Array.from({ length: chunkCount }, (_, i) => `chunk-${i}.js`); + const initialFiles = new Map([ + ['main.js', { entrypoint: true, name: 'main', type: 'script', serverFile: false, depth: 0 }], + ...chunks.map( + (chunk, i) => + [chunk, { entrypoint: false, type: 'script', serverFile: false, depth: i + 1 }] as [ + string, + InitialFileRecord, + ], + ), + ]); + const outputFiles = ['main.js', ...chunks].map((path) => + createOutputFile(path, `console.log(${JSON.stringify(path)});`, BuildOutputFileType.Browser), + ); + + const { csrContent, errors } = await generateIndexHtml( + initialFiles, + outputFiles, + createOptions(true), + ); + expect(errors).toHaveSize(0); + + for (const chunk of chunks.slice(0, 10)) { + expect(csrContent).toContain(` { + const initialFiles = new Map([ + ['main.js', { entrypoint: true, name: 'main', type: 'script', serverFile: false, depth: 0 }], + ['chunk-a.js', { entrypoint: false, type: 'script', serverFile: false, depth: 1 }], + ]); + const outputFiles = ['main.js', 'chunk-a.js'].map((path) => + createOutputFile(path, `console.log(${JSON.stringify(path)});`, BuildOutputFileType.Browser), + ); + const options = createOptions(true, false); + + const { csrContent, errors } = await generateIndexHtml(initialFiles, outputFiles, options); + expect(errors).toHaveSize(0); + + expect(csrContent).toContain(`"./${outputFiles[1].path}":`); + expect(csrContent).not.toContain(`"./${outputFiles[0].path}":`); + expect(csrContent).not.toContain('rel="modulepreload"'); + }); +});