Skip to content

fix(@angular/build): merge component stylesheet metafiles in a stable order - #34210

Merged
alan-agius4 merged 1 commit into
angular:mainfrom
sdjayna:fix-build-metafile-merge-order
Oct 27, 2026
Merged

alan-agius4 merged 1 commit into
angular:mainfrom
sdjayna:fix-build-metafile-merge-order

Conversation

@sdjayna

@sdjayna sdjayna commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Build output is non-deterministic: two builds of identical sources can write different stats files. Merging the component stylesheet metafiles in a stable order fixes it.

PR Checklist

PR Type

  • Bugfix

What is the current behavior?

Issue Number: #34209

outputs[<file>].inputs in the stats file changes between builds of the same sources. The cause is one loop in build.onEnd: the compiler plugin merges each component stylesheet's metafile into the main metafile in the order the stylesheets finished bundling, and when two stylesheets produce the same output file, the last one merged owns the entry. Every emitted file is byte-identical; only the metadata moves.

What is the new behavior?

The entries are sorted by key before merging, so the merged metafile is the same on every build.

The new spec builds two components in different directories with identical stylesheets, builds twice, and asserts the shared output is attributed to the same input both times.

Does this PR introduce a breaking change?

  • No

Other information

Measured on 22.1.9 with the reproduction at https://github.com/sdjayna/angular-metafile-merge-order-repro (60 components, 12 identical-stylesheet pairs, 4 identical-image pairs): without the change 5 of 5 build pairs differed, in 7 to 10 of 54 outputs entries; with it, 0 of 3 differed and the stats file was byte-identical. The same loop is on main today. The sort runs once per build over one entry per stylesheet, so its cost does not register against the bundling.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request ensures deterministic metafile generation by sorting additional compilation results by key order before merging, preventing non-deterministic output attribution caused by completion-order variations. A test has been added to verify this behavior. The feedback suggests that if the merging logic is eventually updated to merge duplicate output inputs rather than overwriting them, the test assertion should be updated to expect both input files.

Comment thread packages/angular/build/src/builders/application/tests/options/stats-json_spec.ts Outdated
@alan-agius4

Copy link
Copy Markdown
Collaborator

Thanks for investigating this! However, there are a couple of concerns with this approach:

  1. Stats accuracy: outputs[file].inputs in an esbuild metafile is intended to list all input source files that contributed to that output file. By sorting and continuing to shallowly overwrite with Object.assign(result.metafile.outputs, metafile.outputs), we deterministically drop inputs (as seen in the test where src/app/a/shared.css is completely missing from outputs['shared.css'].inputs). When an output already exists in result.metafile.outputs, we should merge existingOutput.inputs rather than replacing the entire output entry.
  2. Performance: [...additionalResults].sort(...) creates and sorts an array of [key, value] tuples across all component stylesheets on every build and rebuild in the critical path, even when statsJson is disabled. In large apps with thousands of components, this introduces unnecessary allocations and overhead. If deterministic key ordering for stats.json is required, that can be done when generating/serializing the stats JSON (where options.stats is enabled) rather than in the compilation plugin's onEnd callback.

@sdjayna

sdjayna commented Sep 30, 2026

Copy link
Copy Markdown
Contributor Author

Both points taken, and this replaces what I said to the bot above. Pushed as two fixups.

  • The merge now unions inputs when an output already exists, and builds a new object for the merged entry. The new object matters: the stylesheet bundler caches its result across rebuilds and the clone shares each output's inputs, so assigning in place carried one stylesheet's input into the other's entry on later rebuilds. The sort is gone.
  • The stats files are serialised with sorted keys, behind options.stats. onEnd is otherwise as it was.
  • Three specs: both inputs listed on every build; both stats files sorted; and a five-build watch test that changes one of two identical stylesheets, restores it, and changes it again, the ordering that exposes an in-place merge.

Each spec fails against the code it guards, and the application target passes in full. One detail for the record: unminified CSS carries a source-path comment, so identical stylesheets share a hashed name only when styles are minified, which is why this stayed hidden in development builds.

@alan-agius4 alan-agius4 added the action: review The PR is still awaiting reviews from at least one requested reviewer label Oct 1, 2026
@alan-agius4
alan-agius4 self-requested a review October 1, 2026 15:47
@sdjayna

sdjayna commented Oct 26, 2026

Copy link
Copy Markdown
Contributor Author

@alan-agius4 a gentle nudge on this one, in case it slipped: both of your points from 30 September are addressed in the fixups, and it still rebases cleanly onto current main with the @angular/build unit and application integration suites passing, including the 8 statsJson specs. Happy to rebase and squash the fixups once it's approved.

@alan-agius4 alan-agius4 left a comment •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the updates! There are still a couple of performance/memory concerns and some test cleanups needed. Please see the inline comments below. Also, when squashing the fixup commits, please make sure to update the commit message so it reflects the current approach.

Comment thread packages/angular/build/src/tools/esbuild/angular/compiler-plugin.ts
Comment thread packages/angular/build/src/builders/application/execute-build.ts
Comment thread packages/angular/build/src/builders/application/tests/options/stats-json_spec.ts Outdated
Comment thread packages/angular/build/src/builders/application/tests/options/stats-json_spec.ts Outdated
Comment thread packages/angular/build/src/builders/application/tests/options/stats-json_spec.ts Outdated
…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.
@sdjayna
sdjayna force-pushed the fix-build-metafile-merge-order branch from 1356bd1 to 56c42a4 Compare October 27, 2026 08:54
@sdjayna

sdjayna commented Oct 27, 2026

Copy link
Copy Markdown
Contributor Author

You're right, the earlier revisions weren't surgical. I've applied all three suggestions:

  • compiler-plugin.ts: clone-on-first-insert, then an in-place Object.assign for colliding outputs. No per-collision spread, so the v8:11536 path is gone and the cached metafile stays untouched.
  • execute-build.ts: a targeted stableStringify that sorts only inputs, outputs and each output.inputs, with no JSON.stringify replacer, keeping V8 on its native serializer. (sortObjectKeys assigns through a Record<string, unknown> and casts once at return, so it type-checks under the strict config.)
  • The spec uses expectSortedKeys in place of the recursive clone, and the two sorted-keys cases are folded into the existing browser and SSR tests to drop the extra builds.

One correction while I'm here: my earlier reply on the Gemini thread said this "only fixes the order, it doesn't change what the merge records." That's no longer accurate - the merge now unions the inputs of colliding outputs so every input is attributed, and the tests assert it.

Squashed into one commit with an updated message. The statsJson integration specs pass locally.

@alan-agius4 alan-agius4 added target: patch This PR is targeted for the next patch release action: merge The PR is ready for merge by the caretaker and removed action: review The PR is still awaiting reviews from at least one requested reviewer labels Oct 27, 2026

@alan-agius4 alan-agius4 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, thanks for the updates!

@alan-agius4
alan-agius4 merged commit b0c415a into angular:main Oct 27, 2026
68 of 69 checks passed
@alan-agius4

Copy link
Copy Markdown
Collaborator

This PR was merged into the repository. The changes were merged into the following branches:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

action: merge The PR is ready for merge by the caretaker area: @angular/build target: patch This PR is targeted for the next patch release

Projects

None yet

Development

Successfully merging this pull request may close these issues.

stats.json is non-deterministic when two component stylesheets produce the same output file

2 participants