diff --git a/goldens/public-api/router/index.md b/goldens/public-api/router/index.md index 73106b5fa8f0f..701f6ce95bdbf 100644 --- a/goldens/public-api/router/index.md +++ b/goldens/public-api/router/index.md @@ -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 { @@ -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) @@ -821,14 +819,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 773741068f269..7192756290d08 100644 --- a/packages/core/test/bundling/router/bundle.golden_symbols.json +++ b/packages/core/test/bundling/router/bundle.golden_symbols.json @@ -1892,9 +1892,6 @@ { "name": "ɵɵinject" }, - { - "name": "ɵɵinjectAttribute" - }, { "name": "ɵɵlistener" }, diff --git a/packages/router/src/directives/router_outlet.ts b/packages/router/src/directives/router_outlet.ts index a582dfeed53d3..8fec3d76d636e 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, inject, Injector, Input, OnDestroy, OnInit, Output, SimpleChanges, ViewContainerRef, ɵRuntimeError as RuntimeError,} from '@angular/core'; import {RuntimeErrorCode} from '../errors'; import {Data} from '../models'; @@ -165,7 +165,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(); @@ -180,36 +185,64 @@ export class RouterOutlet implements OnDestroy, OnInit, RouterOutletContract { */ @Output('detach') detachEvents = new EventEmitter(); - 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); } } } 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..f284cfe342d76 --- /dev/null +++ b/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: '', + 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: '', + 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: ` +
+ +
+ `, + 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, 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; +}