Skip to content

fix(@angular/build): disable code splitting for unit test builds - #33729

Open
jonmarozick wants to merge 1 commit into
angular:mainfrom
jonmarozick:fix/unit-test-shared-chunk-init
Open

fix(@angular/build): disable code splitting for unit test builds#33729
jonmarozick wants to merge 1 commit into
angular:mainfrom
jonmarozick:fix/unit-test-shared-chunk-init

Conversation

@jonmarozick

@jonmarozick jonmarozick commented Aug 2, 2026

Copy link
Copy Markdown

Fixes #33728

PR Checklist

Please check to confirm your PR fulfills the following requirements:

  • The commit message follows our guidelines: https://github.com/angular/angular-cli/blob/main/CONTRIBUTING.md#-commit-message-guidelines
  • Tests for the changes have been added (for bug fixes / features) — unit-test/tests/behavior/vitest-shared-chunk-init_spec.ts; see "Other information" for how the fixture was validated red/green.
  • Docs have been added / updated (for bug fixes / features) — N/A: the new option is internal-only and not exposed in the builder schema.

PR Type

What kind of change does this PR introduce?

  • Bugfix

What is the current behavior?

Issue Number: #33728

Under @angular/build:unit-test with the default (jsdom) runner, an export from a module that code
splitting hoisted into a shared chunk can be observed as undefined by an importing chunk — a
component reading an exported const in a class-field initializer fails with
TypeError: Cannot read properties of undefined (reading 'map'), in a test that never touches the
runner's own API. The emitted bundles are valid ESM and the same specs pass in browser mode, so the
defect is in how the Node-side module-runner path evaluates the split output, not in the bundles.

Every spec file is its own entry point, so splitting hoists any module reached from more than one
spec into a shared chunk behind lazy __esm initializers; importing chunks then read the export as
a live ESM binding, which that path does not reliably preserve at class-field-initialization time.
The full analysis — including the exact four-condition trigger set (shared chunk + cross-chunk
field read + an async test callback + zone.js polyfills) isolated by single-variable toggles — is
in #33728, with a minimal reproduction at https://github.com/jonmarozick/ng-shared-chunk-repro
(npm install && npm test: expected 2 passed, actual 1 failed).

The failure only appears once a project has more than one spec file, because a single entry point
inlines everything and never splits — so a suite can pass for a long time and then break when a
second spec file is added. The impact can also be silent: in a larger project on the same setup,
jsdom reported 108 of 216 tests (three spec files never loaded) in half the wall-clock of browser
mode.

What is the new behavior?

esbuild code splitting is disabled for the unit-test build only, through a new internal option
(disableCodeSplitting) alongside the existing internal test-only options. Test bundles are never
downloaded by a browser, so splitting has nothing to optimize here. Three small changes:

  • application/options.ts — declare the internal option and pass it through normalizeOptions.
  • esbuild/application-code-bundle.ts — honour it in createBrowserCodeBundleOptions, following
    the existing buildOptions.splitting = false precedent used for the polyfills bundle.
  • unit-test/runners/vitest/build-options.ts — set it for the Vitest runner's build.

With the change, the minimal reproduction passes (2/2) and no shared chunks are emitted for test
builds. Application builds are untouched.

Does this PR introduce a breaking change?

  • No

Cost: shared modules are duplicated into each spec bundle. Measured on a real 105-spec-file
project via --dump-virtual-files:

output files total size chunks wall
splitting on 169 1.21 MB 57 30s
splitting off 112 6.93 MB 0 28s

Total output grows ~5.7×; test run time was unchanged. If the extra memory is a concern for very
large suites, gating the option on the runner being jsdom (rather than all Vitest builds) would
narrow it, since browser mode does not exhibit the bug — happy to adjust.

Other information

Verification. Verified by applying the equivalent change to an installed 21.2.19
@angular/build (building the CLI from source with Bazel was not available on this machine), so CI
should be treated as the authoritative run:

check before after
minimal reproduction (jsdom) 1 failed / 1 passed 2 passed
real 105-spec-file project, browser mode 1018 passed 1018 passed
ng build (application build, flag unset) ok ok, still splits

Regression test. unit-test/tests/behavior/vitest-shared-chunk-init_spec.ts encodes the
issue's four trigger conditions: a shared const imported by two spec entries (shared chunk), a
component reading it in a class-field initializer, an async test callback in that component's
spec (no await needed), and zone.js in the polyfills (the setupApplicationTarget default).
Earlier fixture attempts passed without the fix because their test callbacks were synchronous —
zone forces async downleveling, the spec then imports the __async helper, and esbuild emits that
spec entry CommonJS-wrapped with the component module behind a lazy __esm initializer, which is
the shape in which the cross-chunk read fails.

Since Bazel wasn't runnable on my machine, the fixture's exact file contents were validated
against an installed 21.2.19 @angular/build in a fresh ng new workspace: unpatched, the
container spec fails
with the issue's TypeError (1 failed / 2 passed); with this change
applied, all pass
and no chunk-*.js is emitted. Please treat the CI run of the harness test as
authoritative. One caveat encoded as a comment in the test: the failure is sensitive to inert
content (a top-level console.log in either module defuses it), so the fixtures intentionally
mirror the reproduction byte-for-byte rather than a paraphrase.

🤖 Generated with Claude Code

@google-cla

google-cla Bot commented Aug 2, 2026

Copy link
Copy Markdown

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

@jonmarozick
jonmarozick force-pushed the fix/unit-test-shared-chunk-init branch from b8a3e00 to 9e99820 Compare August 2, 2026 19:19

@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 introduces a new internal option, disableCodeSplitting, to the application builder options. This option is designed to disable esbuild code splitting for browser code bundles, which is particularly useful for unit-test runners (such as Vitest) where module loading does not reliably preserve live ESM bindings across chunk boundaries. The option has been integrated into the option normalization process, the Vitest build options, and the browser code bundle creation logic. I have no feedback to provide as there are no review comments to address.

Every spec file is its own entry point, so esbuild code splitting hoists any module
reached from more than one spec into a chunk shared between them. A module placed in a
shared chunk is wrapped in a lazy `__esm` initializer, so its exported value is only
assigned once that initializer runs, and importing chunks read the export as a live ESM
binding.

The unit test runners load the generated output through a module runner rather than the
browser's own ESM implementation, and that does not reliably preserve those bindings. An
importing chunk can therefore observe the export as `undefined`. A component whose class
field initializer reads a `const` exported from a module that was hoisted into a shared
chunk fails with a `TypeError`, while the same value read later, or read from within the
shared chunk itself, is correct. It only appears once a project has more than one spec
file, because a single entry point inlines everything and never splits.

Test bundles are never downloaded by a browser, so splitting has nothing to optimize
here. This disables it for the unit test build only, via an internal option, leaving
application builds unaffected.

The regression test mirrors the reproduction's exact shape: a shared const read during
class-field initialization from a spec with an async test callback, under zone.js
polyfills. That combination is load-bearing: zone.js downlevels async, the spec then
imports the `__async` helper, and esbuild emits the spec entry CommonJS-wrapped with the
component module behind a lazy `__esm` initializer. The fixture file set was verified to
fail against an unpatched 21.2.19 build and pass with this change applied.
@jonmarozick
jonmarozick force-pushed the fix/unit-test-shared-chunk-init branch from 9e99820 to 746b351 Compare August 2, 2026 19:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

1 participant