Skip to content

Commit

Permalink
feat(router): make RouterOutlet name an Input so it can be set dynami…
Browse files Browse the repository at this point in the history
…cally

This commit updates the 'name' of `RouterOutlet` to be an `Input` rather
than an attribute. Note that this change does not affect `[attr.name]=`
because those already would not have worked. The static name was only
read in the constructor and if it wasn't available, it would use
'PRIMARY' instead.

fixes #12522

BREAKING CHANGE: Previously, the `RouterOutlet` would immediately
instantiate the component being activated during navigation. Now the
component is not instantiated until the change detection runs. This
could affect tests which do not trigger change detection after a router
navigation.
  • Loading branch information
atscott committed Jul 7, 2022
1 parent ad2721a commit 3859f79
Show file tree
Hide file tree
Showing 4 changed files with 210 additions and 24 deletions.
9 changes: 6 additions & 3 deletions goldens/public-api/router/index.md
Expand Up @@ -744,7 +744,7 @@ export class RouterModule {

// @public
export class RouterOutlet implements OnDestroy, OnInit, RouterOutletContract {
constructor(parentContexts: ChildrenOutletContexts, location: ViewContainerRef, name: string, changeDetector: ChangeDetectorRef, environmentInjector: EnvironmentInjector);
constructor(parentContexts: ChildrenOutletContexts, location: ViewContainerRef, changeDetector: ChangeDetectorRef, environmentInjector: EnvironmentInjector);
// (undocumented)
get activatedRoute(): ActivatedRoute;
// (undocumented)
Expand All @@ -765,14 +765,17 @@ export class RouterOutlet implements OnDestroy, OnInit, RouterOutletContract {
detachEvents: EventEmitter<unknown>;
// (undocumented)
get isActivated(): boolean;
name: string;
// (undocumented)
ngOnChanges(changes: SimpleChanges): void;
// (undocumented)
ngOnDestroy(): void;
// (undocumented)
ngOnInit(): void;
// (undocumented)
static ɵdir: i0.ɵɵDirectiveDeclaration<RouterOutlet, "router-outlet", ["outlet"], {}, { "activateEvents": "activate"; "deactivateEvents": "deactivate"; "attachEvents": "attach"; "detachEvents": "detach"; }, never, never, false>;
static ɵdir: i0.ɵɵDirectiveDeclaration<RouterOutlet, "router-outlet", ["outlet"], { "name": "name"; }, { "activateEvents": "activate"; "deactivateEvents": "deactivate"; "attachEvents": "attach"; "detachEvents": "detach"; }, never, never, false>;
// (undocumented)
static ɵfac: i0.ɵɵFactoryDeclaration<RouterOutlet, [null, null, { attribute: "name"; }, null, null]>;
static ɵfac: i0.ɵɵFactoryDeclaration<RouterOutlet, never>;
}

// @public
Expand Down
3 changes: 0 additions & 3 deletions packages/core/test/bundling/router/bundle.golden_symbols.json
Expand Up @@ -1997,9 +1997,6 @@
{
"name": "ɵɵinject"
},
{
"name": "ɵɵinjectAttribute"
},
{
"name": "ɵɵinvalidFactory"
},
Expand Down
65 changes: 47 additions & 18 deletions packages/router/src/directives/router_outlet.ts
Expand Up @@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/

import {Attribute, ChangeDetectorRef, ComponentFactoryResolver, ComponentRef, Directive, EnvironmentInjector, EventEmitter, Injector, OnDestroy, OnInit, Output, ViewContainerRef, ɵRuntimeError as RuntimeError,} from '@angular/core';
import {ChangeDetectorRef, ComponentFactoryResolver, ComponentRef, Directive, EnvironmentInjector, EventEmitter, Injector, Input, OnDestroy, OnInit, Output, SimpleChanges, ViewContainerRef, ɵRuntimeError as RuntimeError,} from '@angular/core';

import {RuntimeErrorCode} from '../errors';
import {Data} from '../models';
Expand Down Expand Up @@ -161,7 +161,12 @@ export interface RouterOutletContract {
export class RouterOutlet implements OnDestroy, OnInit, RouterOutletContract {
private activated: ComponentRef<any>|null = null;
private _activatedRoute: ActivatedRoute|null = null;
private name: string;
/**
* The name of the outlet
*
* @see [named outlets](guide/router-tutorial-toh#displaying-multiple-routes-in-named-outlets)
*/
@Input() name = PRIMARY_OUTLET;

@Output('activate') activateEvents = new EventEmitter<any>();
@Output('deactivate') deactivateEvents = new EventEmitter<any>();
Expand All @@ -178,10 +183,27 @@ export class RouterOutlet implements OnDestroy, OnInit, RouterOutletContract {

constructor(
private parentContexts: ChildrenOutletContexts, private location: ViewContainerRef,
@Attribute('name') name: string, private changeDetector: ChangeDetectorRef,
private environmentInjector: EnvironmentInjector) {
this.name = name || PRIMARY_OUTLET;
parentContexts.onChildOutletCreated(this.name, this);
private changeDetector: ChangeDetectorRef, private environmentInjector: EnvironmentInjector) {
}

/** @nodoc */
ngOnChanges(changes: SimpleChanges) {
if (changes['name']) {
const {firstChange, previousValue} = changes['name'];
if (firstChange) {
// The first change is handled by ngOnInit. Because ngOnChanges doesn't get called when no
// input is set at all, we need to centrally handle the first change there.
return;
}

// unregister with the old name
if (this.parentContexts.getContext(previousValue)?.outlet === this) {
this.deactivate();
this.parentContexts.onChildOutletDestroyed(previousValue);
}
// register the new name
this.initializeOutletWithName();
}
}

/** @nodoc */
Expand All @@ -194,18 +216,25 @@ export class RouterOutlet implements OnDestroy, OnInit, RouterOutletContract {

/** @nodoc */
ngOnInit(): void {
if (!this.activated) {
// If the outlet was not instantiated at the time the route got activated we need to populate
// the outlet when it is initialized (ie inside a NgIf)
const context = this.parentContexts.getContext(this.name);
if (context && context.route) {
if (context.attachRef) {
// `attachRef` is populated when there is an existing component to mount
this.attach(context.attachRef, context.route);
} else {
// otherwise the component defined in the configuration is created
this.activateWith(context.route, context.injector);
}
this.initializeOutletWithName();
}

private initializeOutletWithName() {
this.parentContexts.onChildOutletCreated(this.name, this);
if (this.activated) {
return;
}

// If the outlet was not instantiated at the time the route got activated we need to populate
// the outlet when it is initialized (ie inside a NgIf)
const context = this.parentContexts.getContext(this.name);
if (context?.route) {
if (context.attachRef) {
// `attachRef` is populated when there is an existing component to mount
this.attach(context.attachRef, context.route);
} else {
// otherwise the component defined in the configuration is created
this.activateWith(context.route, context.injector);
}
}
}
Expand Down
157 changes: 157 additions & 0 deletions packages/router/test/directives/router_outlet.spec.ts
@@ -0,0 +1,157 @@
import {CommonModule} from '@angular/common';
import {Component, Type} from '@angular/core';
import {ComponentFixture, fakeAsync, TestBed, tick} from '@angular/core/testing';
import {Router, RouterModule} from '@angular/router/src';
import {RouterTestingModule} from '@angular/router/testing';


describe('router outlet name', () => {
it('should support name binding', fakeAsync(() => {
@Component({
standalone: true,
template: '<router-outlet [name]="name"></router-outlet>',
imports: [RouterModule],
})
class RootCmp {
name = 'popup'
}

@Component({
template: 'popup component',
standalone: true,
})
class PopupCmp {
}

TestBed.configureTestingModule({
imports:
[RouterTestingModule.withRoutes([{path: '', outlet: 'popup', component: PopupCmp}])]
});
const router = TestBed.inject(Router);
const fixture = createRoot(router, RootCmp);
expect(fixture.nativeElement.innerHTML).toContain('popup component');
}));

it('should be able to change the name of the outlet', fakeAsync(() => {
@Component({
standalone: true,
template: '<router-outlet [name]="name"></router-outlet>',
imports: [RouterModule],
})
class RootCmp {
name = ''
}

@Component({
template: 'hello world',
standalone: true,
})
class GreetingCmp {
}

@Component({
template: 'goodbye cruel world',
standalone: true,
})
class FarewellCmp {
}

TestBed.configureTestingModule({
imports: [RouterTestingModule.withRoutes([
{path: '', outlet: 'greeting', component: GreetingCmp},
{path: '', outlet: 'farewell', component: FarewellCmp},
])]
});
const router = TestBed.inject(Router);
const fixture = createRoot(router, RootCmp);

expect(fixture.nativeElement.innerHTML).not.toContain('goodbye');
expect(fixture.nativeElement.innerHTML).not.toContain('hello');

fixture.componentInstance.name = 'greeting';
advance(fixture);
expect(fixture.nativeElement.innerHTML).toContain('hello');
expect(fixture.nativeElement.innerHTML).not.toContain('goodbye');

fixture.componentInstance.name = 'goodbye';
advance(fixture);
expect(fixture.nativeElement.innerHTML).toContain('goodbye');
expect(fixture.nativeElement.innerHTML).not.toContain('hello');
}));

it('should support outlets in ngFor', fakeAsync(() => {
@Component({
standalone: true,
template: `
<div *ngFor="let outlet of outlets">
<router-outlet [name]="outlet"></router-outlet>
</div>
`,
imports: [RouterModule, CommonModule],
})
class RootCmp {
outlets = ['outlet1', 'outlet2', 'outlet3'];
}

@Component({
template: 'component 1',
standalone: true,
})
class Cmp1 {
}

@Component({
template: 'component 2',
standalone: true,
})
class Cmp2 {
}

@Component({
template: 'component 3',
standalone: true,
})
class Cmp3 {
}

TestBed.configureTestingModule({
imports: [RouterTestingModule.withRoutes([
{path: '1', outlet: 'outlet1', component: Cmp1},
{path: '2', outlet: 'outlet2', component: Cmp2},
{path: '3', outlet: 'outlet3', component: Cmp3},
])]
});
const router = TestBed.inject(Router);
const fixture = createRoot(router, RootCmp);

router.navigate([{outlets: {'outlet1': '1'}}]);
advance(fixture);
expect(fixture.nativeElement.innerHTML).toContain('component 1');
expect(fixture.nativeElement.innerHTML).not.toContain('component 2');
expect(fixture.nativeElement.innerHTML).not.toContain('component 3');

router.navigate([{outlets: {'outlet1': null, 'outlet2': '2', 'outlet3': '3'}}]);
advance(fixture);
expect(fixture.nativeElement.innerHTML).not.toContain('component 1');
expect(fixture.nativeElement.innerHTML).toMatch('.*component 2.*component 3');

// reverse the outlets
fixture.componentInstance.outlets = ['outlet3', 'outlet2', 'outlet1'];
router.navigate([{outlets: {'outlet1': '1', 'outlet2': '2', 'outlet3': '3'}}]);
advance(fixture);
expect(fixture.nativeElement.innerHTML).toMatch('.*component 3.*component 2.*component 1');
}));
});

function advance(fixture: ComponentFixture<any>, millis?: number): void {
tick(millis);
fixture.detectChanges();
}

function createRoot<T>(router: Router, type: Type<T>): ComponentFixture<T> {
const f = TestBed.createComponent(type);
advance(f);
router.initialNavigation();
advance(f);
return f;
}

0 comments on commit 3859f79

Please sign in to comment.