angular: forwardRef breaks at runtime with ES2015

🐞 bug report

Affected Package

The issue is caused by package @angular/core

Is this a regression?

No.

Description

Using forwardRef as described in https://angular.io/api/core/forwardRef while targeting ES2015 results on a Uncaught ReferenceError: Cannot access 'Lock' before initialization runtime error.

🔬 Minimal Reproduction

  • make a new project ng new forward-ref-project && cd forward-ref-project
  • ensure tsconfig.json contains "target": "es2015",
  • replace the contents of src/main.ts with:
import { Inject, forwardRef, ReflectiveInjector } from '@angular/core';
class Door {
  lock: Lock;

  // Door attempts to inject Lock, despite it not being defined yet.
  // forwardRef makes this possible.
  constructor(@Inject(forwardRef(() => Lock)) lock: Lock) { this.lock = lock; }
}

// Only at this point Lock is defined.
class Lock { }

const injector = ReflectiveInjector.resolveAndCreate([Door, Lock]);
const door = injector.get(Door);
console.log(door instanceof Door);
console.log(door.lock instanceof Lock);
  • ng serve -o

🔥 Exception or Error

Uncaught ReferenceError: Cannot access 'Lock' before initialization
    at Module../src/main.ts (main.ts:21)
    at __webpack_require__ (bootstrap:78)
    at Object.2 (main.ts:30)
    at __webpack_require__ (bootstrap:78)
    at checkDeferredModules (bootstrap:45)
    at Array.webpackJsonpCallback [as push] (bootstrap:32)
    at main.js:1

🌍 Your Environment

Angular Version:


Angular CLI: 8.0.0-beta.18
Node: 10.10.0
OS: win32 x64
Angular: 8.0.0-beta.14
... animations, common, compiler, compiler-cli, core, forms
... language-service, platform-browser, platform-browser-dynamic
... router

Package                           Version
-----------------------------------------------------------
@angular-devkit/architect         0.800.0-beta.18
@angular-devkit/build-angular     0.800.0-beta.18
@angular-devkit/build-optimizer   0.800.0-beta.18
@angular-devkit/build-webpack     0.800.0-beta.18
@angular-devkit/core              8.0.0-beta.18
@angular-devkit/schematics        8.0.0-beta.18
@angular/cli                      8.0.0-beta.18
@ngtools/webpack                  8.0.0-beta.18
@schematics/angular               8.0.0-beta.18
@schematics/update                0.800.0-beta.18
rxjs                              6.4.0
typescript                        3.4.5
webpack                           4.30.0

Anything else relevant?

forwardRef is provided specifically for the purpose of referencing something that isn’t defined. This is useful in breaking circular dependencies, and when declaring both services and components in the same file.

forwardRef works because it delays the resolution of the reference to a time at which it is already declared through the callback indirection. In the API example, the symbol we want to delay resolution is Lock:

class Door {
  lock: Lock;

  // Door attempts to inject Lock, despite it not being defined yet.
  // forwardRef makes this possible.
  constructor(@Inject(forwardRef(() => Lock)) lock: Lock) { this.lock = lock; }
}

// Only at this point Lock is defined.
class Lock { }

But Lock is actually being referenced in more places than just inside forwardRef. It is also being used as a TS type in the class property, and in the constructor parameter.

Types don’t usually have a runtime representation so that shouldn’t be a problem. But constructor types are an exception and actually do have a runtime representation. We can see this by looking at the transpiled code:

import * as tslib_1 from "tslib";
import { Inject, forwardRef, ReflectiveInjector } from '@angular/core';
let Door = class Door {
    // Door attempts to inject Lock, despite it not being defined yet.
    // forwardRef makes this possible.
    constructor(lock) { this.lock = lock; }
};
Door = tslib_1.__decorate([
    tslib_1.__param(0, Inject(forwardRef(() => Lock))),
    tslib_1.__metadata("design:paramtypes", [Lock])
], Door);
// Only at this point Lock is defined.
class Lock {
}
const injector = ReflectiveInjector.resolveAndCreate([Door, Lock]);
const door = injector.get(Door);
console.log(door instanceof Door);
console.log(door.lock instanceof Lock);

