diff --git a/modules/angular2/src/router/instruction.ts b/modules/angular2/src/router/instruction.ts index dc174744ec..6d4299cc49 100644 --- a/modules/angular2/src/router/instruction.ts +++ b/modules/angular2/src/router/instruction.ts @@ -24,7 +24,6 @@ export class 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: string; - reuse: boolean = false; specificity: number; @@ -50,23 +49,4 @@ export class Instruction { } 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()); } diff --git a/modules/angular2/src/router/interfaces.ts b/modules/angular2/src/router/interfaces.ts new file mode 100644 index 0000000000..afa04661d9 --- /dev/null +++ b/modules/angular2/src/router/interfaces.ts @@ -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; +} diff --git a/modules/angular2/src/router/lifecycle_annotations.dart b/modules/angular2/src/router/lifecycle_annotations.dart new file mode 100644 index 0000000000..232e19b12c --- /dev/null +++ b/modules/angular2/src/router/lifecycle_annotations.dart @@ -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"; diff --git a/modules/angular2/src/router/lifecycle_annotations.ts b/modules/angular2/src/router/lifecycle_annotations.ts new file mode 100644 index 0000000000..112017ff89 --- /dev/null +++ b/modules/angular2/src/router/lifecycle_annotations.ts @@ -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); diff --git a/modules/angular2/src/router/lifecycle_annotations_impl.ts b/modules/angular2/src/router/lifecycle_annotations_impl.ts new file mode 100644 index 0000000000..db3367bc8b --- /dev/null +++ b/modules/angular2/src/router/lifecycle_annotations_impl.ts @@ -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")); diff --git a/modules/angular2/src/router/route_lifecycle_reflector.dart b/modules/angular2/src/router/route_lifecycle_reflector.dart new file mode 100644 index 0000000000..94559474be --- /dev/null +++ b/modules/angular2/src/router/route_lifecycle_reflector.dart @@ -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; +} diff --git a/modules/angular2/src/router/route_lifecycle_reflector.ts b/modules/angular2/src/router/route_lifecycle_reflector.ts new file mode 100644 index 0000000000..418b696e51 --- /dev/null +++ b/modules/angular2/src/router/route_lifecycle_reflector.ts @@ -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(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; +} diff --git a/modules/angular2/src/router/route_registry.ts b/modules/angular2/src/router/route_registry.ts index bf10fcb1ac..98ae2aa994 100644 --- a/modules/angular2/src/router/route_registry.ts +++ b/modules/angular2/src/router/route_registry.ts @@ -126,7 +126,11 @@ export class RouteRegistry { this.configFromComponent(componentType); 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) diff --git a/modules/angular2/src/router/router.ts b/modules/angular2/src/router/router.ts index b98b9cc45f..dd81df9ac4 100644 --- a/modules/angular2/src/router/router.ts +++ b/modules/angular2/src/router/router.ts @@ -15,6 +15,7 @@ import {Pipeline} from './pipeline'; import {Instruction} from './instruction'; import {RouterOutlet} from './router_outlet'; import {Location} from './location'; +import {getCanActivateHook} from './route_lifecycle_reflector'; let _resolveToTrue = PromiseWrapper.resolve(true); let _resolveToFalse = PromiseWrapper.resolve(false); @@ -39,7 +40,6 @@ let _resolveToFalse = PromiseWrapper.resolve(false); export class Router { navigating: boolean = false; lastNavigationAttempt: string; - previousUrl: string = null; private _currentInstruction: Instruction = null; private _currentNavigation: Promise = _resolveToTrue; @@ -67,7 +67,7 @@ export class Router { // TODO: sibling routes this._outlet = outlet; if (isPresent(this._currentInstruction)) { - return outlet.activate(this._currentInstruction); + return outlet.commit(this._currentInstruction); } 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. */ navigate(url: string): Promise { - if (this.navigating) { - return this._currentNavigation; - } - this.lastNavigationAttempt = url; - return this._currentNavigation = this.recognize(url).then((matchedInstruction) => { - if (isBlank(matchedInstruction)) { - return _resolveToFalse; - } - - if (isPresent(this._currentInstruction)) { - matchedInstruction.reuseComponentsFrom(this._currentInstruction); - } - + return this._currentNavigation = this._currentNavigation.then((_) => { + this.lastNavigationAttempt = url; this._startNavigating(); - - var result = - this.commit(matchedInstruction) - .then((_) => { - this._finishNavigating(); - ObservableWrapper.callNext(this._subject, matchedInstruction.accumulatedUrl); - }); - - return PromiseWrapper.catchError(result, (err) => { - this._finishNavigating(); - throw err; - }); + return this._afterPromiseFinishNavigating(this.recognize(url).then((matchedInstruction) => { + if (isBlank(matchedInstruction)) { + return false; + } + return this._reuse(matchedInstruction) + .then((_) => this._canActivate(matchedInstruction)) + .then((result) => { + if (!result) { + return false; + } + 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): Promise { + return PromiseWrapper.catchError(promise.then((_) => this._finishNavigating()), (err) => { + this._finishNavigating(); + throw err; + }); + } + + _reuse(instruction): Promise { + 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 { + return canActivateOne(instruction, this._currentInstruction); + } + + private _canDeactivate(instruction: Instruction): Promise { + if (isBlank(this._outlet)) { + return _resolveToTrue; + } + var next: Promise; + 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 { + this._currentInstruction = instruction; + if (isPresent(this._outlet)) { + return this._outlet.commit(instruction); + } + return _resolveToTrue; + } + + _startNavigating(): void { this.navigating = true; } _finishNavigating(): void { this.navigating = false; } @@ -149,24 +208,12 @@ export class Router { subscribe(onNext): void { ObservableWrapper.subscribe(this._subject, onNext); } - /** - * Updates this router and all descendant routers according to the given instruction - */ - commit(instruction: Instruction): Promise { - 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 */ - deactivate(): Promise { + deactivate(instruction: Instruction): Promise { if (isPresent(this._outlet)) { - return this._outlet.deactivate(); + return this._outlet.deactivate(instruction); } return _resolveToTrue; } @@ -185,11 +232,10 @@ export class Router { * router has yet to successfully navigate. */ renavigate(): Promise { - var destination = isBlank(this.previousUrl) ? this.lastNavigationAttempt : this.previousUrl; - if (isBlank(destination)) { + if (isBlank(this.lastNavigationAttempt)) { return this._currentNavigation; } - return this.navigate(destination); + return this.navigate(this.lastNavigationAttempt); } @@ -288,3 +334,24 @@ function splitAndFlattenLinkParams(linkParams: List): List { return accumulation; }, []); } + +function canActivateOne(nextInstruction, currentInstruction): Promise { + 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; + }); +} diff --git a/modules/angular2/src/router/router_outlet.ts b/modules/angular2/src/router/router_outlet.ts index 0e51c3616c..bd9037426e 100644 --- a/modules/angular2/src/router/router_outlet.ts +++ b/modules/angular2/src/router/router_outlet.ts @@ -1,4 +1,5 @@ import {Promise, PromiseWrapper} from 'angular2/src/facade/async'; +import {StringMapWrapper} from 'angular2/src/facade/collection'; import {isBlank, isPresent} from 'angular2/src/facade/lang'; 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 * 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. @@ -18,11 +20,10 @@ import {Instruction, RouteParams} from './instruction' * * ``` */ -@Directive({ - selector: 'router-outlet' -}) +@Directive({selector: 'router-outlet'}) export class RouterOutlet { - private _childRouter: routerMod.Router = null; + childRouter: routerMod.Router = null; + private _componentRef: ComponentRef = null; private _currentInstruction: Instruction = null; @@ -38,34 +39,100 @@ export class RouterOutlet { /** * Given an instruction, update the contents of this outlet. */ - activate(instruction: Instruction): Promise { - // if we're able to reuse the component, we just have to pass along the instruction to the - // component's router - // so it can propagate changes to its children - if ((instruction == this._currentInstruction || instruction.reuse) && - isPresent(this._childRouter)) { - return this._childRouter.commit(instruction.child); + commit(instruction: Instruction): Promise { + var next; + if (instruction.reuse) { + next = this._reuse(instruction); + } else { + next = this.deactivate(instruction).then((_) => this._activate(instruction)); } + return next.then((_) => this._commitChild(instruction)); + } + private _commitChild(instruction: Instruction): Promise { + if (isPresent(this.childRouter)) { + return this.childRouter.commit(instruction.child); + } else { + return PromiseWrapper.resolve(true); + } + } + + private _activate(instruction: Instruction): Promise { + var previousInstruction = this._currentInstruction; this._currentInstruction = instruction; - 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)]); + this.childRouter = this._parentRouter.childRouter(instruction.component); - return this.deactivate() - .then((_) => this._loader.loadNextToLocation(instruction.component, this._elementRef, - bindings)) + var bindings = Injector.resolve([ + bind(RouteParams) + .toValue(new RouteParams(instruction.params())), + bind(routerMod.Router).toValue(this.childRouter) + ]); + return this._loader.loadNextToLocation(instruction.component, this._elementRef, bindings) .then((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 { - return (isPresent(this._childRouter) ? this._childRouter.deactivate() : - PromiseWrapper.resolve(true)) + /** + * Called by Router during recognition phase + */ + canDeactivate(nextInstruction: Instruction): Promise { + 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 { + 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 { + 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 { + 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((_) => { if (isPresent(this._componentRef)) { this._componentRef.dispose(); @@ -73,9 +140,4 @@ export class RouterOutlet { } }); } - - canDeactivate(instruction: Instruction): Promise { - // TODO: how to get ahold of the component instance here? - return PromiseWrapper.resolve(true); - } } diff --git a/modules/angular2/test/router/outlet_spec.ts b/modules/angular2/test/router/outlet_spec.ts index 54926490b9..36cc014339 100644 --- a/modules/angular2/test/router/outlet_spec.ts +++ b/modules/angular2/test/router/outlet_spec.ts @@ -18,7 +18,8 @@ import { import {Injector, bind} from 'angular2/di'; import {Component, View} from 'angular2/src/core/annotations/decorators'; 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 {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 {Location} from 'angular2/src/router/location'; 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'; -var teamCmpCount; +var cmpInstanceCount, log, eventBus, completer; export function main() { describe('Outlet Directive', () => { @@ -55,7 +65,9 @@ export function main() { tcb = tcBuilder; rtr = router; location = loc; - teamCmpCount = 0; + cmpInstanceCount = 0; + log = ''; + eventBus = new EventEmitter(); })); function compile(template: string = "") { @@ -158,13 +170,13 @@ export function main() { .then((_) => rtr.navigate('/team/angular/user/rado')) .then((_) => { rootTC.detectChanges(); - expect(teamCmpCount).toBe(1); + expect(cmpInstanceCount).toBe(1); expect(rootTC.nativeElement).toHaveText('team angular { hello rado }'); }) .then((_) => rtr.navigate('/team/angular/user/victor')) .then((_) => { rootTC.detectChanges(); - expect(teamCmpCount).toBe(1); + expect(cmpInstanceCount).toBe(1); expect(rootTC.nativeElement).toHaveText('team angular { hello victor }'); 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', () => { var clickOnElement = function(view) { @@ -352,7 +640,7 @@ class TeamCmp { id: string; constructor(params: RouteParams) { this.id = params.get('id'); - teamCmpCount += 1; + cmpInstanceCount += 1; } } @@ -361,3 +649,175 @@ class TeamCmp { class MyComp { 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 {}`, directives: [RouterOutlet]}) +@RouteConfig([{path: '/child-activate', component: ActivateCmp}]) +class ParentActivateCmp implements OnActivate { + onActivate(next: Instruction, prev: Instruction): Promise { + 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 { + completer = PromiseWrapper.completer(); + logHook('deactivate', next, prev); + return completer.promise; + } +} + +@Component({selector: 'parent-deactivate-cmp'}) +@View({template: `parent {}`, 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 {}`, 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 {}`, 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 {}`, 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 {}`, 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: ``, 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 { + 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: ``, 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 { +} diff --git a/modules/angular2/test/router/router_spec.ts b/modules/angular2/test/router/router_spec.ts index 45bc098538..470cfa43d7 100644 --- a/modules/angular2/test/router/router_spec.ts +++ b/modules/angular2/test/router/router_spec.ts @@ -55,7 +55,7 @@ export function main() { router.config({'path': '/', 'component': DummyComponent}) .then((_) => router.registerOutlet(outlet)) .then((_) => { - expect(outlet.spy('activate')).toHaveBeenCalled(); + expect(outlet.spy('commit')).toHaveBeenCalled(); expect(location.urlChanges).toEqual([]); async.done(); }); @@ -70,7 +70,7 @@ export function main() { .then((_) => router.config({'path': '/a', 'component': DummyComponent})) .then((_) => router.navigate('/a')) .then((_) => { - expect(outlet.spy('activate')).toHaveBeenCalled(); + expect(outlet.spy('commit')).toHaveBeenCalled(); expect(location.urlChanges).toEqual(['/a']); async.done(); }); @@ -83,11 +83,11 @@ export function main() { router.registerOutlet(outlet) .then((_) => router.navigate('/a')) .then((_) => { - expect(outlet.spy('activate')).not.toHaveBeenCalled(); + expect(outlet.spy('commit')).not.toHaveBeenCalled(); return router.config({'path': '/a', 'component': DummyComponent}); }) .then((_) => { - expect(outlet.spy('activate')).toHaveBeenCalled(); + expect(outlet.spy('commit')).toHaveBeenCalled(); async.done(); }); })); @@ -132,10 +132,10 @@ class DummyParentComp { function makeDummyOutlet() { var ref = new DummyOutlet(); - ref.spy('activate').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('deactivate').andCallFake((_) => PromiseWrapper.resolve(true)); + ref.spy('commit').andCallFake((_) => PromiseWrapper.resolve(true)); return ref; }