Skip to content

unit-test builder: "sideEffects": false elides statically-referenced barrel modules, component reads as undefined in TestBed #33910

Description

@PatVandyke

Which @angular/* package(s) are the source of the bug?

build

Is this a regression?

Yes, this behaviour used to work in version 21.2.19

Description

In a multi-entry-point library built with ng-packagr, @angular/build:unit-test (Vitest + jsdom) elides module bodies from the primary entry point's export * barrel graph when the library's package.json declares "sideEffects": false. The namespace re-export accessors for those modules survive and return undefined.

When one of the dropped exports is a standalone component referenced from a retained imports: [...] array literal of another component, TestBed.configureTestingModule throws:

TypeError: Cannot read properties of undefined (reading 'ɵcmp')
  at getComponentDef        (core/testing/src/test_bed_compiler.ts:1169)
  at isStandaloneComponent  (core/testing/src/test_bed_compiler.ts:1162)
  at queueTypesFromModulesArrayRecur
  at TestBedCompiler.queueTypesFromModulesArray
  at TestBedCompiler.configureTestingModule

Dropping a module whose export is statically referenced from a retained array literal looks incorrect regardless of sideEffects, which is a module-level assertion about import-for-side-effect semantics — not a licence to remove a module that is still statically reachable.

In our library (761 barrel exports across 4 entry points) this fails 36 specs in 3 files, all in one secondary entry point. On Angular 21.2.19 the identical suite is 389/389 green.

Not a duplicate of #33728

#33728 describes the same symptom (an export from a shared chunk reading as undefined under the jsdom runner) and was fixed by #33729 "disable code splitting for unit test builds", released in v22.1.5.

That fix is installed and active here and the failure still reproduces. Verified in the installed package:

  • src/builders/unit-test/runners/vitest/build-options.js:238 sets disableCodeSplitting: true
  • honoured at src/tools/esbuild/application-code-bundle.js:55-58 (buildOptions.splitting = false)

So this is a distinct cause: not cross-chunk lazy initialisers, but whole-module elision driven by sideEffects.

Please provide a link to a minimal reproduction of the bug

Unable to share the affected codebase. Reproduction recipe below — it is deterministic and the toggle is a single field.

Setup

  1. ng new repro --no-standalone=false then ng generate library lib, @angular/build >= 22.1.5, TypeScript 6.0.3, Node 22.
  2. Add "sideEffects": false to projects/lib/package.json.
  3. In the primary entry, create a standalone FooComponent and export it through a multi-level barrel: public-api.tsexport * from './lib/components/public-api-components'export * from './foo/foo.component'. At least two levels of export * matter; a single flat barrel did not reproduce for us.
  4. Add a secondary entry point (projects/lib/sub/ with its own ng-package.json and public-api.ts), mapped in the workspace tsconfig.json paths to its source public-api.ts, alongside "@lib": ["./projects/lib/src/public-api.ts"].
  5. In the secondary entry, create a standalone BarComponent with imports: [FooComponent], importing it as import {FooComponent} from '@lib'.
  6. Give BarComponent a spec doing TestBed.configureTestingModule({imports: [BarComponent]}).
  7. Configure a test target with @angular/build:unit-test, buildTarget pointing at the library build, and include covering the secondary entry's specs. Run the whole suite, not a single file — see below.

Expected: spec passes.
Actual: TypeError: Cannot read properties of undefined (reading 'ɵcmp').

Toggle: change projects/lib/package.json to "sideEffects": true (or a glob matching the source tree, e.g. ["./src/**", "./*/src/**"]) → passes. Change it back → fails. Deterministic in both directions.

Diagnostics that pin the mechanism

These are the observations that distinguish elision from TDZ, resolution failure, or interop snapshotting:

  1. The module body never runs. Injecting an unconditional (globalThis as any).__RAN = true at the top of an affected module leaves __RAN undefined at runtime, while a control module in the same barrel has its marker set. A module containing an unconditional global assignment can only be removed if it is declared side-effect-free.
  2. Not TDZ. On import * as barrel from '@lib', the affected names are present as ACCESSOR property descriptors and still return undefined after both a microtask and a macrotask tick.
  3. Not duplicate module identity. directImport === barrel.Foo is true.
  4. Only classes are affected in practice. Plain functions exported from the same barrel in the same import statement resolve correctly; the value read as undefined is the component class. In our failing component, ɵcmp.dependencies was ["UNDEFINED", "TranslatePipe"] — the third-party pipe resolved, the barrel-imported component did not.
  5. Whole-suite dependent. Running the affected spec alone via --include passes; only the full multi-file run drops the module. The set of undefined exports also changes with import order — consistent with per-entry retention analysis rather than a fixed graph defect.
  6. Not specific to one entry point. A barrel probe placed in a different, CommonJS-free secondary entry returns the identical set of undefined exports. Whether a given spec fails depends on which barrel modules that entry's retention set happens to keep.

Please provide the exception or error you saw

TypeError: Cannot read properties of undefined (reading 'ɵcmp')
    at getComponentDef (../packages/core/testing/src/test_bed_compiler.ts:1169:24)
    at isStandaloneComponent (../packages/core/testing/src/test_bed_compiler.ts:1162:14)
    at queueTypesFromModulesArrayRecur (../packages/core/testing/src/test_bed_compiler.ts:847:23)
    at TestBedCompiler.queueTypesFromModulesArray (../packages/core/testing/src/test_bed_compiler.ts:861:4)
    at TestBedCompiler.configureTestingModule (../packages/core/testing/src/test_bed_compiler.ts:226:11)
    at TestBedImpl.configureTestingModule (../packages/core/testing/src/test_bed.ts:594:18)

Please provide the environment you discovered this bug in (run: ng version)

Angular CLI: 22.1.5
Node: 22.23.1
Package Manager: npm 10.x
OS: linux x64

Angular: 22.1.3
... animations, cdk, common, compiler, compiler-cli, core, forms,
    platform-browser, router

Package                         Version
---------------------------------------------------------
@angular/build                  22.1.5
@angular/cli                    22.1.5
@angular-devkit/build-angular   22.1.3
ng-packagr                      22.1.1
typescript                      6.0.3
vitest                          (via @angular/build:unit-test)

Anything else?

Workaround that keeps published tree-shaking intact: instead of "sideEffects": false, use globs that match only the workspace source tree and nothing in the build output, e.g.

"sideEffects": ["./src/**", "./*/src/**"]

ng-packagr emits fesm2022/*.mjs plus per-entry package.json stubs, so these globs match zero emitted files. The published package therefore keeps effective side-effect-free semantics for consumers, while the workspace source — which is what the unit-test build reads through tsconfig paths — is treated as impure.

Related but distinct: #33467 (unit-test builder resolves include relative to sourceRoot, also a multi-entry-point blind spot) and #33893 (sideEffects: false interacting with optimizer pure annotations inside published FESMs — statement-level rather than whole-module).

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions