Skip to content

pure-toplevel-functions marks the IIFE Babel emits for self-referencing static fields as PURE → Injector.ɵprov dropped, NG0201 (webpack builder, targets below Chrome 94) #34017

Description

@ValeraGin

Command

build, serve

Is this a regression?

  • Yes, this behavior used to work in the previous version

The previous version in which this bug was not present was

Same family as #29145 (Angular 19). #29250 fixed the _defineProperty(...) call shape, but the IIFE wrapper that Babel emits for self-referencing static fields is still annotated as pure.

Description

With the webpack builder (@angular-devkit/build-angular:browser), optimization: true / buildOptimizer: true and a browserslist that requires lowering class static blocks (any target below Chrome 94, e.g. chrome >= 80, Smart TV engines), the application fails at bootstrap with NG0201 because Injector.ɵprov (and __NG_ELEMENT_ID__) no longer exist on Injector from @angular/core.

Chain:

  1. @babel/preset-env for such targets lowers class fields (class-static-block lowering pulls in transform-class-properties). Static fields that reference their own class (static ɵprov = ɵɵdefineInjectable({ token: Injector, ... })) are emitted as a zero-argument IIFE:
    _Injector = Injector;
    (() => {
      _defineProperty(_Injector, "ɵprov", ɵɵdefineInjectable({ token: _Injector, ... }));
      _defineProperty(_Injector, "__NG_ELEMENT_ID__", -1);
    })();
    For targets that only need private methods lowered (Chrome 84–93) the same code appears as a _staticBlock() helper call.
  2. pure-toplevel-functions (running in the non-safe mode used for @angular/* packages) skips IIFEs only when they have arguments (path.node.arguments.length !== 0) and does not know the _staticBlock helper name, so the wrapper gets /*#__PURE__*/.
  3. esbuild in JavaScriptOptimizerPlugin drops the annotated, unused call. Injector.ɵprov is gone → NG0201 at bootstrap.

#29250 added an exemption for direct _defineProperty(...) calls, which covers the non-self-referencing fields (THROW_IF_NOT_FOUND, NULL) but not the IIFE that Babel wraps around self-referencing ones.

Minimal Reproduction

Standalone script (run inside a project with @angular-devkit/build-angular installed; it uses the real application Babel preset with the options the webpack loader passes for @angular/* packages):

// repro.cjs
const { transformSync } = require('@babel/core');
const esbuild = require('esbuild');
const preset = require('@angular-devkit/build-angular/src/tools/babel/presets/application.js').default;
const src = `
export class Injector {
  static THROW_IF_NOT_FOUND = THROW_IF_NOT_FOUND;
  static NULL = new NullInjector();
  static create(options, parent) { return createInjector(options, parent); }
  static ɵprov = /* @__PURE__ */ ɵɵdefineInjectable({ token: Injector, providedIn: 'any', factory: () => ɵɵinject(INJECTOR) });
  static __NG_ELEMENT_ID__ = -1;
}`;
const lowered = transformSync(src, {
  configFile: false, babelrc: false, sourceType: 'module', compact: false,
  filename: '/x/node_modules/@angular/core/fesm2022/core.mjs',
  presets: [[preset, { supportedBrowsers: ['chrome 80'], forceAsyncTransformation: false, optimize: { topLevelSafeMode: false, wrapDecorators: true } }]],
}).code;
console.log(lowered);
const out = esbuild.transformSync(lowered, { treeShaking: true, format: 'esm', target: 'chrome80', charset: 'utf8' });
console.log(out.code);
console.log(/ɵprov/.test(out.code) ? 'OK: Injector.ɵprov kept' : 'BUG: Injector.ɵprov dropped');

Output after the preset (note the /*#__PURE__*/ on the IIFE):

_Injector = Injector;
_defineProperty(Injector, "THROW_IF_NOT_FOUND", THROW_IF_NOT_FOUND);
_defineProperty(Injector, "NULL", /*#__PURE__*/new NullInjector());
/*#__PURE__*/(() => {
  _defineProperty(_Injector, "ɵprov", /* @__PURE__ */ɵɵdefineInjectable({ token: _Injector, providedIn: 'any', factory: () => ɵɵinject(INJECTOR) }));
  _defineProperty(_Injector, "__NG_ELEMENT_ID__", -1);
})();

After esbuild the IIFE is gone: BUG: Injector.ɵprov dropped.

Full-app steps: ng new, switch to @angular-devkit/build-angular:browser, .browserslistrc = chrome >= 80, optimization: true, buildOptimizer: true, ng build, open the app → NG0201 at bootstrap.

Exception or Error

NG0201 at bootstrap: no provider for Injector (Injector.ɵprov was removed from the bundle); the app stays on the splash screen.

Your Environment

Angular CLI: 22.0.9 (@angular/build 22.0.9 nested under @angular-devkit/build-angular); the plugin source is unchanged in @angular/build 22.1.7
Angular: 22.1.0
@babel/core / @babel/preset-env: 7.29.7
esbuild: 0.19.9
Node: 22.23.1
OS: darwin

Anything else relevant?

A minimal fix that works for us (applied via patch-package): never annotate IIFEs regardless of argument count, and treat _staticBlock/_staticBlock2… as Babel helper names. Class wrappers that are safe to drop are already annotated by adjust-static-class-members, so tree-shaking is unaffected in practice (main bundle +0.06% in our legacy build).

 function isBabelHelperName(name) {
-    return babelHelpers.has(name);
+    return babelHelpers.has(name) || /^_staticBlock\d*$/.test(name);
 }
 ...
                 const callee = path.get('callee');
-                if ((callee.isFunctionExpression() || callee.isArrowFunctionExpression()) &&
-                    path.node.arguments.length !== 0) {
+                if (callee.isFunctionExpression() || callee.isArrowFunctionExpression()) {
                     return;
                 }

Since #33751 moved these plugins into @angular-devkit/build-angular, the fix would land there for 22.2. Happy to send a PR.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions