feat(router): lifecycle hooks

Closes #2640
This commit is contained in:
Brian Ford 2015-07-07 15:44:29 -07:00
parent f5f85bb528
commit a9a552c112
12 changed files with 823 additions and 106 deletions

View File

@ -24,7 +24,6 @@ export class Instruction {
// "capturedUrl" is the part of the URL captured by this instruction // "capturedUrl" is the part of the URL captured by this instruction
// "accumulatedUrl" is the part of the URL captured by this instruction and all children // "accumulatedUrl" is the part of the URL captured by this instruction and all children
accumulatedUrl: string; accumulatedUrl: string;
reuse: boolean = false; reuse: boolean = false;
specificity: number; specificity: number;
@ -50,23 +49,4 @@ export class Instruction {
} }
return this._params; return this._params;
} }
hasChild(): boolean { return isPresent(this.child); }
/**
* Takes a currently active instruction and sets a reuse flag on each of this instruction's
* children
*/
reuseComponentsFrom(oldInstruction: Instruction): void {
var nextInstruction = this;
while (nextInstruction.reuse = shouldReuseComponent(nextInstruction, oldInstruction) &&
isPresent(oldInstruction = oldInstruction.child) &&
isPresent(nextInstruction = nextInstruction.child))
;
}
}
function shouldReuseComponent(instr1: Instruction, instr2: Instruction): boolean {
return instr1.component == instr2.component &&
StringMapWrapper.equals(instr1.params(), instr2.params());
} }

View File

@ -0,0 +1,43 @@
import {Instruction} from './instruction';
import {global} from 'angular2/src/facade/lang';
// This is here only so that after TS transpilation the file is not empty.
// TODO(rado): find a better way to fix this, or remove if likely culprit
// https://github.com/systemjs/systemjs/issues/487 gets closed.
var __ignore_me = global;
/**
* Defines route lifecycle method [onActivate]
*/
export interface OnActivate {
onActivate(nextInstruction: Instruction, prevInstruction: Instruction): any;
}
/**
* Defines route lifecycle method [onReuse]
*/
export interface OnReuse {
onReuse(nextInstruction: Instruction, prevInstruction: Instruction): any;
}
/**
* Defines route lifecycle method [onDeactivate]
*/
export interface OnDeactivate {
onDeactivate(nextInstruction: Instruction, prevInstruction: Instruction): any;
}
/**
* Defines route lifecycle method [canReuse]
*/
export interface CanReuse {
canReuse(nextInstruction: Instruction, prevInstruction: Instruction): any;
}
/**
* Defines route lifecycle method [canDeactivate]
*/
export interface CanDeactivate {
canDeactivate(nextInstruction: Instruction, prevInstruction: Instruction): any;
}

View File

@ -0,0 +1,8 @@
/**
* This indirection is needed for TS compilation path.
* See comment in lifecycle_annotations.ts.
*/
library angular2.router.lifecycle_annotations;
export "./lifecycle_annotations_impl.dart";

View File

@ -0,0 +1,17 @@
/**
* This indirection is needed to free up Component, etc symbols in the public API
* to be used by the decorator versions of these annotations.
*/
import {makeDecorator} from 'angular2/src/util/decorators';
import {CanActivate as CanActivateAnnotation} from './lifecycle_annotations_impl';
export {
canReuse,
canDeactivate,
onActivate,
onReuse,
onDeactivate
} from './lifecycle_annotations_impl';
export var CanActivate = makeDecorator(CanActivateAnnotation);

View File

@ -0,0 +1,18 @@
import {CONST, CONST_EXPR} from 'angular2/src/facade/lang';
@CONST()
export class RouteLifecycleHook {
constructor(public name: string) {}
}
@CONST()
export class CanActivate {
constructor(public fn: Function) {}
}
export const canReuse: RouteLifecycleHook = CONST_EXPR(new RouteLifecycleHook("canReuse"));
export const canDeactivate: RouteLifecycleHook =
CONST_EXPR(new RouteLifecycleHook("canDeactivate"));
export const onActivate: RouteLifecycleHook = CONST_EXPR(new RouteLifecycleHook("onActivate"));
export const onReuse: RouteLifecycleHook = CONST_EXPR(new RouteLifecycleHook("onReuse"));
export const onDeactivate: RouteLifecycleHook = CONST_EXPR(new RouteLifecycleHook("onDeactivate"));

View File

@ -0,0 +1,38 @@
library angular.router.route_lifecycle_reflector;
import 'package:angular2/src/router/lifecycle_annotations_impl.dart';
import 'package:angular2/src/router/interfaces.dart';
import 'package:angular2/src/reflection/reflection.dart';
bool hasLifecycleHook(RouteLifecycleHook e, type) {
if (type is! Type) return false;
final List interfaces = reflector.interfaces(type);
var interface;
if (e == onActivate) {
interface = OnActivate;
} else if (e == onDeactivate) {
interface = OnDeactivate;
} else if (e == onReuse) {
interface = OnReuse;
} else if (e == canDeactivate) {
interface = CanDeactivate;
} else if (e == canReuse) {
interface = CanReuse;
}
return interfaces.contains(interface);
}
Function getCanActivateHook(type) {
final List annotations = reflector.annotations(type);
for (var annotation in annotations) {
if (annotation is CanActivate) {
return annotation.fn;
}
}
return null;
}

View File

@ -0,0 +1,20 @@
import {Type, isPresent} from 'angular2/src/facade/lang';
import {RouteLifecycleHook, CanActivate} from './lifecycle_annotations_impl';
import {reflector} from 'angular2/src/reflection/reflection';
export function hasLifecycleHook(e: RouteLifecycleHook, type): boolean {
if (!(type instanceof Type)) return false;
return e.name in(<any>type).prototype;
}
export function getCanActivateHook(type): Function {
var annotations = reflector.annotations(type);
for (let i = 0; i < annotations.length; i += 1) {
let annotation = annotations[i];
if (annotation instanceof CanActivate) {
return annotation.fn;
}
}
return null;
}

View File

@ -126,7 +126,11 @@ export class RouteRegistry {
this.configFromComponent(componentType); this.configFromComponent(componentType);
if (partialMatch.unmatchedUrl.length == 0) { if (partialMatch.unmatchedUrl.length == 0) {
return new Instruction(componentType, partialMatch.matchedUrl, recognizer); if (recognizer.terminal) {
return new Instruction(componentType, partialMatch.matchedUrl, recognizer);
} else {
return null;
}
} }
return this.recognize(partialMatch.unmatchedUrl, componentType) return this.recognize(partialMatch.unmatchedUrl, componentType)

View File

@ -15,6 +15,7 @@ import {Pipeline} from './pipeline';
import {Instruction} from './instruction'; import {Instruction} from './instruction';
import {RouterOutlet} from './router_outlet'; import {RouterOutlet} from './router_outlet';
import {Location} from './location'; import {Location} from './location';
import {getCanActivateHook} from './route_lifecycle_reflector';
let _resolveToTrue = PromiseWrapper.resolve(true); let _resolveToTrue = PromiseWrapper.resolve(true);
let _resolveToFalse = PromiseWrapper.resolve(false); let _resolveToFalse = PromiseWrapper.resolve(false);
@ -39,7 +40,6 @@ let _resolveToFalse = PromiseWrapper.resolve(false);
export class Router { export class Router {
navigating: boolean = false; navigating: boolean = false;
lastNavigationAttempt: string; lastNavigationAttempt: string;
previousUrl: string = null;
private _currentInstruction: Instruction = null; private _currentInstruction: Instruction = null;
private _currentNavigation: Promise<any> = _resolveToTrue; private _currentNavigation: Promise<any> = _resolveToTrue;
@ -67,7 +67,7 @@ export class Router {
// TODO: sibling routes // TODO: sibling routes
this._outlet = outlet; this._outlet = outlet;
if (isPresent(this._currentInstruction)) { if (isPresent(this._currentInstruction)) {
return outlet.activate(this._currentInstruction); return outlet.commit(this._currentInstruction);
} }
return _resolveToTrue; return _resolveToTrue;
} }
@ -109,35 +109,94 @@ export class Router {
* If the given URL does not begin with `/`, the router will navigate relative to this component. * If the given URL does not begin with `/`, the router will navigate relative to this component.
*/ */
navigate(url: string): Promise<any> { navigate(url: string): Promise<any> {
if (this.navigating) { return this._currentNavigation = this._currentNavigation.then((_) => {
return this._currentNavigation; this.lastNavigationAttempt = url;
}
this.lastNavigationAttempt = url;
return this._currentNavigation = this.recognize(url).then((matchedInstruction) => {
if (isBlank(matchedInstruction)) {
return _resolveToFalse;
}
if (isPresent(this._currentInstruction)) {
matchedInstruction.reuseComponentsFrom(this._currentInstruction);
}
this._startNavigating(); this._startNavigating();
return this._afterPromiseFinishNavigating(this.recognize(url).then((matchedInstruction) => {
var result = if (isBlank(matchedInstruction)) {
this.commit(matchedInstruction) return false;
.then((_) => { }
this._finishNavigating(); return this._reuse(matchedInstruction)
ObservableWrapper.callNext(this._subject, matchedInstruction.accumulatedUrl); .then((_) => this._canActivate(matchedInstruction))
}); .then((result) => {
if (!result) {
return PromiseWrapper.catchError(result, (err) => { return false;
this._finishNavigating(); }
throw err; return this._canDeactivate(matchedInstruction)
}); .then((result) => {
if (result) {
return this.commit(matchedInstruction)
.then((_) => {
this._emitNavigationFinish(matchedInstruction.accumulatedUrl);
return true;
});
}
});
});
}));
}); });
} }
private _emitNavigationFinish(url): void { ObservableWrapper.callNext(this._subject, url); }
private _afterPromiseFinishNavigating(promise: Promise<any>): Promise<any> {
return PromiseWrapper.catchError(promise.then((_) => this._finishNavigating()), (err) => {
this._finishNavigating();
throw err;
});
}
_reuse(instruction): Promise<any> {
if (isBlank(this._outlet)) {
return _resolveToFalse;
}
return this._outlet.canReuse(instruction)
.then((result) => {
instruction.reuse = result;
if (isPresent(this._outlet.childRouter) && isPresent(instruction.child)) {
return this._outlet.childRouter._reuse(instruction.child);
}
});
}
private _canActivate(instruction: Instruction): Promise<boolean> {
return canActivateOne(instruction, this._currentInstruction);
}
private _canDeactivate(instruction: Instruction): Promise<boolean> {
if (isBlank(this._outlet)) {
return _resolveToTrue;
}
var next: Promise<boolean>;
if (isPresent(instruction) && instruction.reuse) {
next = _resolveToTrue;
} else {
next = this._outlet.canDeactivate(instruction);
}
return next.then((result) => {
if (result == false) {
return false;
}
if (isPresent(this._outlet.childRouter)) {
return this._outlet.childRouter._canDeactivate(isPresent(instruction) ? instruction.child :
null);
}
return true;
});
}
/**
* Updates this router and all descendant routers according to the given instruction
*/
commit(instruction: Instruction): Promise<any> {
this._currentInstruction = instruction;
if (isPresent(this._outlet)) {
return this._outlet.commit(instruction);
}
return _resolveToTrue;
}
_startNavigating(): void { this.navigating = true; } _startNavigating(): void { this.navigating = true; }
_finishNavigating(): void { this.navigating = false; } _finishNavigating(): void { this.navigating = false; }
@ -149,24 +208,12 @@ export class Router {
subscribe(onNext): void { ObservableWrapper.subscribe(this._subject, onNext); } subscribe(onNext): void { ObservableWrapper.subscribe(this._subject, onNext); }
/**
* Updates this router and all descendant routers according to the given instruction
*/
commit(instruction: Instruction): Promise<any> {
this._currentInstruction = instruction;
if (isPresent(this._outlet)) {
return this._outlet.activate(instruction);
}
return _resolveToTrue;
}
/** /**
* Removes the contents of this router's outlet and all descendant outlets * Removes the contents of this router's outlet and all descendant outlets
*/ */
deactivate(): Promise<any> { deactivate(instruction: Instruction): Promise<any> {
if (isPresent(this._outlet)) { if (isPresent(this._outlet)) {
return this._outlet.deactivate(); return this._outlet.deactivate(instruction);
} }
return _resolveToTrue; return _resolveToTrue;
} }
@ -185,11 +232,10 @@ export class Router {
* router has yet to successfully navigate. * router has yet to successfully navigate.
*/ */
renavigate(): Promise<any> { renavigate(): Promise<any> {
var destination = isBlank(this.previousUrl) ? this.lastNavigationAttempt : this.previousUrl; if (isBlank(this.lastNavigationAttempt)) {
if (isBlank(destination)) {
return this._currentNavigation; return this._currentNavigation;
} }
return this.navigate(destination); return this.navigate(this.lastNavigationAttempt);
} }
@ -288,3 +334,24 @@ function splitAndFlattenLinkParams(linkParams: List<any>): List<any> {
return accumulation; return accumulation;
}, []); }, []);
} }
function canActivateOne(nextInstruction, currentInstruction): Promise<boolean> {
var next = _resolveToTrue;
if (isPresent(nextInstruction.child)) {
next = canActivateOne(nextInstruction.child,
isPresent(currentInstruction) ? currentInstruction.child : null);
}
return next.then((res) => {
if (res == false) {
return false;
}
if (nextInstruction.reuse) {
return true;
}
var hook = getCanActivateHook(nextInstruction.component);
if (isPresent(hook)) {
return hook(nextInstruction, currentInstruction);
}
return true;
});
}

