Skip to content

Commit

Permalink
feat(router): Allow for custom router outlet implementations (#40827)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
atscott committed Feb 19, 2021
1 parent d0b6270 commit a82fddf
Show file tree
Hide file tree
Showing 4 changed files with 84 additions and 8 deletions.
17 changes: 14 additions & 3 deletions goldens/public-api/router/router.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down
67 changes: 66 additions & 1 deletion packages/router/src/directives/router_outlet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion packages/router/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
6 changes: 3 additions & 3 deletions packages/router/src/router_outlet_context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';


Expand All @@ -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();
Expand All @@ -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);
Expand Down

0 comments on commit a82fddf

Please sign in to comment.