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
This commit is contained in:
Andrew Scott 2021-02-12 09:45:12 -08:00 committed by atscott
parent d0b6270990
commit a82fddf1ce
4 changed files with 84 additions and 8 deletions

View File

@ -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<string, OutletContext>;
onOutletReAttached(contexts: Map<string, OutletContext>): void;
@ -234,7 +234,7 @@ export declare class NoPreloading implements PreloadingStrategy {
export declare class OutletContext {
attachRef: ComponentRef<any> | 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<RouterModule>;
}
export declare class RouterOutlet implements OnDestroy, OnInit {
export declare class RouterOutlet implements OnDestroy, OnInit, RouterOutletContract {
activateEvents: EventEmitter<any>;
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<unknown>, activatedRoute: ActivatedRoute): void;
deactivate(): void;
detach(): ComponentRef<unknown>;
}
export declare class RouterPreloader implements OnDestroy {
constructor(router: Router, moduleLoader: NgModuleFactoryLoader, compiler: Compiler, injector: Injector, preloadingStrategy: PreloadingStrategy);
ngOnDestroy(): void;

View File

@ -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<unknown>;
/**
* Called when the `RouteReuseStrategy` instructs to re-attach a previously detached subtree.
*/
attach(ref: ComponentRef<unknown>, 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<any>|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;

View File

@ -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';

View File

@ -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<string, OutletContext>();
/** 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);