diff --git a/packages/angular/build/src/builders/application/execute-build.ts b/packages/angular/build/src/builders/application/execute-build.ts index d3b628a169e2..7fc43de92b00 100644 --- a/packages/angular/build/src/builders/application/execute-build.ts +++ b/packages/angular/build/src/builders/application/execute-build.ts @@ -7,6 +7,7 @@ */ import { BuilderContext } from '@angular-devkit/architect'; +import type { Metafile } from 'esbuild'; import { createAngularCompilation } from '../../tools/angular/compilation'; import { AngularCompilationContext, @@ -43,6 +44,42 @@ import { createComponentStyleBundler, setupBundlerContexts } from './setup-bundl /** The esbuild error text prefix used to detect top-level await errors. */ const TOP_LEVEL_AWAIT_ERROR_TEXT = 'Top-level await is not available in the configured target environment'; + +/** Returns a shallow copy of a record with its keys sorted. */ +function sortObjectKeys>(value: T): T { + const sorted: Record = {}; + for (const key of Object.keys(value).sort()) { + sorted[key] = value[key]; + } + + return sorted as T; +} + +/** + * Serializes a metafile with its dynamic path keys sorted so the stats file is identical across + * builds. Only `inputs`, `outputs`, and each output's `inputs` have dynamic keys; the remaining + * fixed-schema objects are left untouched so V8 keeps its native `JSON.stringify` fast path. + */ +function stableStringify(metafile: Metafile): string { + const outputs: Metafile['outputs'] = {}; + for (const key of Object.keys(metafile.outputs).sort()) { + const output = metafile.outputs[key]; + outputs[key] = { + ...output, + inputs: sortObjectKeys(output.inputs), + }; + } + + return JSON.stringify( + { + inputs: sortObjectKeys(metafile.inputs), + outputs, + }, + null, + 2, + ); +} + // eslint-disable-next-line max-lines-per-function export async function executeBuild( options: NormalizedApplicationBuildOptions, @@ -394,14 +431,14 @@ export async function executeBuild( if (options.stats) { executionResult.addOutputFile( 'browser-stats.json', - JSON.stringify(browserMetafile, null, 2), + stableStringify(browserMetafile), BuildOutputFileType.Root, ); if (serverEntryPoint) { executionResult.addOutputFile( 'server-stats.json', - JSON.stringify(serverMetafile, null, 2), + stableStringify(serverMetafile), BuildOutputFileType.Root, ); } diff --git a/packages/angular/build/src/builders/application/tests/options/stats-json_spec.ts b/packages/angular/build/src/builders/application/tests/options/stats-json_spec.ts index f38ae4996d16..667c87afb1bc 100644 --- a/packages/angular/build/src/builders/application/tests/options/stats-json_spec.ts +++ b/packages/angular/build/src/builders/application/tests/options/stats-json_spec.ts @@ -7,11 +7,33 @@ */ import { buildApplication } from '../../index'; +import { OutputHashing } from '../../schema'; import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; +/** Asserts that a record's keys are already in sorted order. */ +function expectSortedKeys(record: Record, context: string): void { + const keys = Object.keys(record); + expect(keys) + .withContext(context) + .toEqual([...keys].sort()); +} + +/** A standalone component in `src/app/` that uses the stylesheet `src/app//shared.css`. */ +function sharedStylesheetComponent(dir: string): string { + return ` + import { Component } from '@angular/core'; + @Component({ + selector: 'app-shared-${dir}', + template: '

${dir}

', + styleUrl: './shared.css', + }) + export class Shared${dir.toUpperCase()}Component {} + `; +} + describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { describe('Option: "statsJson"', () => { - it('generates only browser stats file containing valid metafile data when true', async () => { + it('generates only browser stats file with valid, sorted metafile data when true', async () => { harness.useTarget('build', { ...BASE_OPTIONS, statsJson: true, @@ -27,6 +49,8 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { expect(browserStats.inputs).toBeDefined(); expect(browserStats.outputs).toBeDefined(); expect(Object.keys(browserStats.outputs).length).toBeGreaterThan(0); + expectSortedKeys(browserStats.inputs, 'inputs'); + expectSortedKeys(browserStats.outputs, 'outputs'); }); it('does not generate stats files when false', async () => { @@ -52,6 +76,131 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { harness.expectFile('dist/server-stats.json').toNotExist(); }); + describe('shared component stylesheet', () => { + beforeEach(async () => { + // Two components in different directories with byte-identical stylesheets of the same + // file name bundle to the same output file. + await harness.writeFiles({ + 'src/app/a/shared.component.ts': sharedStylesheetComponent('a'), + 'src/app/a/shared.css': 'p { color: red; }', + 'src/app/b/shared.component.ts': sharedStylesheetComponent('b'), + 'src/app/b/shared.css': 'p { color: red; }', + 'src/app/app.component.html': + '', + 'src/app/app.module.ts': ` + import { NgModule } from '@angular/core'; + import { BrowserModule } from '@angular/platform-browser'; + import { AppComponent } from './app.component'; + import { SharedAComponent } from './a/shared.component'; + import { SharedBComponent } from './b/shared.component'; + @NgModule({ + declarations: [AppComponent], + imports: [BrowserModule, SharedAComponent, SharedBComponent], + bootstrap: [AppComponent], + }) + export class AppModule {} + `, + }); + }); + + it('attributes a shared component stylesheet output to every input', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + statsJson: true, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + // Both inputs are attributed to the single shared output, and in sorted order. + const stats = JSON.parse(harness.readFile('dist/browser-stats.json')); + expect(Object.keys(stats.outputs['shared.css'].inputs)).toEqual([ + 'src/app/a/shared.css', + 'src/app/b/shared.css', + ]); + }); + + it('re-attributes a shared component stylesheet output after one stylesheet changes', async () => { + // Hashed output names keep the two stylesheets apart once their content differs, so the + // outputs are found by their inputs. Minified styles carry no source path comment, which + // gives the identical stylesheets the same hashed name. + harness.useTarget('build', { + ...BASE_OPTIONS, + statsJson: true, + optimization: { styles: true }, + outputHashing: OutputHashing.All, + watch: true, + }); + + await harness.executeWithCases([ + async ({ result }) => { + expect(result?.success).toBeTrue(); + const stats = JSON.parse(harness.readFile('dist/browser-stats.json')); + expect(Object.values(stats.outputs)).toContain( + jasmine.objectContaining({ + inputs: { + 'src/app/a/shared.css': jasmine.anything(), + 'src/app/b/shared.css': jasmine.anything(), + }, + }), + ); + + await harness.writeFile('src/app/a/shared.css', 'p { color: blue; }'); + }, + async ({ result }) => { + expect(result?.success).toBeTrue(); + const stats = JSON.parse(harness.readFile('dist/browser-stats.json')); + expect(Object.values(stats.outputs)).toContain( + jasmine.objectContaining({ + inputs: { 'src/app/a/shared.css': jasmine.anything() }, + }), + ); + expect(Object.values(stats.outputs)).toContain( + jasmine.objectContaining({ + inputs: { 'src/app/b/shared.css': jasmine.anything() }, + }), + ); + + await harness.writeFile('src/app/a/shared.css', 'p { color: red; }'); + }, + async ({ result }) => { + expect(result?.success).toBeTrue(); + const stats = JSON.parse(harness.readFile('dist/browser-stats.json')); + expect(Object.values(stats.outputs)).toContain( + jasmine.objectContaining({ + inputs: { + 'src/app/a/shared.css': jasmine.anything(), + 'src/app/b/shared.css': jasmine.anything(), + }, + }), + ); + + // The restore re-bundled only `a`, so `b` now merges first. A second change to `a` + // checks that the merge left the cached `b` result untouched. + await harness.writeFile('src/app/a/shared.css', 'p { color: green; }'); + }, + ({ result }) => { + expect(result?.success).toBeTrue(); + const stats = JSON.parse(harness.readFile('dist/browser-stats.json')); + // The inner `inputs` objects are matched exactly, not with `objectContaining`: if the + // previous case had mutated the cached `b` result in place, `b` would read + // `{ a, b }` here and this exact match would fail. This is the guard for the + // clone-on-first-insert in the metafile merge, so it must stay an exact match. + expect(Object.values(stats.outputs)).toContain( + jasmine.objectContaining({ + inputs: { 'src/app/a/shared.css': jasmine.anything() }, + }), + ); + expect(Object.values(stats.outputs)).toContain( + jasmine.objectContaining({ + inputs: { 'src/app/b/shared.css': jasmine.anything() }, + }), + ); + }, + ]); + }); + }); + describe('server build', () => { beforeEach(async () => { await harness.modifyFile('src/tsconfig.app.json', (content) => { @@ -63,7 +212,7 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { }); }); - it('generates separated browser and server stats files for an SSR build', async () => { + it('generates separated browser and server stats files with sorted keys for an SSR build', async () => { harness.useTarget('build', { ...BASE_OPTIONS, server: 'src/main.server.ts', @@ -80,6 +229,11 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { const browserStats = JSON.parse(harness.readFile('dist/browser-stats.json')); const serverStats = JSON.parse(harness.readFile('dist/server-stats.json')); + expectSortedKeys(browserStats.inputs, 'browser inputs'); + expectSortedKeys(browserStats.outputs, 'browser outputs'); + expectSortedKeys(serverStats.inputs, 'server inputs'); + expectSortedKeys(serverStats.outputs, 'server outputs'); + const browserPaths = new Set(Object.keys(browserStats.outputs)); const serverPaths = new Set(Object.keys(serverStats.outputs)); diff --git a/packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts b/packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts index 9a36eea56d61..9d8de5a13972 100644 --- a/packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts +++ b/packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts @@ -638,7 +638,21 @@ export function createCompilerPlugin( // mitigating significant performance overhead for large apps. // See: https://bugs.chromium.org/p/v8/issues/detail?id=11536 Object.assign(result.metafile.inputs, metafile.inputs); - Object.assign(result.metafile.outputs, metafile.outputs); + for (const [outputPath, output] of Object.entries(metafile.outputs)) { + // Two stylesheets can bundle to the same output file. Clone the inputs on first + // insert so colliding inputs can be unioned in place with Object.assign, keeping + // every input attributed without per-collision spread allocations and leaving the + // cached `additionalResults` metafile untouched. + const existing = result.metafile.outputs[outputPath]; + if (existing) { + Object.assign(existing.inputs, output.inputs); + } else { + result.metafile.outputs[outputPath] = { + ...output, + inputs: { ...output.inputs }, + }; + } + } } }