diff --git a/packages/router/src/directives/router_outlet.ts b/packages/router/src/directives/router_outlet.ts index 9c8082b8b6..12612f91fc 100644 --- a/packages/router/src/directives/router_outlet.ts +++ b/packages/router/src/directives/router_outlet.ts @@ -6,8 +6,9 @@ * found in the LICENSE file at https://angular.io/license */ -import {Attribute, ComponentFactoryResolver, ComponentRef, Directive, EventEmitter, Injector, OnDestroy, Output, ReflectiveInjector, ResolvedReflectiveProvider, ViewContainerRef} from '@angular/core'; -import {RouterOutletMap} from '../router_outlet_map'; +import {Attribute, ChangeDetectorRef, ComponentFactoryResolver, ComponentRef, Directive, EventEmitter, Injector, OnDestroy, OnInit, Output, ViewContainerRef} from '@angular/core'; + +import {ChildrenOutletContexts} from '../router_outlet_context'; import {ActivatedRoute} from '../router_state'; import {PRIMARY_OUTLET} from '../shared'; @@ -36,23 +37,40 @@ import {PRIMARY_OUTLET} from '../shared'; * @stable */ @Directive({selector: 'router-outlet'}) -export class RouterOutlet implements OnDestroy { +export class RouterOutlet implements OnDestroy, OnInit { private activated: ComponentRef|null = null; private _activatedRoute: ActivatedRoute|null = null; - private _outletName: string; - public outletMap: RouterOutletMap; + private name: string; @Output('activate') activateEvents = new EventEmitter(); @Output('deactivate') deactivateEvents = new EventEmitter(); constructor( - private parentOutletMap: RouterOutletMap, private location: ViewContainerRef, - private resolver: ComponentFactoryResolver, @Attribute('name') name: string) { - this._outletName = name || PRIMARY_OUTLET; - parentOutletMap.registerOutlet(this._outletName, this); + private parentContexts: ChildrenOutletContexts, private location: ViewContainerRef, + private resolver: ComponentFactoryResolver, @Attribute('name') name: string, + private changeDetector: ChangeDetectorRef) { + this.name = name || PRIMARY_OUTLET; + parentContexts.onChildOutletCreated(this.name, this); } - ngOnDestroy(): void { this.parentOutletMap.removeOutlet(this._outletName); } + ngOnDestroy(): void { this.parentContexts.onChildOutletDestroyed(this.name); } + + 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.resolver || null); + } + } + } + } /** @deprecated since v4 **/ get locationInjector(): Injector { return this.location.injector; } @@ -102,65 +120,37 @@ export class RouterOutlet implements OnDestroy { } } - /** @deprecated since v4, use {@link #activateWith} */ - activate( - activatedRoute: ActivatedRoute, resolver: ComponentFactoryResolver, injector: Injector, - providers: ResolvedReflectiveProvider[], outletMap: RouterOutletMap): void { + activateWith(activatedRoute: ActivatedRoute, resolver: ComponentFactoryResolver|null) { if (this.isActivated) { throw new Error('Cannot activate an already activated outlet'); } - - this.outletMap = outletMap; this._activatedRoute = activatedRoute; - - const snapshot = activatedRoute._futureSnapshot; - const component: any = snapshot._routeConfig !.component; - const factory = resolver.resolveComponentFactory(component) !; - - const inj = ReflectiveInjector.fromResolvedProviders(providers, injector); - - this.activated = this.location.createComponent(factory, this.location.length, inj, []); - this.activated.changeDetectorRef.detectChanges(); - - this.activateEvents.emit(this.activated.instance); - } - - activateWith( - activatedRoute: ActivatedRoute, resolver: ComponentFactoryResolver|null, - outletMap: RouterOutletMap) { - if (this.isActivated) { - throw new Error('Cannot activate an already activated outlet'); - } - - this.outletMap = outletMap; - this._activatedRoute = activatedRoute; - const snapshot = activatedRoute._futureSnapshot; const component = snapshot._routeConfig !.component; - resolver = resolver || this.resolver; const factory = resolver.resolveComponentFactory(component); - - const injector = new OutletInjector(activatedRoute, outletMap, this.location.injector); - + const childContexts = this.parentContexts.getOrCreateContext(this.name).children; + const injector = new OutletInjector(activatedRoute, childContexts, this.location.injector); this.activated = this.location.createComponent(factory, this.location.length, injector); - this.activated.changeDetectorRef.detectChanges(); - + // Calling `markForCheck` to make sure we will run the change detection when the + // `RouterOutlet` is inside a `ChangeDetectionStrategy.OnPush` component. + this.changeDetector.markForCheck(); this.activateEvents.emit(this.activated.instance); } } class OutletInjector implements Injector { constructor( - private route: ActivatedRoute, private map: RouterOutletMap, private parent: Injector) {} + private route: ActivatedRoute, private childContexts: ChildrenOutletContexts, + private parent: Injector) {} get(token: any, notFoundValue?: any): any { if (token === ActivatedRoute) { return this.route; } - if (token === RouterOutletMap) { - return this.map; + if (token === ChildrenOutletContexts) { + return this.childContexts; } return this.parent.get(token, notFoundValue); diff --git a/packages/router/src/index.ts b/packages/router/src/index.ts index 6e329cf2be..f4924f47f8 100644 --- a/packages/router/src/index.ts +++ b/packages/router/src/index.ts @@ -17,7 +17,7 @@ export {DetachedRouteHandle, RouteReuseStrategy} from './route_reuse_strategy'; export {NavigationExtras, Router} from './router'; export {ROUTES} from './router_config_loader'; export {ExtraOptions, ROUTER_CONFIGURATION, ROUTER_INITIALIZER, RouterModule, provideRoutes} from './router_module'; -export {RouterOutletMap} from './router_outlet_map'; +export {ChildrenOutletContexts, OutletContext} from './router_outlet_context'; export {NoPreloading, PreloadAllModules, PreloadingStrategy, RouterPreloader} from './router_preloader'; export {ActivatedRoute, ActivatedRouteSnapshot, RouterState, RouterStateSnapshot} from './router_state'; export {PRIMARY_OUTLET, ParamMap, Params, convertToParamMap} from './shared'; diff --git a/packages/router/src/route_reuse_strategy.ts b/packages/router/src/route_reuse_strategy.ts index 45da2d6164..6f142e045d 100644 --- a/packages/router/src/route_reuse_strategy.ts +++ b/packages/router/src/route_reuse_strategy.ts @@ -8,6 +8,7 @@ import {ComponentRef} from '@angular/core'; +import {OutletContext} from './router_outlet_context'; import {ActivatedRoute, ActivatedRouteSnapshot} from './router_state'; import {TreeNode} from './utils/tree'; @@ -23,6 +24,7 @@ export type DetachedRouteHandle = {}; /** @internal */ export type DetachedRouteHandleInternal = { + contexts: Map, componentRef: ComponentRef, route: TreeNode, }; @@ -36,7 +38,11 @@ export abstract class RouteReuseStrategy { /** Determines if this route (and its subtree) should be detached to be reused later */ abstract shouldDetach(route: ActivatedRouteSnapshot): boolean; - /** Stores the detached route */ + /** + * Stores the detached route. + * + * Storing a `null` value should erase the previously stored value. + */ abstract store(route: ActivatedRouteSnapshot, handle: DetachedRouteHandle|null): void; /** Determines if this route (and its subtree) should be reattached */ diff --git a/packages/router/src/router.ts b/packages/router/src/router.ts index 48b9f3f020..2a7936a140 100644 --- a/packages/router/src/router.ts +++ b/packages/router/src/router.ts @@ -25,14 +25,13 @@ import {applyRedirects} from './apply_redirects'; import {LoadedRouterConfig, QueryParamsHandling, ResolveData, Route, Routes, RunGuardsAndResolvers, validateConfig} from './config'; import {createRouterState} from './create_router_state'; import {createUrlTree} from './create_url_tree'; -import {RouterOutlet} from './directives/router_outlet'; import {Event, NavigationCancel, NavigationEnd, NavigationError, NavigationStart, RouteConfigLoadEnd, RouteConfigLoadStart, RoutesRecognized} from './events'; import {recognize} from './recognize'; import {DefaultRouteReuseStrategy, DetachedRouteHandleInternal, RouteReuseStrategy} from './route_reuse_strategy'; import {RouterConfigLoader} from './router_config_loader'; -import {RouterOutletMap} from './router_outlet_map'; +import {ChildrenOutletContexts, OutletContext} from './router_outlet_context'; import {ActivatedRoute, ActivatedRouteSnapshot, RouterState, RouterStateSnapshot, advanceActivatedRoute, createEmptyState, equalParamsAndUrlSegments, inheritedParamsDataResolve} from './router_state'; -import {PRIMARY_OUTLET, Params, isNavigationCancelingError} from './shared'; +import {Params, isNavigationCancelingError} from './shared'; import {DefaultUrlHandlingStrategy, UrlHandlingStrategy} from './url_handling_strategy'; import {UrlSerializer, UrlTree, containsTree, createEmptyUrlTree} from './url_tree'; import {andObservables, forEach, shallowEqual, waitForMap, wrapIntoObservable} from './utils/collection'; @@ -251,7 +250,7 @@ export class Router { // TODO: vsavkin make internal after the final is out. constructor( private rootComponentType: Type|null, private urlSerializer: UrlSerializer, - private outletMap: RouterOutletMap, private location: Location, injector: Injector, + private rootContexts: ChildrenOutletContexts, private location: Location, injector: Injector, loader: NgModuleFactoryLoader, compiler: Compiler, public config: Routes) { const onLoadStart = (r: Route) => this.triggerEvent(new RouteConfigLoadStart(r)); const onLoadEnd = (r: Route) => this.triggerEvent(new RouteConfigLoadEnd(r)); @@ -631,7 +630,7 @@ export class Router { const moduleInjector = this.ngModule.injector; preActivation = new PreActivation(snapshot, this.currentRouterState.snapshot, moduleInjector); - preActivation.traverse(this.outletMap); + preActivation.traverse(this.rootContexts); return {appliedUrl, snapshot}; }); @@ -702,7 +701,7 @@ export class Router { } new ActivateRoutes(this.routeReuseStrategy, state, storedState) - .activate(this.outletMap); + .activate(this.rootContexts); navigationIsSuccessful = true; }) @@ -767,10 +766,10 @@ export class PreActivation { private future: RouterStateSnapshot, private curr: RouterStateSnapshot, private moduleInjector: Injector) {} - traverse(parentOutletMap: RouterOutletMap): void { + traverse(parentContexts: ChildrenOutletContexts): void { const futureRoot = this.future._root; const currRoot = this.curr ? this.curr._root : null; - this.traverseChildRoutes(futureRoot, currRoot, parentOutletMap, [futureRoot.value]); + this.traverseChildRoutes(futureRoot, currRoot, parentContexts, [futureRoot.value]); } checkGuards(): Observable { @@ -793,34 +792,35 @@ export class PreActivation { private traverseChildRoutes( futureNode: TreeNode, currNode: TreeNode|null, - outletMap: RouterOutletMap|null, futurePath: ActivatedRouteSnapshot[]): void { + contexts: ChildrenOutletContexts|null, futurePath: ActivatedRouteSnapshot[]): void { const prevChildren = nodeChildrenAsMap(currNode); // Process the children of the future route futureNode.children.forEach(c => { - this.traverseRoutes(c, prevChildren[c.value.outlet], outletMap, futurePath.concat([c.value])); + this.traverseRoutes(c, prevChildren[c.value.outlet], contexts, futurePath.concat([c.value])); delete prevChildren[c.value.outlet]; }); // Process any children left from the current route (not active for the future route) forEach( prevChildren, (v: TreeNode, k: string) => - this.deactivateRouteAndItsChildren(v, outletMap !._outlets[k])); + this.deactivateRouteAndItsChildren(v, contexts !.getContext(k))); } private traverseRoutes( futureNode: TreeNode, currNode: TreeNode, - parentOutletMap: RouterOutletMap|null, futurePath: ActivatedRouteSnapshot[]): void { + parentContexts: ChildrenOutletContexts|null, futurePath: ActivatedRouteSnapshot[]): void { const future = futureNode.value; const curr = currNode ? currNode.value : null; - const outlet = parentOutletMap ? parentOutletMap._outlets[futureNode.value.outlet] : null; + const context = parentContexts ? parentContexts.getContext(futureNode.value.outlet) : null; // reusing the node if (curr && future._routeConfig === curr._routeConfig) { if (this.shouldRunGuardsAndResolvers( curr, future, future._routeConfig !.runGuardsAndResolvers)) { this.canActivateChecks.push(new CanActivate(futurePath)); - this.canDeactivateChecks.push(new CanDeactivate(outlet !.component, curr)); + const outlet = context !.outlet !; + this.canDeactivateChecks.push(new CanDeactivate(outlet.component, curr)); } else { // we need to set the data future.data = curr.data; @@ -830,25 +830,25 @@ export class PreActivation { // If we have a component, we need to go through an outlet. if (future.component) { this.traverseChildRoutes( - futureNode, currNode, outlet ? outlet.outletMap : null, futurePath); + futureNode, currNode, context ? context.children : null, futurePath); // if we have a componentless route, we recurse but keep the same outlet map. } else { - this.traverseChildRoutes(futureNode, currNode, parentOutletMap, futurePath); + this.traverseChildRoutes(futureNode, currNode, parentContexts, futurePath); } } else { if (curr) { - this.deactivateRouteAndItsChildren(currNode, outlet); + this.deactivateRouteAndItsChildren(currNode, context); } this.canActivateChecks.push(new CanActivate(futurePath)); // If we have a component, we need to go through an outlet. if (future.component) { - this.traverseChildRoutes(futureNode, null, outlet ? outlet.outletMap : null, futurePath); + this.traverseChildRoutes(futureNode, null, context ? context.children : null, futurePath); // if we have a componentless route, we recurse but keep the same outlet map. } else { - this.traverseChildRoutes(futureNode, null, parentOutletMap, futurePath); + this.traverseChildRoutes(futureNode, null, parentContexts, futurePath); } } } @@ -871,24 +871,24 @@ export class PreActivation { } private deactivateRouteAndItsChildren( - route: TreeNode, outlet: RouterOutlet|null): void { - const prevChildren = nodeChildrenAsMap(route); + route: TreeNode, context: OutletContext|null): void { + const children = nodeChildrenAsMap(route); const r = route.value; - forEach(prevChildren, (v: TreeNode, k: string) => { + forEach(children, (node: TreeNode, childName: string) => { if (!r.component) { - this.deactivateRouteAndItsChildren(v, outlet); - } else if (outlet) { - this.deactivateRouteAndItsChildren(v, outlet.outletMap._outlets[k]); + this.deactivateRouteAndItsChildren(node, context); + } else if (context) { + this.deactivateRouteAndItsChildren(node, context.children.getContext(childName)); } else { - this.deactivateRouteAndItsChildren(v, null); + this.deactivateRouteAndItsChildren(node, null); } }); if (!r.component) { this.canDeactivateChecks.push(new CanDeactivate(null, r)); - } else if (outlet && outlet.isActivated) { - this.canDeactivateChecks.push(new CanDeactivate(outlet.component, r)); + } else if (context && context.outlet && context.outlet.isActivated) { + this.canDeactivateChecks.push(new CanDeactivate(context.outlet.component, r)); } else { this.canDeactivateChecks.push(new CanDeactivate(null, r)); } @@ -1002,103 +1002,109 @@ class ActivateRoutes { private routeReuseStrategy: RouteReuseStrategy, private futureState: RouterState, private currState: RouterState) {} - activate(parentOutletMap: RouterOutletMap): void { + activate(parentContexts: ChildrenOutletContexts): void { const futureRoot = this.futureState._root; const currRoot = this.currState ? this.currState._root : null; - this.deactivateChildRoutes(futureRoot, currRoot, parentOutletMap); + this.deactivateChildRoutes(futureRoot, currRoot, parentContexts); advanceActivatedRoute(this.futureState.root); - this.activateChildRoutes(futureRoot, currRoot, parentOutletMap); + this.activateChildRoutes(futureRoot, currRoot, parentContexts); } // De-activate the child route that are not re-used for the future state private deactivateChildRoutes( futureNode: TreeNode, currNode: TreeNode|null, - outletMap: RouterOutletMap): void { - const prevChildren: {[outlet: string]: any} = nodeChildrenAsMap(currNode); + contexts: ChildrenOutletContexts): void { + const children: {[outletName: string]: TreeNode} = nodeChildrenAsMap(currNode); // Recurse on the routes active in the future state to de-activate deeper children - futureNode.children.forEach(child => { - const childOutletName = child.value.outlet; - this.deactivateRoutes(child, prevChildren[childOutletName], outletMap); - delete prevChildren[childOutletName]; + futureNode.children.forEach(futureChild => { + const childOutletName = futureChild.value.outlet; + this.deactivateRoutes(futureChild, children[childOutletName], contexts); + delete children[childOutletName]; }); // De-activate the routes that will not be re-used - forEach(prevChildren, (v: any, k: string) => this.deactivateRouteAndItsChildren(v, outletMap)); + forEach(children, (v: TreeNode, childName: string) => { + this.deactivateRouteAndItsChildren(v, contexts); + }); } private deactivateRoutes( futureNode: TreeNode, currNode: TreeNode, - parentOutletMap: RouterOutletMap): void { + parentContext: ChildrenOutletContexts): void { const future = futureNode.value; const curr = currNode ? currNode.value : null; if (future === curr) { - // Reusing the node, check to see of the children need to be de-activated + // Reusing the node, check to see if the children need to be de-activated if (future.component) { // If we have a normal route, we need to go through an outlet. - const outlet = getOutlet(parentOutletMap, future); - this.deactivateChildRoutes(futureNode, currNode, outlet.outletMap); + const context = parentContext.getContext(future.outlet); + if (context) { + this.deactivateChildRoutes(futureNode, currNode, context.children); + } } else { // if we have a componentless route, we recurse but keep the same outlet map. - this.deactivateChildRoutes(futureNode, currNode, parentOutletMap); + this.deactivateChildRoutes(futureNode, currNode, parentContext); } } else { if (curr) { - this.deactivateRouteAndItsChildren(currNode, parentOutletMap); + // Deactivate the current route which will not be re-used + this.deactivateRouteAndItsChildren(currNode, parentContext); } } } private deactivateRouteAndItsChildren( - route: TreeNode, parentOutletMap: RouterOutletMap): void { + route: TreeNode, parentContexts: ChildrenOutletContexts): void { if (this.routeReuseStrategy.shouldDetach(route.value.snapshot)) { - this.detachAndStoreRouteSubtree(route, parentOutletMap); + this.detachAndStoreRouteSubtree(route, parentContexts); } else { - this.deactivateRouteAndOutlet(route, parentOutletMap); + this.deactivateRouteAndOutlet(route, parentContexts); } } private detachAndStoreRouteSubtree( - route: TreeNode, parentOutletMap: RouterOutletMap): void { - const outlet = getOutlet(parentOutletMap, route.value); - const componentRef = outlet.detach(); - this.routeReuseStrategy.store(route.value.snapshot, {componentRef, route}); + route: TreeNode, parentContexts: ChildrenOutletContexts): void { + const context = parentContexts.getContext(route.value.outlet); + if (context && context.outlet) { + const componentRef = context.outlet.detach(); + const contexts = context.children.onOutletDeactivated(); + this.routeReuseStrategy.store(route.value.snapshot, {componentRef, route, contexts}); + } } private deactivateRouteAndOutlet( - route: TreeNode, parentOutletMap: RouterOutletMap): void { - const prevChildren: {[outletName: string]: any} = nodeChildrenAsMap(route); - let outlet: RouterOutlet; + route: TreeNode, parentContexts: ChildrenOutletContexts): void { + const context = parentContexts.getContext(route.value.outlet); - // getOutlet throws when cannot find the right outlet, - // which can happen if an outlet was in an NgIf and was removed - try { - outlet = getOutlet(parentOutletMap, route.value); - } catch (e) { - return; + if (context) { + const children: {[outletName: string]: any} = nodeChildrenAsMap(route); + const contexts = route.value.component ? context.children : parentContexts; + + forEach(children, (v: any, k: string) => {this.deactivateRouteAndItsChildren(v, contexts)}); + + if (context.outlet) { + // Destroy the component + context.outlet.deactivate(); + // Destroy the contexts for all the outlets that were in the component + context.children.onOutletDeactivated(); + } } - - const outletMap = route.value.component ? outlet.outletMap : parentOutletMap; - - forEach( - prevChildren, (v: any, k: string) => { this.deactivateRouteAndItsChildren(v, outletMap); }); - - outlet.deactivate(); } private activateChildRoutes( futureNode: TreeNode, currNode: TreeNode|null, - outletMap: RouterOutletMap): void { - const prevChildren: {[outlet: string]: any} = nodeChildrenAsMap(currNode); + contexts: ChildrenOutletContexts): void { + const children: {[outlet: string]: any} = nodeChildrenAsMap(currNode); futureNode.children.forEach( - c => { this.activateRoutes(c, prevChildren[c.value.outlet], outletMap); }); + c => { this.activateRoutes(c, children[c.value.outlet], contexts); }); } private activateRoutes( futureNode: TreeNode, currNode: TreeNode, - parentOutletMap: RouterOutletMap): void { + parentContexts: ChildrenOutletContexts): void { const future = futureNode.value; const curr = currNode ? currNode.value : null; @@ -1108,42 +1114,50 @@ class ActivateRoutes { if (future === curr) { if (future.component) { // If we have a normal route, we need to go through an outlet. - const outlet = getOutlet(parentOutletMap, future); - this.activateChildRoutes(futureNode, currNode, outlet.outletMap); + const context = parentContexts.getOrCreateContext(future.outlet); + this.activateChildRoutes(futureNode, currNode, context.children); } else { // if we have a componentless route, we recurse but keep the same outlet map. - this.activateChildRoutes(futureNode, currNode, parentOutletMap); + this.activateChildRoutes(futureNode, currNode, parentContexts); } } else { if (future.component) { // if we have a normal route, we need to place the component into the outlet and recurse. - const outlet = getOutlet(parentOutletMap, futureNode.value); + const context = parentContexts.getOrCreateContext(future.outlet); if (this.routeReuseStrategy.shouldAttach(future.snapshot)) { const stored = (this.routeReuseStrategy.retrieve(future.snapshot)); this.routeReuseStrategy.store(future.snapshot, null); - outlet.attach(stored.componentRef, stored.route.value); + context.children.onOutletReAttached(stored.contexts); + context.attachRef = stored.componentRef; + context.route = stored.route.value; + if (context.outlet) { + // Attach right away when the outlet has already been instantiated + // Otherwise attach from `RouterOutlet.ngOnInit` when it is instantiated + context.outlet.attach(stored.componentRef, stored.route.value); + } advanceActivatedRouteNodeAndItsChildren(stored.route); } else { - const outletMap = new RouterOutletMap(); - this.placeComponentIntoOutlet(outletMap, future, outlet); - this.activateChildRoutes(futureNode, null, outletMap); + const config = parentLoadedConfig(future.snapshot); + const cmpFactoryResolver = config ? config.module.componentFactoryResolver : null; + + context.route = future; + context.resolver = cmpFactoryResolver; + if (context.outlet) { + // Activate the outlet when it has already been instantiated + // Otherwise it will get activated from its `ngOnInit` when instantiated + context.outlet.activateWith(future, cmpFactoryResolver); + } + + this.activateChildRoutes(futureNode, null, context.children); } } else { // if we have a componentless route, we recurse but keep the same outlet map. - this.activateChildRoutes(futureNode, null, parentOutletMap); + this.activateChildRoutes(futureNode, null, parentContexts); } } } - - private placeComponentIntoOutlet( - outletMap: RouterOutletMap, future: ActivatedRoute, outlet: RouterOutlet): void { - const config = parentLoadedConfig(future.snapshot); - const cmpFactoryResolver = config ? config.module.componentFactoryResolver : null; - - outlet.activateWith(future, cmpFactoryResolver, outletMap); - } } function advanceActivatedRouteNodeAndItsChildren(node: TreeNode): void { @@ -1183,19 +1197,6 @@ function nodeChildrenAsMap(node: TreeNode| null) { return map; } -function getOutlet(outletMap: RouterOutletMap, route: ActivatedRoute): RouterOutlet { - const outlet = outletMap._outlets[route.outlet]; - if (!outlet) { - const componentName = (route.component).name; - if (route.outlet === PRIMARY_OUTLET) { - throw new Error(`Cannot find primary outlet to load '${componentName}'`); - } else { - throw new Error(`Cannot find the outlet ${route.outlet} to load '${componentName}'`); - } - } - return outlet; -} - function validateCommands(commands: string[]): void { for (let i = 0; i < commands.length; i++) { const cmd = commands[i]; diff --git a/packages/router/src/router_module.ts b/packages/router/src/router_module.ts index a7c1c076ec..8ecec6aff7 100644 --- a/packages/router/src/router_module.ts +++ b/packages/router/src/router_module.ts @@ -19,7 +19,7 @@ import {RouterOutlet} from './directives/router_outlet'; import {RouteReuseStrategy} from './route_reuse_strategy'; import {ErrorHandler, Router} from './router'; import {ROUTES} from './router_config_loader'; -import {RouterOutletMap} from './router_outlet_map'; +import {ChildrenOutletContexts} from './router_outlet_context'; import {NoPreloading, PreloadAllModules, PreloadingStrategy, RouterPreloader} from './router_preloader'; import {ActivatedRoute} from './router_state'; import {UrlHandlingStrategy} from './url_handling_strategy'; @@ -51,12 +51,12 @@ export const ROUTER_PROVIDERS: Provider[] = [ provide: Router, useFactory: setupRouter, deps: [ - ApplicationRef, UrlSerializer, RouterOutletMap, Location, Injector, NgModuleFactoryLoader, - Compiler, ROUTES, ROUTER_CONFIGURATION, [UrlHandlingStrategy, new Optional()], - [RouteReuseStrategy, new Optional()] + ApplicationRef, UrlSerializer, ChildrenOutletContexts, Location, Injector, + NgModuleFactoryLoader, Compiler, ROUTES, ROUTER_CONFIGURATION, + [UrlHandlingStrategy, new Optional()], [RouteReuseStrategy, new Optional()] ] }, - RouterOutletMap, + ChildrenOutletContexts, {provide: ActivatedRoute, useFactory: rootRoute, deps: [Router]}, {provide: NgModuleFactoryLoader, useClass: SystemJsNgModuleLoader}, RouterPreloader, @@ -270,12 +270,12 @@ export interface ExtraOptions { } export function setupRouter( - ref: ApplicationRef, urlSerializer: UrlSerializer, outletMap: RouterOutletMap, + ref: ApplicationRef, urlSerializer: UrlSerializer, contexts: ChildrenOutletContexts, location: Location, injector: Injector, loader: NgModuleFactoryLoader, compiler: Compiler, config: Route[][], opts: ExtraOptions = {}, urlHandlingStrategy?: UrlHandlingStrategy, routeReuseStrategy?: RouteReuseStrategy) { const router = new Router( - null, urlSerializer, outletMap, location, injector, loader, compiler, flatten(config)); + null, urlSerializer, contexts, location, injector, loader, compiler, flatten(config)); if (urlHandlingStrategy) { router.urlHandlingStrategy = urlHandlingStrategy; diff --git a/packages/router/src/router_outlet_context.ts b/packages/router/src/router_outlet_context.ts new file mode 100644 index 0000000000..7ceb1f7abb --- /dev/null +++ b/packages/router/src/router_outlet_context.ts @@ -0,0 +1,80 @@ +/** + * @license + * Copyright Google Inc. 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 {ComponentFactoryResolver, ComponentRef} from '@angular/core'; + +import {RouterOutlet} from './directives/router_outlet'; +import {ActivatedRoute} from './router_state'; + + +/** + * Store contextual information about a {@link RouterOutlet} + * + * @stable + */ +export class OutletContext { + outlet: RouterOutlet|null = null; + route: ActivatedRoute|null = null; + resolver: ComponentFactoryResolver|null = null; + children = new ChildrenOutletContexts(); + attachRef: ComponentRef|null = null; +} + +/** + * Store contextual information about the children (= nested) {@link RouterOutlet} + * + * @stable + */ +export class ChildrenOutletContexts { + // contexts for child outlets, by name. + private contexts = new Map(); + + /** Called when a `RouterOutlet` directive is instantiated */ + onChildOutletCreated(childName: string, outlet: RouterOutlet): void { + const context = this.getOrCreateContext(childName); + context.outlet = outlet; + this.contexts.set(childName, context); + } + + /** + * Called when a `RouterOutlet` directive is destroyed. + * We need to keep the context as the outlet could be destroyed inside a NgIf and might be + * re-created later. + */ + onChildOutletDestroyed(childName: string): void { + const context = this.getContext(childName); + if (context) { + context.outlet = null; + } + } + + /** + * Called when the corresponding route is deactivated during navigation. + * Because the component get destroyed, all children outlet are destroyed. + */ + onOutletDeactivated(): Map { + const contexts = this.contexts; + this.contexts = new Map(); + return contexts; + } + + onOutletReAttached(contexts: Map) { this.contexts = contexts; } + + getOrCreateContext(childName: string): OutletContext { + let context = this.getContext(childName); + + if (!context) { + context = new OutletContext(); + this.contexts.set(childName, context); + } + + return context; + } + + getContext(childName: string): OutletContext|null { return this.contexts.get(childName) || null; } +} diff --git a/packages/router/src/router_outlet_map.ts b/packages/router/src/router_outlet_map.ts deleted file mode 100644 index f70bc0e814..0000000000 --- a/packages/router/src/router_outlet_map.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * @license - * Copyright Google Inc. 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 {RouterOutlet} from './directives/router_outlet'; - -/** - * @whatItDoes Contains all the router outlets created in a component. - * - * @stable - */ -export class RouterOutletMap { - /** @internal */ - _outlets: {[name: string]: RouterOutlet} = {}; - - /** - * Adds an outlet to this map. - */ - registerOutlet(name: string, outlet: RouterOutlet): void { this._outlets[name] = outlet; } - - /** - * Removes an outlet from this map. - */ - removeOutlet(name: string): void { this._outlets[name] = undefined as any; } -} diff --git a/packages/router/test/integration.spec.ts b/packages/router/test/integration.spec.ts index f130aefc5c..bfbf072606 100644 --- a/packages/router/test/integration.spec.ts +++ b/packages/router/test/integration.spec.ts @@ -7,7 +7,7 @@ */ import {CommonModule, Location} from '@angular/common'; -import {Component, Injectable, NgModule, NgModuleFactoryLoader, NgModuleRef} from '@angular/core'; +import {ChangeDetectionStrategy, Component, Injectable, NgModule, NgModuleFactoryLoader, NgModuleRef} from '@angular/core'; import {ComponentFixture, TestBed, fakeAsync, inject, tick} from '@angular/core/testing'; import {By} from '@angular/platform-browser/src/dom/debug/by'; import {expect} from '@angular/platform-browser/testing/src/matchers'; @@ -120,8 +120,10 @@ describe('Integration', () => { router.resetConfig([{ path: 'parent/:id', component: Parent, - children: - [{path: 'child1', component: Child1}, {path: 'child2', component: Child2}] + children: [ + {path: 'child1', component: Child1}, + {path: 'child2', component: Child2}, + ] }]); router.navigateByUrl('/parent/1/child1'); @@ -131,13 +133,18 @@ describe('Integration', () => { advance(fixture); expect(location.path()).toEqual('/parent/2/child2'); - expect(log).toEqual([{id: '1'}, 'child1 destroy', {id: '2'}, 'child2 constructor']); + expect(log).toEqual([ + {id: '1'}, + 'child1 destroy', + {id: '2'}, + 'child2 constructor', + ]); }))); }); it('should execute navigations serialy', - fakeAsync(inject([Router, Location], (router: Router, location: Location) => { + fakeAsync(inject([Router, Location], (router: Router) => { const fixture = createRoot(router, RootCmp); router.resetConfig([ @@ -173,6 +180,50 @@ describe('Integration', () => { }))); }); + it('Should work inside ChangeDetectionStrategy.OnPush components', fakeAsync(() => { + @Component({ + selector: 'root-cmp', + template: ``, + changeDetection: ChangeDetectionStrategy.OnPush, + }) + class OnPushOutlet { + } + + @Component({selector: 'need-cd', template: `{{'it works!'}}`}) + class NeedCdCmp { + } + + @NgModule({ + declarations: [OnPushOutlet, NeedCdCmp], + entryComponents: [OnPushOutlet, NeedCdCmp], + imports: [RouterModule], + }) + class TestModule { + } + + TestBed.configureTestingModule({imports: [TestModule]}); + + const router: Router = TestBed.get(Router); + const fixture = createRoot(router, RootCmp); + + router.resetConfig([{ + path: 'on', + component: OnPushOutlet, + children: [{ + path: 'push', + component: NeedCdCmp, + }], + }]); + + advance(fixture); + router.navigateByUrl('on'); + advance(fixture); + router.navigateByUrl('on/push'); + advance(fixture); + + expect(fixture.nativeElement).toHaveText('it works!'); + })); + it('should not error when no url left and no children are matching', fakeAsync(inject([Router, Location], (router: Router, location: Location) => { const fixture = createRoot(router, RootCmp); @@ -202,7 +253,7 @@ describe('Integration', () => { router.resetConfig([{ path: 'child', - component: LinkInNgIf, + component: OutletInNgIf, children: [{path: 'simple', component: SimpleCmp}] }]); @@ -212,10 +263,10 @@ describe('Integration', () => { expect(location.path()).toEqual('/child/simple'); }))); - it('should work when an outlet is in an ngIf (and is removed)', fakeAsync(() => { + it('should work when an outlet is added/removed', fakeAsync(() => { @Component({ selector: 'someRoot', - template: `
` + template: `[
]` }) class RootCmpWithLink { cond: boolean = true; @@ -223,26 +274,25 @@ describe('Integration', () => { TestBed.configureTestingModule({declarations: [RootCmpWithLink]}); const router: Router = TestBed.get(Router); - const location: Location = TestBed.get(Location); const fixture = createRoot(router, RootCmpWithLink); - router.resetConfig( - [{path: 'simple', component: SimpleCmp}, {path: 'blank', component: BlankCmp}]); + router.resetConfig([ + {path: 'simple', component: SimpleCmp}, + {path: 'blank', component: BlankCmp}, + ]); router.navigateByUrl('/simple'); advance(fixture); - expect(location.path()).toEqual('/simple'); + expect(fixture.nativeElement).toHaveText('[simple]'); - const instance = fixture.componentInstance; - instance.cond = false; + fixture.componentInstance.cond = false; advance(fixture); + expect(fixture.nativeElement).toHaveText('[]'); - let recordedError: any = null; - router.navigateByUrl('/blank') !.catch(e => recordedError = e); + fixture.componentInstance.cond = true; advance(fixture); - - expect(recordedError.message).toEqual('Cannot find primary outlet to load \'BlankCmp\''); + expect(fixture.nativeElement).toHaveText('[simple]'); })); it('should update location when navigating', fakeAsync(() => { @@ -3230,15 +3280,17 @@ describe('Integration', () => { shouldDetach(route: ActivatedRouteSnapshot): boolean { return false; } store(route: ActivatedRouteSnapshot, detachedTree: DetachedRouteHandle): void {} shouldAttach(route: ActivatedRouteSnapshot): boolean { return false; } - retrieve(route: ActivatedRouteSnapshot): DetachedRouteHandle { return null !; } + retrieve(route: ActivatedRouteSnapshot): DetachedRouteHandle|null { return null; } shouldReuseRoute(future: ActivatedRouteSnapshot, curr: ActivatedRouteSnapshot): boolean { if (future.routeConfig !== curr.routeConfig) { return false; - } else if (Object.keys(future.params).length !== Object.keys(curr.params).length) { - return false; - } else { - return Object.keys(future.params).every(k => future.params[k] === curr.params[k]); } + + if (Object.keys(future.params).length !== Object.keys(curr.params).length) { + return false; + } + + return Object.keys(future.params).every(k => future.params[k] === curr.params[k]); } } @@ -3249,8 +3301,12 @@ describe('Integration', () => { router.routeReuseStrategy = new AttachDetachReuseStrategy(); router.resetConfig([ - {path: 'a', component: TeamCmp, children: [{path: 'b', component: SimpleCmp}]}, - {path: 'c', component: UserCmp} + { + path: 'a', + component: TeamCmp, + children: [{path: 'b', component: SimpleCmp}], + }, + {path: 'c', component: UserCmp}, ]); router.navigateByUrl('/a/b'); @@ -3459,7 +3515,7 @@ class RelativeLinkInIfCmp { @Component( {selector: 'child', template: '
'}) -class LinkInNgIf { +class OutletInNgIf { alwaysTrue = true; } @@ -3538,7 +3594,7 @@ function createRoot(router: Router, type: any): ComponentFixture { QueryParamsAndFragmentCmp, StringLinkButtonCmp, WrapperCmp, - LinkInNgIf, + OutletInNgIf, ComponentRecordingRoutePathAndUrl, RouteCmp, RootCmp, @@ -3564,7 +3620,7 @@ function createRoot(router: Router, type: any): ComponentFixture { QueryParamsAndFragmentCmp, StringLinkButtonCmp, WrapperCmp, - LinkInNgIf, + OutletInNgIf, ComponentRecordingRoutePathAndUrl, RouteCmp, RootCmp, @@ -3592,7 +3648,7 @@ function createRoot(router: Router, type: any): ComponentFixture { QueryParamsAndFragmentCmp, StringLinkButtonCmp, WrapperCmp, - LinkInNgIf, + OutletInNgIf, ComponentRecordingRoutePathAndUrl, RouteCmp, RootCmp, diff --git a/packages/router/test/router.spec.ts b/packages/router/test/router.spec.ts index 77b8984ee7..df51f65c4c 100644 --- a/packages/router/test/router.spec.ts +++ b/packages/router/test/router.spec.ts @@ -11,7 +11,7 @@ import {TestBed, inject} from '@angular/core/testing'; import {ResolveData} from '../src/config'; import {PreActivation, Router} from '../src/router'; -import {RouterOutletMap} from '../src/router_outlet_map'; +import {ChildrenOutletContexts} from '../src/router_outlet_context'; import {ActivatedRouteSnapshot, RouterStateSnapshot, createEmptyStateSnapshot} from '../src/router_state'; import {DefaultUrlSerializer} from '../src/url_tree'; import {TreeNode} from '../src/utils/tree'; @@ -109,7 +109,7 @@ describe('Router', () => { function checkResolveData( future: RouterStateSnapshot, curr: RouterStateSnapshot, injector: any, check: any): void { const p = new PreActivation(future, curr, injector); - p.traverse(new RouterOutletMap()); + p.traverse(new ChildrenOutletContexts()); p.resolveData().subscribe(check, (e) => { throw e; }); } diff --git a/packages/router/testing/src/router_testing_module.ts b/packages/router/testing/src/router_testing_module.ts index 11fb8bb5fa..650502d11c 100644 --- a/packages/router/testing/src/router_testing_module.ts +++ b/packages/router/testing/src/router_testing_module.ts @@ -9,7 +9,7 @@ import {Location, LocationStrategy} from '@angular/common'; import {MockLocationStrategy, SpyLocation} from '@angular/common/testing'; import {Compiler, Injectable, Injector, ModuleWithProviders, NgModule, NgModuleFactory, NgModuleFactoryLoader, Optional} from '@angular/core'; -import {NoPreloading, PreloadingStrategy, ROUTES, Route, Router, RouterModule, RouterOutletMap, Routes, UrlHandlingStrategy, UrlSerializer, provideRoutes, ɵROUTER_PROVIDERS as ROUTER_PROVIDERS, ɵflatten as flatten} from '@angular/router'; +import {ChildrenOutletContexts, NoPreloading, PreloadingStrategy, ROUTES, Route, Router, RouterModule, Routes, UrlHandlingStrategy, UrlSerializer, provideRoutes, ɵROUTER_PROVIDERS as ROUTER_PROVIDERS, ɵflatten as flatten} from '@angular/router'; @@ -82,11 +82,11 @@ export class SpyNgModuleFactoryLoader implements NgModuleFactoryLoader { * @stable */ export function setupTestingRouter( - urlSerializer: UrlSerializer, outletMap: RouterOutletMap, location: Location, + urlSerializer: UrlSerializer, contexts: ChildrenOutletContexts, location: Location, loader: NgModuleFactoryLoader, compiler: Compiler, injector: Injector, routes: Route[][], urlHandlingStrategy?: UrlHandlingStrategy) { const router = new Router( - null !, urlSerializer, outletMap, location, injector, loader, compiler, flatten(routes)); + null !, urlSerializer, contexts, location, injector, loader, compiler, flatten(routes)); if (urlHandlingStrategy) { router.urlHandlingStrategy = urlHandlingStrategy; } @@ -127,8 +127,8 @@ export function setupTestingRouter( provide: Router, useFactory: setupTestingRouter, deps: [ - UrlSerializer, RouterOutletMap, Location, NgModuleFactoryLoader, Compiler, Injector, ROUTES, - [UrlHandlingStrategy, new Optional()] + UrlSerializer, ChildrenOutletContexts, Location, NgModuleFactoryLoader, Compiler, Injector, + ROUTES, [UrlHandlingStrategy, new Optional()] ] }, {provide: PreloadingStrategy, useExisting: NoPreloading}, provideRoutes([]) diff --git a/tools/public_api_guard/router/router.d.ts b/tools/public_api_guard/router/router.d.ts index 27008b53d3..e230887802 100644 --- a/tools/public_api_guard/router/router.d.ts +++ b/tools/public_api_guard/router/router.d.ts @@ -59,6 +59,16 @@ export interface CanLoad { canLoad(route: Route): Observable | Promise | boolean; } +/** @stable */ +export declare class ChildrenOutletContexts { + getContext(childName: string): OutletContext | null; + getOrCreateContext(childName: string): OutletContext; + onChildOutletCreated(childName: string, outlet: RouterOutlet): void; + onChildOutletDestroyed(childName: string): void; + onOutletDeactivated(): Map; + onOutletReAttached(contexts: Map): void; +} + /** @stable */ export declare function convertToParamMap(params: Params): ParamMap; @@ -157,6 +167,15 @@ export declare class NoPreloading implements PreloadingStrategy { preload(route: Route, fn: () => Observable): Observable; } +/** @stable */ +export declare class OutletContext { + attachRef: ComponentRef | null; + children: ChildrenOutletContexts; + outlet: RouterOutlet | null; + resolver: ComponentFactoryResolver | null; + route: ActivatedRoute | null; +} + /** @stable */ export interface ParamMap { readonly keys: string[]; @@ -239,7 +258,7 @@ export declare class Router { readonly routerState: RouterState; readonly url: string; urlHandlingStrategy: UrlHandlingStrategy; - constructor(rootComponentType: Type | null, urlSerializer: UrlSerializer, outletMap: RouterOutletMap, location: Location, injector: Injector, loader: NgModuleFactoryLoader, compiler: Compiler, config: Routes); + constructor(rootComponentType: Type | null, urlSerializer: UrlSerializer, rootContexts: ChildrenOutletContexts, location: Location, injector: Injector, loader: NgModuleFactoryLoader, compiler: Compiler, config: Routes); createUrlTree(commands: any[], {relativeTo, queryParams, fragment, preserveQueryParams, queryParamsHandling, preserveFragment}?: NavigationExtras): UrlTree; dispose(): void; initialNavigation(): void; @@ -329,7 +348,7 @@ export declare class RouterModule { } /** @stable */ -export declare class RouterOutlet implements OnDestroy { +export declare class RouterOutlet implements OnDestroy, OnInit { activateEvents: EventEmitter; readonly activatedRoute: ActivatedRoute; readonly component: Object; @@ -337,20 +356,13 @@ export declare class RouterOutlet implements OnDestroy { readonly isActivated: boolean; /** @deprecated */ readonly locationFactoryResolver: ComponentFactoryResolver; /** @deprecated */ readonly locationInjector: Injector; - outletMap: RouterOutletMap; - constructor(parentOutletMap: RouterOutletMap, location: ViewContainerRef, resolver: ComponentFactoryResolver, name: string); - /** @deprecated */ activate(activatedRoute: ActivatedRoute, resolver: ComponentFactoryResolver, injector: Injector, providers: ResolvedReflectiveProvider[], outletMap: RouterOutletMap): void; - activateWith(activatedRoute: ActivatedRoute, resolver: ComponentFactoryResolver | null, outletMap: RouterOutletMap): void; + constructor(parentContexts: ChildrenOutletContexts, location: ViewContainerRef, resolver: ComponentFactoryResolver, name: string, changeDetector: ChangeDetectorRef); + activateWith(activatedRoute: ActivatedRoute, resolver: ComponentFactoryResolver | null): void; attach(ref: ComponentRef, activatedRoute: ActivatedRoute): void; deactivate(): void; detach(): ComponentRef; ngOnDestroy(): void; -} - -/** @stable */ -export declare class RouterOutletMap { - registerOutlet(name: string, outlet: RouterOutlet): void; - removeOutlet(name: string): void; + ngOnInit(): void; } /** @stable */