From a82fddf1ce6166e0f697e429370eade114094670 Mon Sep 17 00:00:00 2001 From: Andrew Scott Date: Fri, 12 Feb 2021 09:45:12 -0800 Subject: [PATCH] feat(router): Allow for custom router outlet implementations (#40827) This PR formalizes, documents, and makes public the router outlet contract. The set of `RouterOutlet` methods used by the `Router` has not changed in over 4 years, since the introduction of route reuse strategies. Creation of custom router outlets is already possible and is used by the Ionic framework (https://github.com/ionic-team/ionic-framework/blob/master/angular/src/directives/navigation/ion-router-outlet.ts). There is a small "hack" that is needed to make this work, which is that outlets must register with `ChildrenOutletContexts`, but it currently only accepts our `RouterOutlet`. By exposing the interface the `Router` uses to activate and deactivate routes through outlets, we allow for developers to more easily and safely extend the `Router` and have fine-tuned control over navigation and component activation that fits project requirements. PR Close #40827 --- goldens/public-api/router/router.d.ts | 17 ++++- .../router/src/directives/router_outlet.ts | 67 ++++++++++++++++++- packages/router/src/index.ts | 2 +- packages/router/src/router_outlet_context.ts | 6 +- 4 files changed, 84 insertions(+), 8 deletions(-) diff --git a/goldens/public-api/router/router.d.ts b/goldens/public-api/router/router.d.ts index bb9bc0a39f..f0ac9d190f 100644 --- a/goldens/public-api/router/router.d.ts +++ b/goldens/public-api/router/router.d.ts @@ -92,7 +92,7 @@ export declare class ChildActivationStart { export declare class ChildrenOutletContexts { getContext(childName: string): OutletContext | null; getOrCreateContext(childName: string): OutletContext; - onChildOutletCreated(childName: string, outlet: RouterOutlet): void; + onChildOutletCreated(childName: string, outlet: RouterOutletContract): void; onChildOutletDestroyed(childName: string): void; onOutletDeactivated(): Map; onOutletReAttached(contexts: Map): void; @@ -234,7 +234,7 @@ export declare class NoPreloading implements PreloadingStrategy { export declare class OutletContext { attachRef: ComponentRef | null; children: ChildrenOutletContexts; - outlet: RouterOutlet | null; + outlet: RouterOutletContract | null; resolver: ComponentFactoryResolver | null; route: ActivatedRoute | null; } @@ -434,7 +434,7 @@ export declare class RouterModule { static forRoot(routes: Routes, config?: ExtraOptions): ModuleWithProviders; } -export declare class RouterOutlet implements OnDestroy, OnInit { +export declare class RouterOutlet implements OnDestroy, OnInit, RouterOutletContract { activateEvents: EventEmitter; get activatedRoute(): ActivatedRoute; get activatedRouteData(): Data; @@ -450,6 +450,17 @@ export declare class RouterOutlet implements OnDestroy, OnInit { ngOnInit(): void; } +export declare interface RouterOutletContract { + activatedRoute: ActivatedRoute | null; + activatedRouteData: Data; + component: Object | null; + isActivated: boolean; + activateWith(activatedRoute: ActivatedRoute, resolver: ComponentFactoryResolver | null): void; + attach(ref: ComponentRef, activatedRoute: ActivatedRoute): void; + deactivate(): void; + detach(): ComponentRef; +} + export declare class RouterPreloader implements OnDestroy { constructor(router: Router, moduleLoader: NgModuleFactoryLoader, compiler: Compiler, injector: Injector, preloadingStrategy: PreloadingStrategy); ngOnDestroy(): void; diff --git a/packages/router/src/directives/router_outlet.ts b/packages/router/src/directives/router_outlet.ts index 6e7425a4f5..34c158262e 100644 --- a/packages/router/src/directives/router_outlet.ts +++ b/packages/router/src/directives/router_outlet.ts @@ -13,6 +13,67 @@ import {ChildrenOutletContexts} from '../router_outlet_context'; import {ActivatedRoute} from '../router_state'; import {PRIMARY_OUTLET} from '../shared'; +/** + * An interface that defines the contract for developing a component outlet for the `Router`. + * + * An outlet acts as a placeholder that Angular dynamically fills based on the current router state. + * + * A router outlet should register itself with the `Router` via + * `ChildrenOutletContexts#onChildOutletCreated` and unregister with + * `ChildrenOutletContexts#onChildOutletDestroyed`. When the `Router` identifies a matched `Route`, + * it looks for a registered outlet in the `ChildrenOutletContexts` and activates it. + * + * @see `ChildrenOutletContexts` + * @publicApi + */ +export interface RouterOutletContract { + /** + * Whether the given outlet is activated. + * + * An outlet is considered "activated" if it has an active component. + */ + isActivated: boolean; + + /** The instance of the activated component or `null` if the outlet is not activated. */ + component: Object|null; + + /** + * The `Data` of the `ActivatedRoute` snapshot. + */ + activatedRouteData: Data; + + /** + * The `ActivatedRoute` for the outlet or `null` if the outlet is not activated. + */ + activatedRoute: ActivatedRoute|null; + + /** + * Called by the `Router` when the outlet should activate (create a component). + */ + activateWith(activatedRoute: ActivatedRoute, resolver: ComponentFactoryResolver|null): void; + + /** + * A request to destroy the currently activated component. + * + * When a `RouteReuseStrategy` indicates that an `ActivatedRoute` should be removed but stored for + * later re-use rather than destroyed, the `Router` will call `detach` instead. + */ + deactivate(): void; + + /** + * Called when the `RouteReuseStrategy` instructs to detach the subtree. + * + * This is similar to `deactivate`, but the activated component should _not_ be destroyed. + * Instead, it is returned so that it can be reattached later via the `attach` method. + */ + detach(): ComponentRef; + + /** + * Called when the `RouteReuseStrategy` instructs to re-attach a previously detached subtree. + */ + attach(ref: ComponentRef, activatedRoute: ActivatedRoute): void; +} + /** * @description * @@ -60,7 +121,7 @@ import {PRIMARY_OUTLET} from '../shared'; * @publicApi */ @Directive({selector: 'router-outlet', exportAs: 'outlet'}) -export class RouterOutlet implements OnDestroy, OnInit { +export class RouterOutlet implements OnDestroy, OnInit, RouterOutletContract { private activated: ComponentRef|null = null; private _activatedRoute: ActivatedRoute|null = null; private name: string; @@ -103,6 +164,10 @@ export class RouterOutlet implements OnDestroy, OnInit { return !!this.activated; } + /** + * @returns The currently activated component instance. + * @throws An error if the outlet is not activated. + */ get component(): Object { if (!this.activated) throw new Error('Outlet is not activated'); return this.activated.instance; diff --git a/packages/router/src/index.ts b/packages/router/src/index.ts index e85ec0e315..688323fece 100644 --- a/packages/router/src/index.ts +++ b/packages/router/src/index.ts @@ -10,7 +10,7 @@ export {Data, DeprecatedLoadChildren, LoadChildren, LoadChildrenCallback, QueryParamsHandling, ResolveData, Route, Routes, RunGuardsAndResolvers, UrlMatcher, UrlMatchResult} from './config'; export {RouterLink, RouterLinkWithHref} from './directives/router_link'; export {RouterLinkActive} from './directives/router_link_active'; -export {RouterOutlet} from './directives/router_outlet'; +export {RouterOutlet, RouterOutletContract} from './directives/router_outlet'; export {ActivationEnd, ActivationStart, ChildActivationEnd, ChildActivationStart, Event, GuardsCheckEnd, GuardsCheckStart, NavigationCancel, NavigationEnd, NavigationError, NavigationStart, ResolveEnd, ResolveStart, RouteConfigLoadEnd, RouteConfigLoadStart, RouterEvent, RoutesRecognized, Scroll} from './events'; export {CanActivate, CanActivateChild, CanDeactivate, CanLoad, Resolve} from './interfaces'; export {BaseRouteReuseStrategy, DetachedRouteHandle, RouteReuseStrategy} from './route_reuse_strategy'; diff --git a/packages/router/src/router_outlet_context.ts b/packages/router/src/router_outlet_context.ts index 1ed0b8b96b..d0485f4d01 100644 --- a/packages/router/src/router_outlet_context.ts +++ b/packages/router/src/router_outlet_context.ts @@ -8,7 +8,7 @@ import {ComponentFactoryResolver, ComponentRef} from '@angular/core'; -import {RouterOutlet} from './directives/router_outlet'; +import {RouterOutletContract} from './directives/router_outlet'; import {ActivatedRoute} from './router_state'; @@ -18,7 +18,7 @@ import {ActivatedRoute} from './router_state'; * @publicApi */ export class OutletContext { - outlet: RouterOutlet|null = null; + outlet: RouterOutletContract|null = null; route: ActivatedRoute|null = null; resolver: ComponentFactoryResolver|null = null; children = new ChildrenOutletContexts(); @@ -35,7 +35,7 @@ export class ChildrenOutletContexts { private contexts = new Map(); /** Called when a `RouterOutlet` directive is instantiated */ - onChildOutletCreated(childName: string, outlet: RouterOutlet): void { + onChildOutletCreated(childName: string, outlet: RouterOutletContract): void { const context = this.getOrCreateContext(childName); context.outlet = outlet; this.contexts.set(childName, context);