Skip to content

Commit

Permalink
feat: Allow lazily-loaded views to inject masked injection tokens
Browse files Browse the repository at this point in the history
Current behavior:
If providing a service in a lazily loaded module with the same injection token as a service provided in the root module,
the service of the root injector is injected into the lazily loaded view instead of the service of the child injector.

Expected behavior:
The service provided in the lazily loaded module should be preferred over the service provided in the root module.
  • Loading branch information
danielwiehl committed Dec 11, 2018
1 parent e45e2fb commit 3c212d0
Show file tree
Hide file tree
Showing 3 changed files with 199 additions and 3 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
/*
* Copyright (c) 2018 Swiss Federal Railways
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/

import { async, fakeAsync, inject, TestBed } from '@angular/core/testing';
import { Component, Inject, Injectable, InjectionToken, NgModule, NgModuleFactoryLoader, Optional } from '@angular/core';
import { WorkbenchModule } from '../workbench.module';
import { expect, jasmineCustomMatchers } from './util/jasmine-custom-matchers.spec';
import { RouterTestingModule, SpyNgModuleFactoryLoader } from '@angular/router/testing';
import { Router, RouterModule } from '@angular/router';
import { WorkbenchRouter } from '../routing/workbench-router.service';
import { CommonModule } from '@angular/common';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { advance, clickElement } from './util/util.spec';
import { ActivityPartComponent } from '../activity-part/activity-part.component';
import { By } from '@angular/platform-browser';

/**
*
* Testsetup:
*
* +--------------+
* | Test Module |
* +--------------+
* |
* feature
* |
* v
* +------------------------------------------+
* | Feature Module |
* |------------------------------------------|
* | routes: |
* | |
* | 'activity' => Feature_Activity_Component |
* | 'view' => Feature_View_Component |
* +------------------------------------------+
*
*/
// tslint:disable class-name
describe('Lazily loaded view', () => {

beforeEach(async(() => {
jasmine.addMatchers(jasmineCustomMatchers);

TestBed.configureTestingModule({
imports: [AppTestModule]
});

TestBed.get(Router).initialNavigation();
}));

it('should get services injected from its child injector', fakeAsync(inject([WorkbenchRouter, NgModuleFactoryLoader], (wbRouter: WorkbenchRouter, loader: SpyNgModuleFactoryLoader) => {
loader.stubbedModules = {
'./feature/feature.module#FeatureModule': FeatureModule,
};

const fixture = TestBed.createComponent(AppComponent);
advance(fixture);

// Open 'feature/activity'
clickElement(fixture, ActivityPartComponent, 'a.activity');

// Verify injection token
const activityComponent: Feature_Activity_Component = fixture.debugElement.query(By.directive(Feature_Activity_Component)).componentInstance;
expect(activityComponent.featureService).not.toBeNull('(1)');
expect(activityComponent.featureService).not.toBeUndefined('(2)');

// Open 'feature/view'
wbRouter.navigate(['/feature/view']).then();
advance(fixture);

// Verify injection token
const viewComponent: Feature_View_Component = fixture.debugElement.query(By.directive(Feature_View_Component)).componentInstance;
expect(viewComponent.featureService).not.toBeNull('(3)');
expect(viewComponent.featureService).not.toBeUndefined('(4)');
advance(fixture);
})));

/**
* Verifies that a service provided in the lazily loaded module should be preferred over the service provided in the root module.
*/
it('should get services injected from its child injector prior to from the root injector', fakeAsync(inject([WorkbenchRouter, NgModuleFactoryLoader], (wbRouter: WorkbenchRouter, loader: SpyNgModuleFactoryLoader) => {
loader.stubbedModules = {
'./feature/feature.module#FeatureModule': FeatureModule,
};

const fixture = TestBed.createComponent(AppComponent);
advance(fixture);

// Open 'feature/activity'
clickElement(fixture, ActivityPartComponent, 'a.activity');

// Verify injection token
const activityComponent: Feature_Activity_Component = fixture.debugElement.query(By.directive(Feature_Activity_Component)).componentInstance;
expect(activityComponent.injectedValue).toEqual('child-injector-value', '(1)');

// Open 'feature/view'
wbRouter.navigate(['/feature/view']).then();
advance(fixture);

// Verify injection token
const viewComponent: Feature_View_Component = fixture.debugElement.query(By.directive(Feature_View_Component)).componentInstance;
expect(viewComponent.injectedValue).toEqual('child-injector-value', '(2)');
advance(fixture);
})));
});

