Repository navigation
@angular/build:unit-test: with the default jsdom runner and zone.js, an export from a shared chunk is undefined when read during class-field initialisation from a spec with an async test #33728
Description
Command
test
Is this a regression?
Not known to be — this is the @angular/build:unit-test builder's default (jsdom) runner; I have no earlier version where this worked.
Description
Under the unit-test builder's default jsdom runner, a module that esbuild places in a chunk shared between spec entry points is wrapped in a lazy __esm initialiser, and a consumer in a different chunk reads its exports as undefined during class-field initialisation. The same specs pass when the runner is switched to browser mode with the same bundles, so this looks like the Node-side module evaluation path rather than a bundling fault.
The distinction that makes it precise:
- a service reads the shared
constinside a method → always correct; - a component reads the same
constin a class-field initialiser →undefined.
The binding is live — replacing the fields with getters (reading the same import later) makes the failing test pass with no other change. So the value arrives; at construction time it is not yet visible to the importer, even though the emitted code calls init_env_config() earlier in the same module body and construction happens later still.
Trigger conditions — each toggled on its own with everything else constant; all four are required:
| # | Required condition | Evidence |
|---|---|---|
| 1 | ≥ 2 spec entries import the module (shared chunk, lazy __esm wrappers) |
a single spec file inlines everything and passes |
| 2 | a consumer in another chunk reads the export during class-field initialisation | getters pass |
| 3 | the consuming spec file contains an async test callback | making that one test synchronous passes; async with no await still fails |
| 4 | zone.js is in the unit-test polyfills | deleting "polyfills": ["zone.js"] from the unit-test build configuration makes the same suite pass; restoring it fails again |
Not required (removed with the failure surviving): TestBed.configureTestingModule() / compileComponents() (a bare TestBed.createComponent() inside an async test fails), provider overrides, barrel files, @angular/core patch level (fails on 21.2.18 and 21.2.19).
Conditions 3 and 4 are one mechanism: with zone.js present the builder downlevels async/await, so a spec containing any async function imports the __async helper — and esbuild then emits that spec entry CommonJS-wrapped, with its entry-local ESM dependencies (the component module) as lazy __esm initialisers:
// spec-…-lead-generator.container.js (dumped with ng test --dump-virtual-files)
import { DEALERS, LeadService, init_env_config, init_lead_service } from "./chunk-N64URYIK.js";
import { __async, __commonJS, __esm } from "./chunk-EFV3TZQO.js";
var LeadGeneratorContainer;
var init_lead_generator_container = __esm({
"…/lead-generator.container.ts"() {
init_services();
init_env_config(); // runs before the class statement below
LeadGeneratorContainer = class _LeadGeneratorContainer {
leadService = inject(LeadService);
dealers = DEALERS; // undefined at construction time
dealerOptions = this.dealers.map(…); // throws
};
}
});
var require_lead_generator_container_spec = __commonJS({
"…/lead-generator.container.spec.ts"(exports) {
init_lead_generator_container();
describe("LeadGeneratorContainer", () => { … });
}
});
export default require_lead_generator_container_spec();The shared chunk assigns DEALERS inside init_env_config()'s __esm wrapper and exports the binding; the emitted code is valid ESM and runs correctly in browser mode.
One caveat for anyone building a paraphrased fixture: the failure is sensitive to inert content — adding a top-level console.log to either the shared or the importing module defuses it, and one hand-written structural clone of the same shape passes. The linked reproduction is the exact shape that fails deterministically.
Minimal Reproduction
https://github.com/jonmarozick/ng-shared-chunk-repro
npm install
npm test # ng test --no-watch
Expected: 2 passed. Actual: Test Files 1 failed | 1 passed (2) — lead-generator.container.spec.ts fails, lead.service.spec.ts (the control reading the same const in a method) passes.
Nine source files, dependencies are only Angular 21.2.19 + Vitest 4 + jsdom, package-lock.json committed. The module graph:
src/environments/env-config.ts exports `const DEALERS`
├── lead.service.ts reads DEALERS in a method
│ ├── lead.service.spec.ts ← spec entry 1 (passes)
│ └── lead-generator.container.ts
└── lead-generator.container.ts reads DEALERS in a field initialiser
└── lead-generator.container.spec.ts ← spec entry 2 (fails)
Also reproduced by copying these source files unchanged into a fresh ng new workspace on CLI 21.2.19 (@angular/core 21.2.19) with "polyfills": ["zone.js"] added to the unit-test build configuration — so nothing about it depends on the original project. The emitted bundles can be inspected via ng test --dump-virtual-files (they land in .angular/cache/<version>/<project>/unit-test/output-files/).
Exception or Error
TypeError: Cannot read properties of undefined (reading 'map')
❯ _LeadGeneratorContainer.<instance_members_initializer> src/app/features/lead-generator/lead-generator.container.ts:291:43
❯ new _LeadGeneratorContainer src/app/features/lead-generator/lead-generator.container.ts:336:5
❯ NodeInjectorFactory.LeadGeneratorContainer_Factory [as factory] spec-app-features-lead-generator-lead-generator.container.js:1138:16
❯ getNodeInjectable packages/core/src/render3/di.ts:785:37
❯ instantiateAllDirectives packages/core/src/render3/instructions/shared.ts:122:2
❯ createComponentRef packages/core/src/render3/component_ref.ts:301:20
❯ create packages/core/testing/src/test_bed.ts:695:44
Your Environment
Angular CLI: 21.2.19
Node: 24.18.0
Package Manager: npm 11.16.0
OS: win32 x64
@angular/build 21.2.19
@angular/cli 21.2.19
@angular/core 21.2.18
@angular/compiler-cli 21.2.18
typescript 5.9.3
vitest 4.1.10
zone.js 0.16.2
jsdom (builder default)
Also reproduced on Linux CI (ubuntu-latest, Node 24) and in a fresh ng new workspace with @angular/core 21.2.19.
Anything else relevant?
- Not avoidable through runner configuration:
isolatetrue/false,poolforks/threads/vmThreads,fileParallelism: false, and droppingrunnerConfigentirely all still fail. Removing zone.js avoids it but is not available to a zone-based app, and "don't write async tests" is not a workaround. - The impact can be silent: in a sibling project on the same setup, jsdom reported 108 of 216 tests (three spec files never loaded) in half the wall-clock of browser mode — a faster jsdom run can mean less of the suite ran.
- Not tested on 22.x (project fleet is pinned to v21 LTS); the two vitest-related 22.x changelog entries don't touch the module-interop path.
- Possibly related open issues, none the same: "No test suite found in file" error when running Vitest #32252,
@angular/build:unit-testvirtualinit-testbed.jsguardsinitTestEnvironment()behind a once-per-worker symbol → stale DomAdapter under vitest ≥4.0.5 +isolate: false#33047, unit-test (vitest): isolate:false default silently breaks cross-file vi.mock() of shared modules on low-CPU workers #33570. - I have a verified fix (disabling code splitting for the unit-test build only, mirroring the polyfills-bundle precedent) and will open a PR referencing this issue.
Activity
Minimise the failing spec, and document the exact trigger set
fix(@angular/build): re-enable code splitting for unit tests
fix(@angular/build): re-enable code splitting for unit tests
fix(@angular/build): re-enable code splitting for unit tests
fix(@angular/build): re-enable code splitting for unit tests
fix(@angular/build): re-enable code splitting for unit tests
fix(@angular/build): re-enable code splitting for unit tests
fix(@angular/build): re-enable code splitting for unit tests
fix(@angular/build): re-enable code splitting for unit tests
fix(@angular/build): re-enable code splitting for unit tests
AB#5075 Chore: libraries hygiene — class-field getters, editorconfig …