From 56c42a4e149c7aa03819cf13521b64f6acbf30fa Mon Sep 17 00:00:00 2001 From: Stephen Jayna Date: Tue, 27 Oct 2026 08:52:54 +0000 Subject: [PATCH] fix(@angular/build): write the stats file in a stable, deterministic order Component stylesheet metafiles merged into the main metafile in bundle completion order, so the statsJson stats file varied between builds and, when two stylesheets bundled to the same output file, the last merged won and dropped the other's input attribution. Sort the dynamic path dictionaries (inputs, outputs, and each output's inputs) when serializing the stats file, and union the inputs of colliding outputs so every input is attributed. Both avoid a JSON.stringify replacer and per-collision object spreads, keeping V8 on its native serializer and avoiding the O(n^2) allocations raised in review. --- .../src/builders/application/execute-build.ts | 41 ++++- .../tests/options/stats-json_spec.ts | 158 +++++++++++++++++- .../tools/esbuild/angular/compiler-plugin.ts | 16 +- 3 files changed, 210 insertions(+), 5 deletions(-) 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 }, + }; + } + } } }