/****************************************************************************************************
* Definition of App Test Module *
****************************************************************************************************/
@Component({
template: `
<wb-workbench style="position: relative; width: 100%; height: 500px">
<wb-activity cssClass="activity"
label="activity"
routerLink="feature/activity">
</wb-activity>
</wb-workbench>
`
})
class AppComponent {
}

const DI_TOKEN = new InjectionToken<string>('TOKEN');

@Injectable()
export class FeatureService {
}

@NgModule({
imports: [
WorkbenchModule.forRoot(),
NoopAnimationsModule,
RouterTestingModule.withRoutes([
{path: 'feature', loadChildren: './feature/feature.module#FeatureModule'},
]),
],
declarations: [AppComponent],
providers: [
{provide: DI_TOKEN, useValue: 'root-injector-value'},
]
})
class AppTestModule {
}

/****************************************************************************************************
* Definition of Feature Module *
****************************************************************************************************/
@Component({template: 'Injected value: {{injectedValue}}'})
class Feature_Activity_Component {
constructor(@Inject(DI_TOKEN) public injectedValue: string,
@Optional() public featureService: FeatureService) {
}
}

@Component({template: 'Injected value: {{injectedValue}}'})
class Feature_View_Component {
constructor(@Inject(DI_TOKEN) public injectedValue: string,
@Optional() public featureService: FeatureService) {
}
}

@NgModule({
imports: [
CommonModule,
RouterModule.forChild([
{path: 'activity', component: Feature_Activity_Component},
{path: 'view', component: Feature_View_Component}
]),
],
declarations: [
Feature_Activity_Component,
Feature_View_Component
],
providers: [
{provide: DI_TOKEN, useValue: 'child-injector-value'},
FeatureService,
]
})
class FeatureModule {
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,7 @@ export class WorkbenchViewRegistry implements OnDestroy {
private readonly _destroy$ = new Subject<void>();
private readonly _viewRegistry = new Map<string, InternalWorkbenchView>();

constructor(private _injector: Injector,
private _componentFactoryResolver: ComponentFactoryResolver,
constructor(private _componentFactoryResolver: ComponentFactoryResolver,
private _workbench: WorkbenchService) {
}

Expand Down Expand Up @@ -91,8 +90,17 @@ export class WorkbenchViewRegistry implements OnDestroy {
injectionTokens.set(WorkbenchView, view);
injectionTokens.set(InternalWorkbenchView, view);

// We must not use the root injector as parent injector of the portal component element injector.
// Otherwise, if tokens of the root injector are masked or extended in lazily loaded modules, they would not be resolved.
//
// This is by design of Angular injection token resolution rules of not checking module injectors when checking the element hierarchy for a token.
// See function `resolveDep` in Angular file `provider.ts`.
//
// Instead, we use a {NullInjector} which further acts as a barrier to not resolve workbench internal tokens declared somewhere in the element hierarchy.
const injector = new PortalInjector(Injector.NULL, injectionTokens);

portal.init({
injector: new PortalInjector(this._injector, injectionTokens),
injector: injector,
onActivate: (): void => view.activate(true),
onDeactivate: (): void => view.activate(false),
});
Expand Down
1 change: 1 addition & 0 deletions resources/site/_changelog-next-release.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@

### Bug Fixes

* Allow lazily-loaded views to inject masked injection tokens ([#21](https://github.com/SchweizerischeBundesbahnen/scion-workbench/issues/21)) ([xxx](https://github.com/SchweizerischeBundesbahnen/scion-workbench/commit/xxx))

0 comments on commit 3c212d0

Please sign in to comment.