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 (#46569)

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. In rarer cases, this can affect production code that relies
on the exact timing of component availability.

PR Close #46569
  • Loading branch information
atscott authored and AndrewKushnir committed Oct 4, 2022
1 parent 07d9a27 commit c3f8579
Show file tree
Hide file tree
Showing 4 changed files with 224 additions and 28 deletions.
9 changes: 5 additions & 4 deletions goldens/public-api/router/index.md
Expand Up @@ -31,7 +31,6 @@ import { SimpleChanges } from '@angular/core';
import { Title } from '@angular/platform-browser';
import { Type } from '@angular/core';
import { Version } from '@angular/core';
import { ViewContainerRef } from '@angular/core';

// @public
export class ActivatedRoute {
Expand Down Expand Up @@ -800,7 +799,6 @@ export class RouterModule {

// @public
export class RouterOutlet implements OnDestroy, OnInit, RouterOutletContract {
constructor(parentContexts: ChildrenOutletContexts, location: ViewContainerRef, name: string, changeDetector: ChangeDetectorRef, environmentInjector: EnvironmentInjector);
// (undocumented)
get activatedRoute(): ActivatedRoute;
// (undocumented)
Expand All @@ -821,14 +819,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, true, never>;
static ɵdir: i0.ɵɵDirectiveDeclaration<RouterOutlet, "router-outlet", ["outlet"], { "name": "name"; }, { "activateEvents": "activate"; "deactivateEvents": "deactivate"; "attachEvents": "attach"; "detachEvents": "detach"; }, never, never, true, never>;
// (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 @@ -1892,9 +1892,6 @@
{
"name": "ɵɵinject"
},
{
"name": "ɵɵinjectAttribute"
},
{
"name": "ɵɵlistener"
},
Expand Down
75 changes: 54 additions & 21 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, inject, 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 @@ -165,7 +165,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 @@ -180,36 +185,64 @@ export class RouterOutlet implements OnDestroy, OnInit, RouterOutletContract {
*/
@Output('detach') detachEvents = new EventEmitter<unknown>();

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 parentContexts = inject(ChildrenOutletContexts);
private location = inject(ViewContainerRef);
private changeDetector = inject(ChangeDetectorRef);
private environmentInjector = inject(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.isTrackedInParentContexts(previousValue)) {
this.deactivate();
this.parentContexts.onChildOutletDestroyed(previousValue);
}
// register the new name
this.initializeOutletWithName();
}
}

/** @nodoc */
ngOnDestroy(): void {
// Ensure that the registered outlet is this one before removing it on the context.
if (this.parentContexts.getContext(this.name)?.outlet === this) {
if (this.isTrackedInParentContexts(this.name)) {
this.parentContexts.onChildOutletDestroyed(this.name);
}
}

private isTrackedInParentContexts(outletName: string) {
return this.parentContexts.getContext(outletName)?.outlet === this;
}

/** @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
165 changes: 165 additions & 0 deletions packages/router/test/directives/router_outlet.spec.ts
@@ -0,0 +1,165 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/

import {CommonModule, NgForOf} from '@angular/common';
import {Component, Type} from '@angular/core';
import {ComponentFixture, fakeAsync, TestBed, tick} from '@angular/core/testing';
import {Router, RouterModule, RouterOutlet} 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: [RouterOutlet],
})
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: [RouterOutlet],
})
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: [RouterOutlet, NgForOf],
})
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 c3f8579

Please sign in to comment.