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
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);
}
}
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"}
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.
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"}
Why do changes made to an object that is provided via forRoot
get reverted when the object is injected into a dependent class?
--aot
flag rather than the broader --prod
flag.angular-cli
project: https://github.com/angular/angular-cli/issues/10610
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;
.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
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:
Note that argument is simply static object value.
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:
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:
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.
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 })
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With