From 3859f798d7379413d40a89ab911edcf29944c9a0 Mon Sep 17 00:00:00 2001 From: Andrew Scott Date: Tue, 28 Jun 2022 14:54:55 -0700 Subject: [PATCH] feat(router): make RouterOutlet name an Input so it can be set dynamically 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. --- goldens/public-api/router/index.md | 9 +- .../router/bundle.golden_symbols.json | 3 - .../router/src/directives/router_outlet.ts | 65 ++++++-- .../test/directives/router_outlet.spec.ts | 157 ++++++++++++++++++ 4 files changed, 210 insertions(+), 24 deletions(-) create mode 100644 packages/router/test/directives/router_outlet.spec.ts diff --git a/goldens/public-api/router/index.md b/goldens/public-api/router/index.md index 979d53735c4c9..38112e9714b19 100644 --- a/goldens/public-api/router/index.md +++ b/goldens/public-api/router/index.md @@ -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) @@ -765,14 +765,17 @@ export class RouterOutlet implements OnDestroy, OnInit, RouterOutletContract { detachEvents: EventEmitter; // (undocumented) get isActivated(): boolean; + name: string; + // (undocumented) + ngOnChanges(changes: SimpleChanges): void; // (undocumented) ngOnDestroy(): void; // (undocumented) ngOnInit(): void; // (undocumented) - static ɵdir: i0.ɵɵDirectiveDeclaration; + static ɵdir: i0.ɵɵDirectiveDeclaration; // (undocumented) - static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵfac: i0.ɵɵFactoryDeclaration; } // @public diff --git a/packages/core/test/bundling/router/bundle.golden_symbols.json b/packages/core/test/bundling/router/bundle.golden_symbols.json index e6774208e8fa1..fc77e6a9b8718 100644 --- a/packages/core/test/bundling/router/bundle.golden_symbols.json +++ b/packages/core/test/bundling/router/bundle.golden_symbols.json @@ -1997,9 +1997,6 @@ { "name": "ɵɵinject" }, - { - "name": "ɵɵinjectAttribute" - }, { "name": "ɵɵinvalidFactory" }, diff --git a/packages/router/src/directives/router_outlet.ts b/packages/router/src/directives/router_outlet.ts index 0d47273dbbf5e..b4269c9f8fcf4 100644 --- a/packages/router/src/directives/router_outlet.ts +++ b/packages/router/src/directives/router_outlet.ts @@ -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'; @@ -161,7 +161,12 @@ export interface RouterOutletContract { export class RouterOutlet implements OnDestroy, OnInit, RouterOutletContract { private activated: ComponentRef|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(); @Output('deactivate') deactivateEvents = new EventEmitter(); @@ -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 */ @@ -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); } } } diff --git a/packages/router/test/directives/router_outlet.spec.ts b/packages/router/test/directives/router_outlet.spec.ts new file mode 100644 index 0000000000000..3716557a3c344 --- /dev/null +++ b/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: '', + 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: '', + 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: ` +
+ +
+ `, + 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, millis?: number): void { + tick(millis); + fixture.detectChanges(); +} + +function createRoot(router: Router, type: Type): ComponentFixture { + const f = TestBed.createComponent(type); + advance(f); + router.initialNavigation(); + advance(f); + return f; +}