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
const inside a method → always correct;
- a component reads the same
const in 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?
Command
test
Is this a regression?
Not known to be — this is the
@angular/build:unit-testbuilder's default (jsdom) runner; I have no earlier version where this worked.Description
Under the
unit-testbuilder's default jsdom runner, a module that esbuild places in a chunk shared between spec entry points is wrapped in a lazy__esminitialiser, and a consumer in a different chunk reads its exports asundefinedduring 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:
constinside a method → always correct;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:
__esmwrappers)asyncwith noawaitstill fails"polyfills": ["zone.js"]from the unit-test build configuration makes the same suite pass; restoring it fails againNot required (removed with the failure surviving):
TestBed.configureTestingModule()/compileComponents()(a bareTestBed.createComponent()inside an async test fails), provider overrides, barrel files,@angular/corepatch 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__asynchelper — and esbuild then emits that spec entry CommonJS-wrapped, with its entry-local ESM dependencies (the component module) as lazy__esminitialisers:The shared chunk assigns
DEALERSinsideinit_env_config()'s__esmwrapper 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.logto 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
Expected: 2 passed. Actual:
Test Files 1 failed | 1 passed (2)—lead-generator.container.spec.tsfails,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.jsoncommitted. The module graph:Also reproduced by copying these source files unchanged into a fresh
ng newworkspace on CLI 21.2.19 (@angular/core21.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 viang test --dump-virtual-files(they land in.angular/cache/<version>/<project>/unit-test/output-files/).Exception or Error
Your Environment
Also reproduced on Linux CI (ubuntu-latest, Node 24) and in a fresh
ng newworkspace with@angular/core21.2.19.Anything else relevant?
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.@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.