View File

@ -1,4 +1,5 @@
import {Promise, PromiseWrapper} from 'angular2/src/facade/async'; import {Promise, PromiseWrapper} from 'angular2/src/facade/async';
import {StringMapWrapper} from 'angular2/src/facade/collection';
import {isBlank, isPresent} from 'angular2/src/facade/lang'; import {isBlank, isPresent} from 'angular2/src/facade/lang';
import {Directive, Attribute} from 'angular2/src/core/annotations/decorators'; import {Directive, Attribute} from 'angular2/src/core/annotations/decorators';
@ -6,8 +7,9 @@ import {DynamicComponentLoader, ComponentRef, ElementRef} from 'angular2/core';
import {Injector, bind, Dependency, undefinedValue} from 'angular2/di'; import {Injector, bind, Dependency, undefinedValue} from 'angular2/di';
import * as routerMod from './router'; import * as routerMod from './router';
import {Instruction, RouteParams} from './instruction' import {Instruction, RouteParams} from './instruction';
import * as hookMod from './lifecycle_annotations';
import {hasLifecycleHook} from './route_lifecycle_reflector';
/** /**
* A router outlet is a placeholder that Angular dynamically fills based on the application's route. * A router outlet is a placeholder that Angular dynamically fills based on the application's route.
@ -18,11 +20,10 @@ import {Instruction, RouteParams} from './instruction'
* <router-outlet></router-outlet> * <router-outlet></router-outlet>
* ``` * ```
*/ */
@Directive({ @Directive({selector: 'router-outlet'})
selector: 'router-outlet'
})
export class RouterOutlet { export class RouterOutlet {
private _childRouter: routerMod.Router = null; childRouter: routerMod.Router = null;
private _componentRef: ComponentRef = null; private _componentRef: ComponentRef = null;
private _currentInstruction: Instruction = null; private _currentInstruction: Instruction = null;
@ -38,34 +39,100 @@ export class RouterOutlet {
/** /**
* Given an instruction, update the contents of this outlet. * Given an instruction, update the contents of this outlet.
*/ */
activate(instruction: Instruction): Promise<any> { commit(instruction: Instruction): Promise<any> {
// if we're able to reuse the component, we just have to pass along the instruction to the var next;
// component's router if (instruction.reuse) {
// so it can propagate changes to its children next = this._reuse(instruction);
if ((instruction == this._currentInstruction || instruction.reuse) && } else {
isPresent(this._childRouter)) { next = this.deactivate(instruction).then((_) => this._activate(instruction));
return this._childRouter.commit(instruction.child);
} }
return next.then((_) => this._commitChild(instruction));
}
private _commitChild(instruction: Instruction): Promise<any> {
if (isPresent(this.childRouter)) {
return this.childRouter.commit(instruction.child);
} else {
return PromiseWrapper.resolve(true);
}
}
private _activate(instruction: Instruction): Promise<any> {
var previousInstruction = this._currentInstruction;
this._currentInstruction = instruction; this._currentInstruction = instruction;
this._childRouter = this._parentRouter.childRouter(instruction.component); this.childRouter = this._parentRouter.childRouter(instruction.component);
var params = new RouteParams(instruction.params());
var bindings = Injector.resolve(
[bind(RouteParams).toValue(params), bind(routerMod.Router).toValue(this._childRouter)]);
return this.deactivate() var bindings = Injector.resolve([
.then((_) => this._loader.loadNextToLocation(instruction.component, this._elementRef, bind(RouteParams)
bindings)) .toValue(new RouteParams(instruction.params())),
bind(routerMod.Router).toValue(this.childRouter)
]);
return this._loader.loadNextToLocation(instruction.component, this._elementRef, bindings)
.then((componentRef) => { .then((componentRef) => {
this._componentRef = componentRef; this._componentRef = componentRef;
return this._childRouter.commit(instruction.child); if (hasLifecycleHook(hookMod.onActivate, instruction.component)) {
return this._componentRef.instance.onActivate(instruction, previousInstruction);
}
}); });
} }
deactivate(): Promise<any> { /**
return (isPresent(this._childRouter) ? this._childRouter.deactivate() : * Called by Router during recognition phase
PromiseWrapper.resolve(true)) */
canDeactivate(nextInstruction: Instruction): Promise<boolean> {
if (isBlank(this._currentInstruction)) {
return PromiseWrapper.resolve(true);
}
if (hasLifecycleHook(hookMod.canDeactivate, this._currentInstruction.component)) {
return PromiseWrapper.resolve(
this._componentRef.instance.canDeactivate(nextInstruction, this._currentInstruction));
}
return PromiseWrapper.resolve(true);
}
/**
* Called by Router during recognition phase
*/
canReuse(nextInstruction: Instruction): Promise<boolean> {
var result;
if (isBlank(this._currentInstruction) ||
this._currentInstruction.component != nextInstruction.component) {
result = false;
} else if (hasLifecycleHook(hookMod.canReuse, this._currentInstruction.component)) {
result = this._componentRef.instance.canReuse(nextInstruction, this._currentInstruction);
} else {
result = nextInstruction == this._currentInstruction ||
StringMapWrapper.equals(nextInstruction.params(), this._currentInstruction.params());
}
return PromiseWrapper.resolve(result);
}
private _reuse(instruction): Promise<any> {
var previousInstruction = this._currentInstruction;
this._currentInstruction = instruction;
return PromiseWrapper.resolve(
hasLifecycleHook(hookMod.onReuse, this._currentInstruction.component) ?
this._componentRef.instance.onReuse(instruction, previousInstruction) :
true);
}
deactivate(nextInstruction: Instruction): Promise<any> {
return (isPresent(this.childRouter) ?
this.childRouter.deactivate(isPresent(nextInstruction) ? nextInstruction.child :
null) :
PromiseWrapper.resolve(true))
.then((_) => {
if (isPresent(this._componentRef) && isPresent(this._currentInstruction) &&
hasLifecycleHook(hookMod.onDeactivate, this._currentInstruction.component)) {
return this._componentRef.instance.onDeactivate(nextInstruction,
this._currentInstruction);
}
})
.then((_) => { .then((_) => {
if (isPresent(this._componentRef)) { if (isPresent(this._componentRef)) {
this._componentRef.dispose(); this._componentRef.dispose();
@ -73,9 +140,4 @@ export class RouterOutlet {
} }
}); });
} }
canDeactivate(instruction: Instruction): Promise<boolean> {
// TODO: how to get ahold of the component instance here?
return PromiseWrapper.resolve(true);
}
} }

View File

@ -18,7 +18,8 @@ import {
import {Injector, bind} from 'angular2/di'; import {Injector, bind} from 'angular2/di';
import {Component, View} from 'angular2/src/core/annotations/decorators'; import {Component, View} from 'angular2/src/core/annotations/decorators';
import * as annotations from 'angular2/src/core/annotations_impl/view'; import * as annotations from 'angular2/src/core/annotations_impl/view';
import {CONST, NumberWrapper} from 'angular2/src/facade/lang'; import {CONST, NumberWrapper, isPresent} from 'angular2/src/facade/lang';
import {Promise, PromiseWrapper, EventEmitter, ObservableWrapper} from 'angular2/src/facade/async';
import {RootRouter} from 'angular2/src/router/router'; import {RootRouter} from 'angular2/src/router/router';
import {Pipeline} from 'angular2/src/router/pipeline'; import {Pipeline} from 'angular2/src/router/pipeline';
@ -30,9 +31,18 @@ import {DOM} from 'angular2/src/dom/dom_adapter';
import {SpyLocation} from 'angular2/src/mock/location_mock'; import {SpyLocation} from 'angular2/src/mock/location_mock';
import {Location} from 'angular2/src/router/location'; import {Location} from 'angular2/src/router/location';
import {RouteRegistry} from 'angular2/src/router/route_registry'; import {RouteRegistry} from 'angular2/src/router/route_registry';
import {
OnActivate,
OnDeactivate,
OnReuse,
CanDeactivate,
CanReuse
} from 'angular2/src/router/interfaces';
import {CanActivate} from 'angular2/src/router/lifecycle_annotations';
import {Instruction} from 'angular2/src/router/instruction';
import {DirectiveResolver} from 'angular2/src/core/compiler/directive_resolver'; import {DirectiveResolver} from 'angular2/src/core/compiler/directive_resolver';
var teamCmpCount; var cmpInstanceCount, log, eventBus, completer;
export function main() { export function main() {
describe('Outlet Directive', () => { describe('Outlet Directive', () => {
@ -55,7 +65,9 @@ export function main() {
tcb = tcBuilder; tcb = tcBuilder;
rtr = router; rtr = router;
location = loc; location = loc;
teamCmpCount = 0; cmpInstanceCount = 0;
log = '';
eventBus = new EventEmitter();
})); }));
function compile(template: string = "<router-outlet></router-outlet>") { function compile(template: string = "<router-outlet></router-outlet>") {
@ -158,13 +170,13 @@ export function main() {
.then((_) => rtr.navigate('/team/angular/user/rado')) .then((_) => rtr.navigate('/team/angular/user/rado'))
.then((_) => { .then((_) => {
rootTC.detectChanges(); rootTC.detectChanges();
expect(teamCmpCount).toBe(1); expect(cmpInstanceCount).toBe(1);
expect(rootTC.nativeElement).toHaveText('team angular { hello rado }'); expect(rootTC.nativeElement).toHaveText('team angular { hello rado }');
}) })
.then((_) => rtr.navigate('/team/angular/user/victor')) .then((_) => rtr.navigate('/team/angular/user/victor'))
.then((_) => { .then((_) => {
rootTC.detectChanges(); rootTC.detectChanges();
expect(teamCmpCount).toBe(1); expect(cmpInstanceCount).toBe(1);
expect(rootTC.nativeElement).toHaveText('team angular { hello victor }'); expect(rootTC.nativeElement).toHaveText('team angular { hello victor }');
async.done(); async.done();
}); });
@ -228,6 +240,282 @@ export function main() {
}); });
})); }));
it('should call the onActivate hook', inject([AsyncTestCompleter], (async) => {
compile()
.then((_) => rtr.config({'path': '/...', 'component': LifecycleCmp}))
.then((_) => rtr.navigate('/on-activate'))
.then((_) => {
rootTC.detectChanges();
expect(rootTC.nativeElement).toHaveText('activate cmp');
expect(log).toEqual('activate: null -> /on-activate;');
async.done();
});
}));
it('should wait for a parent component\'s onActivate hook to resolve before calling its child\'s',
inject([AsyncTestCompleter], (async) => {
compile()
.then((_) => rtr.config({'path': '/...', 'component': LifecycleCmp}))
.then((_) => {
ObservableWrapper.subscribe(eventBus, (ev) => {
if (ev.startsWith('parent activate')) {
completer.resolve(true);
}
});
rtr.navigate('/parent-activate/child-activate')
.then((_) => {
rootTC.detectChanges();
expect(rootTC.nativeElement).toHaveText('parent {activate cmp}');
expect(log).toEqual(
'parent activate: null -> /parent-activate/child-activate;activate: null -> /child-activate;');
async.done();
});
});
}));
it('should call the onDeactivate hook', inject([AsyncTestCompleter], (async) => {
compile()
.then((_) => rtr.config({'path': '/...', 'component': LifecycleCmp}))
.then((_) => rtr.navigate('/on-deactivate'))
.then((_) => rtr.navigate('/a'))
.then((_) => {
rootTC.detectChanges();
expect(rootTC.nativeElement).toHaveText('A');
expect(log).toEqual('deactivate: /on-deactivate -> /a;');
async.done();
});
}));
it('should wait for a child component\'s onDeactivate hook to resolve before calling its parent\'s',
inject([AsyncTestCompleter], (async) => {
compile()
.then((_) => rtr.config({'path': '/...', 'component': LifecycleCmp}))
.then((_) => rtr.navigate('/parent-deactivate/child-deactivate'))
.then((_) => {
ObservableWrapper.subscribe(eventBus, (ev) => {
if (ev.startsWith('deactivate')) {
completer.resolve(true);
rootTC.detectChanges();
expect(rootTC.nativeElement).toHaveText('parent {deactivate cmp}');
}
});
rtr.navigate('/a').then((_) => {
rootTC.detectChanges();
expect(rootTC.nativeElement).toHaveText('A');
expect(log).toEqual(
'deactivate: /child-deactivate -> null;parent deactivate: /parent-deactivate/child-deactivate -> /a;');
async.done();
});
});
}));
it('should reuse a component when the canReuse hook returns false',
inject([AsyncTestCompleter], (async) => {
compile()
.then((_) => rtr.config({'path': '/...', 'component': LifecycleCmp}))
.then((_) => rtr.navigate('/on-reuse/1/a'))
.then((_) => {
rootTC.detectChanges();
expect(log).toEqual('');
expect(rootTC.nativeElement).toHaveText('reuse {A}');
expect(cmpInstanceCount).toBe(1);
})
.then((_) => rtr.navigate('/on-reuse/2/b'))
.then((_) => {
rootTC.detectChanges();
expect(log).toEqual('reuse: /on-reuse/1/a -> /on-reuse/2/b;');
expect(rootTC.nativeElement).toHaveText('reuse {B}');
expect(cmpInstanceCount).toBe(1);
async.done();
});
}));
it('should not reuse a component when the canReuse hook returns false',
inject([AsyncTestCompleter], (async) => {
compile()
.then((_) => rtr.config({'path': '/...', 'component': LifecycleCmp}))
.then((_) => rtr.navigate('/never-reuse/1/a'))
.then((_) => {
rootTC.detectChanges();
expect(log).toEqual('');
expect(rootTC.nativeElement).toHaveText('reuse {A}');
expect(cmpInstanceCount).toBe(1);
})
.then((_) => rtr.navigate('/never-reuse/2/b'))
.then((_) => {
rootTC.detectChanges();
expect(log).toEqual('');
expect(rootTC.nativeElement).toHaveText('reuse {B}');
expect(cmpInstanceCount).toBe(2);
async.done();
});
}));
it('should navigate when canActivate returns true', inject([AsyncTestCompleter], (async) => {
compile()
.then((_) => rtr.config({'path': '/...', 'component': LifecycleCmp}))
.then((_) => {
ObservableWrapper.subscribe(eventBus, (ev) => {
if (ev.startsWith('canActivate')) {
completer.resolve(true);
}
});
rtr.navigate('/can-activate/a')
.then((_) => {
rootTC.detectChanges();
expect(rootTC.nativeElement).toHaveText('canActivate {A}');
expect(log).toEqual('canActivate: null -> /can-activate/a;');
async.done();
});
});
}));
it('should not navigate when canActivate returns false',
inject([AsyncTestCompleter], (async) => {
compile()
.then((_) => rtr.config({'path': '/...', 'component': LifecycleCmp}))
.then((_) => {
ObservableWrapper.subscribe(eventBus, (ev) => {
if (ev.startsWith('canActivate')) {
completer.resolve(false);
}
});
rtr.navigate('/can-activate/a')
.then((_) => {
rootTC.detectChanges();
expect(rootTC.nativeElement).toHaveText('');
expect(log).toEqual('canActivate: null -> /can-activate/a;');
async.done();
});
});
}));
it('should navigate away when canDeactivate returns true',
inject([AsyncTestCompleter], (async) => {
compile()
.then((_) => rtr.config({'path': '/...', 'component': LifecycleCmp}))
.then((_) => rtr.navigate('/can-deactivate/a'))
.then((_) => {
rootTC.detectChanges();
expect(rootTC.nativeElement).toHaveText('canDeactivate {A}');
expect(log).toEqual('');
ObservableWrapper.subscribe(eventBus, (ev) => {
if (ev.startsWith('canDeactivate')) {
completer.resolve(true);
}
});
rtr.navigate('/a').then((_) => {
rootTC.detectChanges();
expect(rootTC.nativeElement).toHaveText('A');
expect(log).toEqual('canDeactivate: /can-deactivate/a -> /a;');
async.done();
});
});
}));
it('should not navigate away when canDeactivate returns false',
inject([AsyncTestCompleter], (async) => {
compile()
.then((_) => rtr.config({'path': '/...', 'component': LifecycleCmp}))
.then((_) => rtr.navigate('/can-deactivate/a'))
.then((_) => {
rootTC.detectChanges();
expect(rootTC.nativeElement).toHaveText('canDeactivate {A}');
expect(log).toEqual('');
ObservableWrapper.subscribe(eventBus, (ev) => {
if (ev.startsWith('canDeactivate')) {
completer.resolve(false);
}
});
rtr.navigate('/a').then((_) => {
rootTC.detectChanges();
expect(rootTC.nativeElement).toHaveText('canDeactivate {A}');
expect(log).toEqual('canDeactivate: /can-deactivate/a -> /a;');
async.done();
});
});
}));
it('should run activation and deactivation hooks in the correct order',
inject([AsyncTestCompleter], (async) => {
compile()
.then((_) => rtr.config({'path': '/...', 'component': LifecycleCmp}))
.then((_) => rtr.navigate('/activation-hooks/child'))
.then((_) => {
expect(log).toEqual('canActivate child: null -> /child;' +
'canActivate parent: null -> /activation-hooks/child;' +
'onActivate parent: null -> /activation-hooks/child;' +
'onActivate child: null -> /child;');
log = '';
return rtr.navigate('/a');
})
.then((_) => {
expect(log).toEqual('canDeactivate parent: /activation-hooks/child -> /a;' +
'canDeactivate child: /child -> null;' +
'onDeactivate child: /child -> null;' +
'onDeactivate parent: /activation-hooks/child -> /a;');
async.done();
});
}));
it('should only run reuse hooks when reusing', inject([AsyncTestCompleter], (async) => {
compile()
.then((_) => rtr.config({'path': '/...', 'component': LifecycleCmp}))
.then((_) => rtr.navigate('/reuse-hooks/1'))
.then((_) => {
expect(log).toEqual('canActivate: null -> /reuse-hooks/1;' +
'onActivate: null -> /reuse-hooks/1;');
ObservableWrapper.subscribe(eventBus, (ev) => {
if (ev.startsWith('canReuse')) {
completer.resolve(true);
}
});
log = '';
return rtr.navigate('/reuse-hooks/2');
})
.then((_) => {
expect(log).toEqual('canReuse: /reuse-hooks/1 -> /reuse-hooks/2;' +
'onReuse: /reuse-hooks/1 -> /reuse-hooks/2;');
async.done();
});
}));
it('should not run reuse hooks when not reusing', inject([AsyncTestCompleter], (async) => {
compile()
.then((_) => rtr.config({'path': '/...', 'component': LifecycleCmp}))
.then((_) => rtr.navigate('/reuse-hooks/1'))
.then((_) => {
expect(log).toEqual('canActivate: null -> /reuse-hooks/1;' +
'onActivate: null -> /reuse-hooks/1;');
ObservableWrapper.subscribe(eventBus, (ev) => {
if (ev.startsWith('canReuse')) {
completer.resolve(false);
}
});
log = '';
return rtr.navigate('/reuse-hooks/2');
})
.then((_) => {
expect(log).toEqual('canReuse: /reuse-hooks/1 -> /reuse-hooks/2;' +
'canActivate: /reuse-hooks/1 -> /reuse-hooks/2;' +
'canDeactivate: /reuse-hooks/1 -> /reuse-hooks/2;' +
'onDeactivate: /reuse-hooks/1 -> /reuse-hooks/2;' +
'onActivate: /reuse-hooks/1 -> /reuse-hooks/2;');
async.done();
});
}));
describe('when clicked', () => { describe('when clicked', () => {
var clickOnElement = function(view) { var clickOnElement = function(view) {
@ -352,7 +640,7 @@ class TeamCmp {
id: string; id: string;
constructor(params: RouteParams) { constructor(params: RouteParams) {
this.id = params.get('id'); this.id = params.get('id');
teamCmpCount += 1; cmpInstanceCount += 1;
} }
} }
@ -361,3 +649,175 @@ class TeamCmp {
class MyComp { class MyComp {
name; name;
} }
function logHook(name: string, next: Instruction, prev: Instruction) {
var message = name + ': ' + (isPresent(prev) ? prev.accumulatedUrl : 'null') + ' -> ' +
(isPresent(next) ? next.accumulatedUrl : 'null') + ';';
log += message;
ObservableWrapper.callNext(eventBus, message);
}
@Component({selector: 'activate-cmp'})
@View({template: 'activate cmp'})
class ActivateCmp implements OnActivate {
onActivate(next: Instruction, prev: Instruction) { logHook('activate', next, prev); }
}
@Component({selector: 'parent-activate-cmp'})
@View({template: `parent {<router-outlet></router-outlet>}`, directives: [RouterOutlet]})
@RouteConfig([{path: '/child-activate', component: ActivateCmp}])
class ParentActivateCmp implements OnActivate {
onActivate(next: Instruction, prev: Instruction): Promise<any> {
completer = PromiseWrapper.completer();
logHook('parent activate', next, prev);
return completer.promise;
}
}
@Component({selector: 'deactivate-cmp'})
@View({template: 'deactivate cmp'})
class DeactivateCmp implements OnDeactivate {
onDeactivate(next: Instruction, prev: Instruction) { logHook('deactivate', next, prev); }
}
@Component({selector: 'deactivate-cmp'})
@View({template: 'deactivate cmp'})
class WaitDeactivateCmp implements OnDeactivate {
onDeactivate(next: Instruction, prev: Instruction): Promise<any> {
completer = PromiseWrapper.completer();
logHook('deactivate', next, prev);
return completer.promise;
}
}
@Component({selector: 'parent-deactivate-cmp'})
@View({template: `parent {<router-outlet></router-outlet>}`, directives: [RouterOutlet]})
@RouteConfig([{path: '/child-deactivate', component: WaitDeactivateCmp}])
class ParentDeactivateCmp implements OnDeactivate {
onDeactivate(next: Instruction, prev: Instruction) { logHook('parent deactivate', next, prev); }
}
@Component({selector: 'reuse-cmp'})
@View({template: `reuse {<router-outlet></router-outlet>}`, directives: [RouterOutlet]})
@RouteConfig([{path: '/a', component: A}, {path: '/b', component: B}])
class ReuseCmp implements OnReuse, CanReuse {
constructor() { cmpInstanceCount += 1; }
canReuse(next: Instruction, prev: Instruction) { return true; }
onReuse(next: Instruction, prev: Instruction) { logHook('reuse', next, prev); }
}
@Component({selector: 'never-reuse-cmp'})
@View({template: `reuse {<router-outlet></router-outlet>}`, directives: [RouterOutlet]})
@RouteConfig([{path: '/a', component: A}, {path: '/b', component: B}])
class NeverReuseCmp implements OnReuse, CanReuse {
constructor() { cmpInstanceCount += 1; }
canReuse(next: Instruction, prev: Instruction) { return false; }
onReuse(next: Instruction, prev: Instruction) { logHook('reuse', next, prev); }
}
@Component({selector: 'can-activate-cmp'})
@View({template: `canActivate {<router-outlet></router-outlet>}`, directives: [RouterOutlet]})
@RouteConfig([{path: '/a', component: A}, {path: '/b', component: B}])
@CanActivate(CanActivateCmp.canActivate)
class CanActivateCmp {
static canActivate(next: Instruction, prev: Instruction) {
completer = PromiseWrapper.completer();
logHook('canActivate', next, prev);
return completer.promise;
}
}
@Component({selector: 'can-deactivate-cmp'})
@View({template: `canDeactivate {<router-outlet></router-outlet>}`, directives: [RouterOutlet]})
@RouteConfig([{path: '/a', component: A}, {path: '/b', component: B}])
class CanDeactivateCmp implements CanDeactivate {
canDeactivate(next: Instruction, prev: Instruction) {
completer = PromiseWrapper.completer();
logHook('canDeactivate', next, prev);
return completer.promise;
}
}
@Component({selector: 'all-hooks-child-cmp'})
@View({template: `child`})
@CanActivate(AllHooksChildCmp.canActivate)
class AllHooksChildCmp implements CanDeactivate, OnDeactivate, OnActivate {
canDeactivate(next: Instruction, prev: Instruction) {
logHook('canDeactivate child', next, prev);
return true;
}
onDeactivate(next: Instruction, prev: Instruction) { logHook('onDeactivate child', next, prev); }
static canActivate(next: Instruction, prev: Instruction) {
logHook('canActivate child', next, prev);
return true;
}
onActivate(next: Instruction, prev: Instruction) { logHook('onActivate child', next, prev); }
}
@Component({selector: 'all-hooks-parent-cmp'})
@View({template: `<router-outlet></router-outlet>`, directives: [RouterOutlet]})
@RouteConfig([{path: '/child', component: AllHooksChildCmp}])
@CanActivate(AllHooksParentCmp.canActivate)
class AllHooksParentCmp implements CanDeactivate, OnDeactivate, OnActivate {
canDeactivate(next: Instruction, prev: Instruction) {
logHook('canDeactivate parent', next, prev);
return true;
}
onDeactivate(next: Instruction, prev: Instruction) { logHook('onDeactivate parent', next, prev); }
static canActivate(next: Instruction, prev: Instruction) {
logHook('canActivate parent', next, prev);
return true;
}
onActivate(next: Instruction, prev: Instruction) { logHook('onActivate parent', next, prev); }
}
@Component({selector: 'reuse-hooks-cmp'})
@View({template: 'reuse hooks cmp'})
@CanActivate(ReuseHooksCmp.canActivate)
class ReuseHooksCmp implements OnActivate, OnReuse, OnDeactivate, CanReuse, CanDeactivate {
canReuse(next: Instruction, prev: Instruction): Promise<any> {
completer = PromiseWrapper.completer();
logHook('canReuse', next, prev);
return completer.promise;
}
onReuse(next: Instruction, prev: Instruction) { logHook('onReuse', next, prev); }
canDeactivate(next: Instruction, prev: Instruction) {
logHook('canDeactivate', next, prev);
return true;
}
onDeactivate(next: Instruction, prev: Instruction) { logHook('onDeactivate', next, prev); }
static canActivate(next: Instruction, prev: Instruction) {
logHook('canActivate', next, prev);
return true;
}
onActivate(next: Instruction, prev: Instruction) { logHook('onActivate', next, prev); }
}
@Component({selector: 'lifecycle-cmp'})
@View({template: `<router-outlet></router-outlet>`, directives: [RouterOutlet]})
@RouteConfig([
{path: '/a', component: A},
{path: '/on-activate', component: ActivateCmp},
{path: '/parent-activate/...', component: ParentActivateCmp},
{path: '/on-deactivate', component: DeactivateCmp},
{path: '/parent-deactivate/...', component: ParentDeactivateCmp},
{path: '/on-reuse/:number/...', component: ReuseCmp},
{path: '/never-reuse/:number/...', component: NeverReuseCmp},
{path: '/can-activate/...', component: CanActivateCmp},
{path: '/can-deactivate/...', component: CanDeactivateCmp},
{path: '/activation-hooks/...', component: AllHooksParentCmp},
{path: '/reuse-hooks/:number', component: ReuseHooksCmp}
])
class LifecycleCmp {
}

View File

@ -55,7 +55,7 @@ export function main() {
router.config({'path': '/', 'component': DummyComponent}) router.config({'path': '/', 'component': DummyComponent})
.then((_) => router.registerOutlet(outlet)) .then((_) => router.registerOutlet(outlet))
.then((_) => { .then((_) => {
expect(outlet.spy('activate')).toHaveBeenCalled(); expect(outlet.spy('commit')).toHaveBeenCalled();
expect(location.urlChanges).toEqual([]); expect(location.urlChanges).toEqual([]);
async.done(); async.done();
}); });
@ -70,7 +70,7 @@ export function main() {
.then((_) => router.config({'path': '/a', 'component': DummyComponent})) .then((_) => router.config({'path': '/a', 'component': DummyComponent}))
.then((_) => router.navigate('/a')) .then((_) => router.navigate('/a'))
.then((_) => { .then((_) => {
expect(outlet.spy('activate')).toHaveBeenCalled(); expect(outlet.spy('commit')).toHaveBeenCalled();
expect(location.urlChanges).toEqual(['/a']); expect(location.urlChanges).toEqual(['/a']);
async.done(); async.done();
}); });
@ -83,11 +83,11 @@ export function main() {
router.registerOutlet(outlet) router.registerOutlet(outlet)
.then((_) => router.navigate('/a')) .then((_) => router.navigate('/a'))
.then((_) => { .then((_) => {
expect(outlet.spy('activate')).not.toHaveBeenCalled(); expect(outlet.spy('commit')).not.toHaveBeenCalled();
return router.config({'path': '/a', 'component': DummyComponent}); return router.config({'path': '/a', 'component': DummyComponent});
}) })
.then((_) => { .then((_) => {
expect(outlet.spy('activate')).toHaveBeenCalled(); expect(outlet.spy('commit')).toHaveBeenCalled();
async.done(); async.done();
}); });
})); }));
@ -132,10 +132,10 @@ class DummyParentComp {
function makeDummyOutlet() { function makeDummyOutlet() {
var ref = new DummyOutlet(); var ref = new DummyOutlet();
ref.spy('activate').andCallFake((_) => PromiseWrapper.resolve(true));
ref.spy('canActivate').andCallFake((_) => PromiseWrapper.resolve(true)); ref.spy('canActivate').andCallFake((_) => PromiseWrapper.resolve(true));
ref.spy('canReuse').andCallFake((_) => PromiseWrapper.resolve(false));
ref.spy('canDeactivate').andCallFake((_) => PromiseWrapper.resolve(true)); ref.spy('canDeactivate').andCallFake((_) => PromiseWrapper.resolve(true));
ref.spy('deactivate').andCallFake((_) => PromiseWrapper.resolve(true)); ref.spy('commit').andCallFake((_) => PromiseWrapper.resolve(true));
return ref; return ref;
} }