The Lock type in the for the constructor parameter was transpiled into tslib_1.__metadata("design:paramtypes", [Lock]). This reference does not have a delayed resolution like the injected forwardRef and is instead immediately resolved, resulting in Uncaught ReferenceError: Cannot access 'Lock' before initialization.

This error isn’t observed when targetting ES5 however. We can understand why by looking at the code when transpiled to ES5 :

import * as tslib_1 from "tslib";
import { Inject, forwardRef, ReflectiveInjector } from '@angular/core';
var Door = /** @class */ (function () {
    // Door attempts to inject Lock, despite it not being defined yet.
    // forwardRef makes this possible.
    function Door(lock) {
        this.lock = lock;
    }
    Door = tslib_1.__decorate([
        tslib_1.__param(0, Inject(forwardRef(function () { return Lock; }))),
        tslib_1.__metadata("design:paramtypes", [Lock])
    ], Door);
    return Door;
}());
// Only at this point Lock is defined.
var Lock = /** @class */ (function () {
    function Lock() {
    }
    return Lock;
}());
var injector = ReflectiveInjector.resolveAndCreate([Door, Lock]);
var door = injector.get(Door);
console.log(door instanceof Door);
console.log(door.lock instanceof Lock);

In ES5 there are no class declarations, so TS instead uses a var. One important different between var and class/let/const is that the latter are all subject to the Temporal Dead Zone.

In practical terms the TDZ means that using a var before it is declared resolves to undefined, but using a class/let/const instead throws a ReferenceError. This is the error we are seeing here.

A possible workaround is to not declare the type in the constructor:

// Instead of adding the type in the parameter
constructor(@Inject(forwardRef(() => Lock)) lock: Lock) { 
  this.lock = lock;
}

// Add it as a cast in the constructor body
constructor(@Inject(forwardRef(() => Lock)) lock) {
  this.lock = lock as Lock;
}

This will change the transpiled code and remove the reference, avoiding the ReferenceError:

Door = tslib_1.__decorate([
    tslib_1.__param(0, Inject(forwardRef(() => Lock))),
    tslib_1.__metadata("design:paramtypes", [Object])
                                             ^^^^^^ was Lock before
], Door);

One important note is that the ReferenceError does not come up on Angular CLI projects compiled with AOT. This is because there we actually transform transpiled TS code and remove Angular decorators, so the metadata reference (tslib_1.__metadata("design:paramtypes", [Lock])) never reaches the browser and thus there is no ReferenceError.

About this issue

  • Original URL
  • State: closed
  • Created 5 years ago
  • Reactions: 23
  • Comments: 19 (7 by maintainers)

Commits related to this issue

Most upvoted comments

I think (in v8) at runtime, a lot of people may be suprised targeting es2015 by default due to the fact CLI historically allowed "showCircularDependencies": false and consider circular deps as warning.

This will breaks a lot of existing angular project, for sure

I had to separate some classes into their own files to prevent this from happening.

Thanks @istiti , good idea, I’ll wait for now, see if the issue gets resolved during the coming weeks. If not we’ll weigh the investment of time. Anyway, crazy coincidence but seems that we have mutual connections. (we’re in Genèva now for the GIAC)

@filipesilva Hey, sorry for the late answer, busy times. A repro will not be possible for the moment, sorry.

Certainly also because my guess is that it’s related to some circular dependency somewhere and we have a lot of them. (the blocking ones we solve by combinations of forwardRef and using Injector.get()) So a repro would have to include a lot of code from the project is my guess.

If I solve the first error, by changing the code in AppReadyEvent service, then the issue appears in a component. So I actually guess that’s progress 😉. And, now it seems to not be related to forwardRef:

image

Ha, I’m updating this as I go along 😃, So I was thinking that it could be related to the fact that I give the SheetViewComponent as type, in a component that SheetViewComponent is connected to already. (so they are circularely referencing each other through multiple other components in between). If I change to: sheetViewComponent: any instead of sheetViewComponent: SheetViewComponent it works.

So the change from 7 to 8 seems to be that somehow inputs cannot have circular references?

This sounds like something that Angular simply cannot fix since it’s a limitation of how JS works, right?