Skip to content

forwardRef breaks at runtime with ES2015 #30106

Closed
@filipesilva

Description

@filipesilva

🐞 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.

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions