Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

When using AOT, changes to objects passed to forRoot are discarded when injected

Overview

I am seeing odd behavior in that the properties that are added to an object through a destructuring assignment (or Object.assign) are present when passed to forRoot but are not present when injected into a service. Additionally, updates made after initialization are present when passed to forRoot but are not present when injected into a service. This only occurs when building with AOT.

I created a minimal project that reproduces the issue: https://github.com/bygrace1986/wat

Package Versions

  • angular: 5.2.0
  • angular-cli: 1.6.4 (my app), 1.7.4 (my test)
  • typescript: 2.4.2

Setup

Create a module that has a static forRoot method that will accept an object that it will then provide via an InjectionToken and injected into a service.

TestModule

import { NgModule, ModuleWithProviders } from '@angular/core';
import { CommonModule } from '@angular/common';

import { TestDependency, TEST_DEPENDENCY, TestService } from './test.service';

@NgModule({
  imports: [
    CommonModule
  ],
  declarations: []
})
export class TestModule {
  static forRoot(dependency?: TestDependency): ModuleWithProviders {
    return {
      ngModule: TestModule,
      providers: [
        TestService,
        { provide: TEST_DEPENDENCY, useValue: dependency }
      ]
    }
  }
}

TestService

import { Injectable, InjectionToken, Inject } from '@angular/core';

export interface TestDependency {
    property: string;
}

export const TEST_DEPENDENCY = new InjectionToken<TestDependency>('TEST_DEPENDENCY');

@Injectable()
export class TestService {
    constructor(@Inject(TEST_DEPENDENCY) dependency: TestDependency) {
        console.log('[TestService]', dependency);
    }
}

Non-mutated Object Scenario

This scenario illustrates that passing an non-mutated object to forRoot is properly injected into a service that depends on it.

To setup reference the TestService in the app.component.html (or somewhere where it will be injected). Pass the dependency to the forRoot method on the TestModule.

AppModule

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';

import { AppComponent } from './app.component';
import { TestModule } from './test/test.module';
import { TestDependency } from './test/test.service';

const dependency = {
  property: 'value'
} as TestDependency;
console.log('[AppModule]', dependency)

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule,
    TestModule.forRoot(dependency)
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

Run ng serve --aot.

Output

[AppModule] {property: "value"}
[TestService] {property: "value"}

Mutated Object Scenario

This scenario illustrates that changes made to an object through object destructuring assignment during initialization or changes made after initialization are ignored when the provided object is injected into a service that depends on it.

To setup create a new object with additional properties and use object destructuring to assign the properties of the old object to the new. Then update a property on dependency and pass it to the forRoot method on the TestModule.

AppModule

const dependency = {
  property: 'value'
} as TestDependency;
const dependencyCopy = { id: 1, name: 'first', ...dependency };
dependencyCopy.name = 'last';
console.log('[AppModule]', dependencyCopy);
...
TestModule.forRoot(dependencyCopy)

Run ng serve --aot.

Output

[AppModule] {id: 1, name: "last", property: "value"}
[TestService] {id: 1, name: "first"}

Unexpected Result

Any property added by object destructuring assignment was removed and any update made after initialization is reverted between when the object is passed to forRoot and injected into TestService. In fact, it isn't event the same object (I debugged and checked with ===). It is as if the original object that is created before assignment or mutation is being used... somehow.

Mutated Object Provided at AppModule Scenario

This scenario illustrates that mutations to the object are not reverted when provided at the AppModule level rather than through forRoot.

To setup don't pass anything to forRoot. Instead use the injection token to provide the object in the AppModule providers list.

AppModule

imports: [
  BrowserModule,
  TestModule.forRoot()
],
providers: [
  { provide: TEST_DEPENDENCY, useValue: dependencyCopy }
],

Run ng serve --aot.

Output

[AppModule] {id: 1, name: "last", property: "value"}
[TestService] {id: 1, name: "last", property: "value"}

Question

Why do changes made to an object that is provided via forRoot get reverted when the object is injected into a dependent class?

Updates

  • Realized that updates after initialization are also reverted.
  • Realized that this can be reproduced with just the --aot flag rather than the broader --prod flag.
  • Updated to latest stable cli version (1.7.4) and still have the issue.
  • Opened a bug on the angular-cli project: https://github.com/angular/angular-cli/issues/10610
  • From reading the generated code I think that the issue is in Phase 2 metadata rewriting. When only referencing the variable in forRoot I get this: i0.ɵmpd(256, i6.TEST_DEPENDENCY, { id: 1, name: "first" }, [])]);. When it is referenced in the AppModule provider list I get this: i0.ɵmpd(256, i6.TEST_DEPENDENCY, i1.ɵ0, [])]); and then this in the app module var ɵ0 = dependencyCopy; exports.ɵ0 = ɵ0;.
like image 591
bygrace Avatar asked May 02 '18 18:05

bygrace


1 Answers

The core of AOT is Metadata collector.

It takes ts.SourceFile, then recursively walks through all AST nodes of this file and transforms all nodes to JSON representation.

Collector checks the following types of AST node at the top level of file:

ts.SyntaxKind.ExportDeclaration
ts.SyntaxKind.ClassDeclaration
ts.SyntaxKind.TypeAliasDeclaration
ts.SyntaxKind.InterfaceDeclaration
ts.SyntaxKind.FunctionDeclaration
ts.SyntaxKind.EnumDeclaration
ts.SyntaxKind.VariableStatement

Angular compiler also tries to calculate all metadata at runtime by using so-called Evaluator so that it can understand a subset of javascript expressions that is listen in docs

It should be noted that compiler supports spread operator but only in array literals not in objects

Case 1

const dependency = {
  property: 'value'               ===========> Non-exported VariableStatement
} as TestDependency;                           with value { property: 'value' }

imports: [
  ...,
  TestModule.forRoot(dependency)  ===========> Call expression with 
                                               ts.SyntaxKind.Identifier argument 
                                               which is calculated above
]

So we will get metadata like: enter image description here

Note that argument is simply static object value.

Case 2

const dependency = {
  property: 'value'               ===========> Non-exported VariableStatement
} as TestDependency;                           with value { property: 'value' }

const dependencyCopy = { 
  id: 1,                         ============> Non-exported VariableStatement
  name: 'first',                               with value { id: 1, name: 'first' }                       
  ...dependency                                (Spread operator is not supported here)
};
dependencyCopy.name = 'last';     ===========> ExpressionStatement is skipped
                                               (see list of supported types above)

...
TestModule.forRoot(dependencyCopy) ===========> Call expression with 
                                                ts.SyntaxKind.Identifier argument 
                                                which is calculated above

Here's what we get now: enter image description here

Case 3

providers: [
  { provide: TEST_DEPENDENCY, useValue: dependencyCopy }
],

In version 5 angular moved to (almost) native TS compilation process using transformers and introduces so-called Lower Expressions transformer which basically means we can now use arrow functions in decorator metadata, for example:

providers: [{provide: SERVER, useFactory: () => TypicalServer}]

will be automatically transformed by angular compiler to the following:

export const ɵ0 = () => new TypicalServer();
...
providers: [{provide: SERVER, useFactory: ɵ0}]

Now let's read documentation:

The compiler treats object literals containing the fields useClass, useValue, useFactory, and data specially. The compiler converts the expression initializing one of these fields into an exported variable, which replaces the expression. This process of rewriting these expressions removes all the restrictions on what can be in them because the compiler doesn't need to know the expression's value—it just needs to be able to generate a reference to the value.

Now let's see how it works in our third case: enter image description here

We can see that angular now uses reference to dependencyCopy object. And as you have already noticed it will be used as var ɵ0 = dependencyCopy; in generated factory.

Possible Solution:

test.module.ts

export class TestModule {
  static forRoot(dependency?: { data: TestDependency }): ModuleWithProviders {
    return {
      ngModule: TestModule,
      providers: [
        TestService,
        { provide: TEST_DEPENDENCY, useValue: dependency.data }
      ]
    };
  }
}

app.module.ts

TestModule.forRoot({ data: dependencyCopy })
like image 170
yurzui Avatar answered Oct 24 '22 17:10

yurzui