refactor(router): improve control flow of descendant route activation

This commit is contained in:
Brian Ford 2015-05-14 13:01:48 -07:00
parent 6b02cb9b44
commit c29ab86d85
5 changed files with 134 additions and 89 deletions

View File

@ -16,26 +16,32 @@ export class RouteParams {
export class Instruction {
component:any;
_children:StringMap<string, Instruction>;
router:any;
matchedUrl:string;
params:StringMap<string, string>;
_children:Map<string, Instruction>;
// the part of the URL captured by this instruction
capturedUrl:string;
// the part of the URL captured by this instruction and all children
accumulatedUrl:string;
params:Map<string, string>;
reuse:boolean;
cost:number;
constructor({params, component, children, matchedUrl, parentCost}:{params:StringMap, component:any, children:StringMap, matchedUrl:string, cost:number} = {}) {
this.reuse = false;
this.matchedUrl = matchedUrl;
this.capturedUrl = matchedUrl;
this.accumulatedUrl = matchedUrl;
this.cost = parentCost;
if (isPresent(children)) {
this._children = children;
var childUrl;
StringMapWrapper.forEach(this._children, (child, _) => {
childUrl = child.matchedUrl;
childUrl = child.accumulatedUrl;
this.cost += child.cost;
});
if (isPresent(childUrl)) {
this.matchedUrl += childUrl;
this.accumulatedUrl += childUrl;
}
} else {
this._children = StringMapWrapper.create();
@ -44,7 +50,11 @@ export class Instruction {
this.params = params;
}
getChildInstruction(outletName:string): Instruction {
hasChild(outletName:string):Instruction {
return StringMapWrapper.contains(this._children, outletName);
}
getChild(outletName:string):Instruction {
return StringMapWrapper.get(this._children, outletName);
}
@ -52,37 +62,23 @@ export class Instruction {
StringMapWrapper.forEach(this._children, fn);
}
mapChildrenAsync(fn):Promise {
return mapObjAsync(this._children, fn);
}
/**
* Does a synchronous, breadth-first traversal of the graph of instructions.
* Takes a function with signature:
* (parent:Instruction, child:Instruction) => {}
*/
traverseSync(fn:Function): void {
this.forEachChild((childInstruction, _) => fn(this, childInstruction));
this.forEachChild(fn);
this.forEachChild((childInstruction, _) => childInstruction.traverseSync(fn));
}
/**
* Does an asynchronous, breadth-first traversal of the graph of instructions.
* Takes a function with signature:
* (child:Instruction, parentOutletName:string) => {}
*/
traverseAsync(fn:Function):Promise {
return this.mapChildrenAsync(fn)
.then((_) => this.mapChildrenAsync((childInstruction, _) => childInstruction.traverseAsync(fn)));
}
/**
* Takes a currently active instruction and sets a reuse flag on this instruction
*/
reuseComponentsFrom(oldInstruction:Instruction): void {
this.forEachChild((childInstruction, outletName) => {
var oldInstructionChild = oldInstruction.getChildInstruction(outletName);
this.traverseSync((childInstruction, outletName) => {
var oldInstructionChild = oldInstruction.getChild(outletName);
if (shouldReuseComponent(childInstruction, oldInstructionChild)) {
childInstruction.reuse = true;
}
@ -104,5 +100,3 @@ function mapObj(obj:StringMap, fn: Function):List {
StringMapWrapper.forEach(obj, (value, key) => ListWrapper.push(result, fn(value, key)));
return result;
}
export var noopInstruction = new Instruction();

View File

@ -11,15 +11,6 @@ export class Pipeline {
constructor() {
this.steps = [
instruction => instruction.traverseSync((parentInstruction, childInstruction) => {
childInstruction.router = parentInstruction.router.childRouter(childInstruction.component);
}),
instruction => instruction.router.traverseOutlets((outlet, name) => {
return outlet.canDeactivate(instruction.getChildInstruction(name));
}),
instruction => instruction.router.traverseOutlets((outlet, name) => {
return outlet.canActivate(instruction.getChildInstruction(name));
}),
instruction => instruction.router.activateOutlets(instruction)
];
}

View File

@ -15,6 +15,15 @@ import {Location} from './location';
* You can see the state of the router by inspecting the read-only field `router.navigating`.
* This may be useful for showing a spinner, for instance.
*
* ## Concepts
* Routers and component instances have a 1:1 correspondence.
*
* The router holds reference to a number of "outlets." An outlet is a placeholder that the
* router dynamically fills in depending on the current URL.
*
* When the router navigates from a URL, it must first recognizes it and serialize it into an `Instruction`.
* The router uses the `RouteRegistry` to get an `Instruction`.
*
* @exportedAs angular2/router
*/
export class Router {
@ -28,19 +37,16 @@ export class Router {
_pipeline:Pipeline;
_registry:RouteRegistry;
_outlets:Map<any, RouterOutlet>;
_children:Map<any, Router>;
_outlets:Map<any, Outlet>;
_subject:EventEmitter;
_location:Location;
constructor(registry:RouteRegistry, pipeline:Pipeline, location:Location, parent:Router, hostComponent) {
constructor(registry:RouteRegistry, pipeline:Pipeline, parent:Router, hostComponent:any) {
this.hostComponent = hostComponent;
this.navigating = false;
this.parent = parent;
this.previousUrl = null;
this._outlets = MapWrapper.create();
this._children = MapWrapper.create();
this._location = location;
this._registry = registry;
this._pipeline = pipeline;
this._subject = new EventEmitter();
@ -51,25 +57,18 @@ export class Router {
/**
* Constructs a child router. You probably don't need to use this unless you're writing a reusable component.
*/
childRouter(outletName = 'default'): Router {
var router = MapWrapper.get(this._children, outletName);
if (isBlank(router)) {
router = new ChildRouter(this, outletName);
MapWrapper.set(this._children, outletName, router);
}
return router;
childRouter(hostComponent:any): Router {
return new ChildRouter(this, hostComponent);
}
/**
* Register an object to notify of route changes. You probably don't need to use this unless you're writing a reusable component.
*/
registerOutlet(outlet:RouterOutlet, name: string = 'default'):Promise {
registerOutlet(outlet:RouterOutlet, name: string = 'default'): Promise {
MapWrapper.set(this._outlets, name, outlet);
if (isPresent(this._currentInstruction)) {
var childInstruction = this._currentInstruction.getChildInstruction(name);
var childInstruction = this._currentInstruction.getChild(name);
return outlet.activate(childInstruction);
}
return PromiseWrapper.resolve(true);
@ -77,7 +76,7 @@ export class Router {
/**
* Update the routing configuration and trigger a navigation.
* Dynamically update the routing configuration and trigger a navigation.
*
* # Usage
*
@ -98,7 +97,6 @@ export class Router {
config(config:any): Promise {
if (config instanceof List) {
config.forEach((configObject) => {
// TODO: this is a hack
this._registry.config(this.hostComponent, configObject);
});
} else {
@ -110,6 +108,9 @@ export class Router {
/**
* Navigate to a URL. Returns a promise that resolves to the canonical URL for the route.
*
* If the given URL begins with a `/`, router will navigate absolutely.
* If the given URL does not begin with `/`, the router will navigate relative to this component.
*/
navigate(url:string):Promise {
if (this.navigating) {
@ -124,19 +125,16 @@ export class Router {
return PromiseWrapper.resolve(false);
}
if(isPresent(this._currentInstruction)) {
if (isPresent(this._currentInstruction)) {
matchedInstruction.reuseComponentsFrom(this._currentInstruction);
}
matchedInstruction.router = this;
this._startNavigating();
var result = this._pipeline.process(matchedInstruction)
var result = this.commit(matchedInstruction)
.then((_) => {
this._location.go(matchedInstruction.matchedUrl);
ObservableWrapper.callNext(this._subject, matchedInstruction.matchedUrl);
ObservableWrapper.callNext(this._subject, matchedInstruction.accumulatedUrl);
this._finishNavigating();
this._currentInstruction = matchedInstruction;
});
PromiseWrapper.catchError(result, (_) => this._finishNavigating());
@ -152,6 +150,7 @@ export class Router {
this.navigating = false;
}
/**
* Subscribe to URL updates from the router
*/
@ -160,25 +159,46 @@ export class Router {
}
activateOutlets(instruction:Instruction):Promise {
return this._queryOutlets((outlet, name) => {
var childInstruction = instruction.getChildInstruction(name);
if (childInstruction.reuse) {
return PromiseWrapper.resolve(true);
/**
*
*/
commit(instruction:Instruction):Promise {
this._currentInstruction = instruction;
// collect all outlets that do not have a corresponding child instruction
// and remove them from the internal map of child outlets
var toDeactivate = ListWrapper.create();
MapWrapper.forEach(this._outlets, (outlet, outletName) => {
if (!instruction.hasChild(outletName)) {
MapWrapper.delete(this._outlets, outletName);
ListWrapper.push(toDeactivate, outlet);
}
return outlet.activate(childInstruction);
})
.then((_) => instruction.mapChildrenAsync((instruction, _) => {
return instruction.router.activateOutlets(instruction);
}));
});
return PromiseWrapper.all(ListWrapper.map(toDeactivate, (outlet) => outlet.deactivate()))
.then((_) => this.activate(instruction));
}
traverseOutlets(fn):Promise {
return this._queryOutlets(fn)
.then((_) => mapObjAsync(this._children, (child, _) => child.traverseOutlets(fn)));
/**
* Recursively remove all components contained by this router's outlets.
* Calls deactivate hooks on all descendant components
*/
deactivate():Promise {
return this._eachOutletAsync((outlet) => outlet.deactivate);
}
_queryOutlets(fn):Promise {
/**
* Recursively activate.
* Calls the "activate" hook on descendant components.
*/
activate(instruction:Instruction):Promise {
return this._eachOutletAsync((outlet, name) => outlet.activate(instruction.getChild(name)));
}
_eachOutletAsync(fn):Promise {
return mapObjAsync(this._outlets, fn);
}
@ -212,17 +232,26 @@ export class Router {
}
export class RootRouter extends Router {
_location:Location;
constructor(registry:RouteRegistry, pipeline:Pipeline, location:Location, hostComponent:Type) {
super(registry, pipeline, location, null, hostComponent);
super(registry, pipeline, null, hostComponent);
this._location = location;
this._location.subscribe((change) => this.navigate(change['url']));
this._registry.configFromComponent(hostComponent);
this.navigate(location.path());
}
commit(instruction):Promise {
return super.commit(instruction).then((_) => {
this._location.go(instruction.accumulatedUrl);
});
}
}
class ChildRouter extends Router {
constructor(parent:Router, hostComponent) {
super(parent._registry, parent._pipeline, parent._location, parent, hostComponent);
super(parent._registry, parent._pipeline, parent, hostComponent);
this.parent = parent;
}
}

View File

@ -1,5 +1,5 @@
import {Promise, PromiseWrapper} from 'angular2/src/facade/async';
import {isBlank} from 'angular2/src/facade/lang';
import {isBlank, isPresent} from 'angular2/src/facade/lang';
import {Directive} from 'angular2/src/core/annotations_impl/annotations';
import {Attribute} from 'angular2/src/core/annotations_impl/di';
@ -9,43 +9,74 @@ import {Injector, bind} from 'angular2/di';
import * as routerMod from './router';
import {Instruction, RouteParams} from './instruction'
/**
* A router outlet is a placeholder that Angular dynamically fills based on the application's route.
*
* ## Use
*
* ```
* <router-outlet></router-outlet>
* ```
*
* Route outlets can also optionally have a name:
*
* ```
* <router-outlet name="side"></router-outlet>
* <router-outlet name="main"></router-outlet>
* ```
*
*/
@Directive({
selector: 'router-outlet'
})
export class RouterOutlet {
_compiler:Compiler;
_injector:Injector;
_router:routerMod.Router;
_parentRouter:routerMod.Router;
_childRouter:routerMod.Router;
_viewContainer:ViewContainerRef;
constructor(viewContainer:ViewContainerRef, compiler:Compiler, router:routerMod.Router, injector:Injector, @Attribute('name') nameAttr:String) {
if (isBlank(nameAttr)) {
nameAttr = 'default';
}
this._router = router;
this._parentRouter = router;
this._childRouter = null;
this._viewContainer = viewContainer;
this._compiler = compiler;
this._injector = injector;
this._router.registerOutlet(this, nameAttr);
this._parentRouter.registerOutlet(this, nameAttr);
}
/**
* Given an instruction, update the contents of this viewport.
*/
activate(instruction:Instruction): Promise {
// if we're able to reuse the component, we just have to pass along the
if (instruction.reuse && isPresent(this._childRouter)) {
return this._childRouter.commit(instruction);
}
return this._compiler.compileInHost(instruction.component).then((pv) => {
this._childRouter = this._parentRouter.childRouter(instruction.component);
var outletInjector = this._injector.resolveAndCreateChild([
bind(RouteParams).toValue(new RouteParams(instruction.params)),
bind(routerMod.Router).toValue(instruction.router)
bind(routerMod.Router).toValue(this._childRouter)
]);
this._viewContainer.clear();
this._viewContainer.create(pv, 0, null, outletInjector);
return this._childRouter.commit(instruction);
});
}
canActivate(instruction:Instruction): Promise<boolean> {
return PromiseWrapper.resolve(true);
deactivate():Promise {
return (isPresent(this._childRouter) ? this._childRouter.deactivate() : PromiseWrapper.resolve(true))
.then((_) => this._viewContainer.clear());
}
canDeactivate(instruction:Instruction): Promise<boolean> {
// TODO: how to get ahold of the component instance here?
return PromiseWrapper.resolve(true);
}
}

View File

@ -24,7 +24,7 @@ export function main() {
var instruction = registry.recognize('/test', rootHostComponent);
expect(instruction.getChildInstruction('default').component).toBe(DummyCompB);
expect(instruction.getChild('default').component).toBe(DummyCompB);
});
it('should prefer static segments to dynamic', () => {
@ -33,7 +33,7 @@ export function main() {
var instruction = registry.recognize('/home', rootHostComponent);
expect(instruction.getChildInstruction('default').component).toBe(DummyCompA);
expect(instruction.getChild('default').component).toBe(DummyCompA);
});
it('should prefer dynamic segments to star', () => {
@ -42,7 +42,7 @@ export function main() {
var instruction = registry.recognize('/home', rootHostComponent);
expect(instruction.getChildInstruction('default').component).toBe(DummyCompA);
expect(instruction.getChild('default').component).toBe(DummyCompA);
});
it('should match the full URL recursively', () => {
@ -50,8 +50,8 @@ export function main() {
var instruction = registry.recognize('/first/second', rootHostComponent);
var parentInstruction = instruction.getChildInstruction('default');
var childInstruction = parentInstruction.getChildInstruction('default');
var parentInstruction = instruction.getChild('default');
var childInstruction = parentInstruction.getChild('default');
expect(parentInstruction.component).toBe(DummyParentComp);
expect(childInstruction.component).toBe(DummyCompB);