fix(router): support outlets within dynamic components

Fixes internal b/27294172
This commit is contained in:
Brian Ford 2016-02-26 10:37:43 -08:00 committed by Vikram Subramanian
parent 75343eb340
commit 7d44b8230e
5 changed files with 142 additions and 25 deletions

View File

@ -1,7 +1,6 @@
import {PromiseWrapper} from 'angular2/src/facade/async'; import {PromiseWrapper} from 'angular2/src/facade/async';
import {StringMapWrapper} from 'angular2/src/facade/collection'; import {StringMapWrapper} from 'angular2/src/facade/collection';
import {isBlank, isPresent} from 'angular2/src/facade/lang'; import {isBlank, isPresent} from 'angular2/src/facade/lang';
import {BaseException, WrappedException} from 'angular2/src/facade/exceptions';
import { import {
Directive, Directive,
@ -11,7 +10,8 @@ import {
ElementRef, ElementRef,
Injector, Injector,
provide, provide,
Dependency Dependency,
OnDestroy
} from 'angular2/core'; } from 'angular2/core';
import * as routerMod from '../router'; import * as routerMod from '../router';
@ -32,7 +32,7 @@ let _resolveToTrue = PromiseWrapper.resolve(true);
* ``` * ```
*/ */
@Directive({selector: 'router-outlet'}) @Directive({selector: 'router-outlet'})
export class RouterOutlet { export class RouterOutlet implements OnDestroy {
name: string = null; name: string = null;
private _componentRef: ComponentRef = null; private _componentRef: ComponentRef = null;
private _currentInstruction: ComponentInstruction = null; private _currentInstruction: ComponentInstruction = null;
@ -81,8 +81,11 @@ export class RouterOutlet {
var previousInstruction = this._currentInstruction; var previousInstruction = this._currentInstruction;
this._currentInstruction = nextInstruction; this._currentInstruction = nextInstruction;
// it's possible the component is removed before it can be reactivated (if nested withing
// another dynamically loaded component, for instance). In that case, we simply activate
// a new one.
if (isBlank(this._componentRef)) { if (isBlank(this._componentRef)) {
throw new BaseException(`Cannot reuse an outlet that does not contain a component.`); return this.activate(nextInstruction);
} }
return PromiseWrapper.resolve( return PromiseWrapper.resolve(
hasLifecycleHook(hookMod.routerOnReuse, this._currentInstruction.componentType) ? hasLifecycleHook(hookMod.routerOnReuse, this._currentInstruction.componentType) ?
@ -157,4 +160,6 @@ export class RouterOutlet {
} }
return PromiseWrapper.resolve(result); return PromiseWrapper.resolve(result);
} }
ngOnDestroy(): void { this._parentRouter.unregisterPrimaryOutlet(this); }
} }

View File

@ -78,6 +78,10 @@ export class Router {
throw new BaseException(`registerPrimaryOutlet expects to be called with an unnamed outlet.`); throw new BaseException(`registerPrimaryOutlet expects to be called with an unnamed outlet.`);
} }
if (isPresent(this._outlet)) {
throw new BaseException(`Primary outlet is already registered.`);
}
this._outlet = outlet; this._outlet = outlet;
if (isPresent(this._currentInstruction)) { if (isPresent(this._currentInstruction)) {
return this.commit(this._currentInstruction, false); return this.commit(this._currentInstruction, false);
@ -85,6 +89,19 @@ export class Router {
return _resolveToTrue; return _resolveToTrue;
} }
/**
* Unregister an outlet (because it was destroyed, etc).
*
* You probably don't need to use this unless you're writing a custom outlet implementation.
*/
unregisterPrimaryOutlet(outlet: RouterOutlet): void {
if (isPresent(outlet.name)) {
throw new BaseException(`registerPrimaryOutlet expects to be called with an unnamed outlet.`);
}
this._outlet = null;
}
/** /**
* Register an outlet to notified of auxiliary route changes. * Register an outlet to notified of auxiliary route changes.
* *
@ -198,6 +215,26 @@ export class Router {
}); });
} }
/** @internal */
_settleInstruction(instruction: Instruction): Promise<any> {
return instruction.resolveComponent().then((_) => {
var unsettledInstructions: Array<Promise<any>> = [];
if (isPresent(instruction.component)) {
instruction.component.reuse = false;
}
if (isPresent(instruction.child)) {
unsettledInstructions.push(this._settleInstruction(instruction.child));
}
StringMapWrapper.forEach(instruction.auxInstruction, (instruction, _) => {
unsettledInstructions.push(this._settleInstruction(instruction));
});
return PromiseWrapper.all(unsettledInstructions);
});
}
/** @internal */ /** @internal */
_navigate(instruction: Instruction, _skipLocationChange: boolean): Promise<any> { _navigate(instruction: Instruction, _skipLocationChange: boolean): Promise<any> {
return this._settleInstruction(instruction) return this._settleInstruction(instruction)
@ -220,26 +257,6 @@ export class Router {
}); });
} }
/** @internal */
_settleInstruction(instruction: Instruction): Promise<any> {
return instruction.resolveComponent().then((_) => {
var unsettledInstructions: Array<Promise<any>> = [];
if (isPresent(instruction.component)) {
instruction.component.reuse = false;
}
if (isPresent(instruction.child)) {
unsettledInstructions.push(this._settleInstruction(instruction.child));
}
StringMapWrapper.forEach(instruction.auxInstruction, (instruction, _) => {
unsettledInstructions.push(this._settleInstruction(instruction));
});
return PromiseWrapper.all(unsettledInstructions);
});
}
private _emitNavigationFinish(url): void { ObservableWrapper.callEmit(this._subject, url); } private _emitNavigationFinish(url): void { ObservableWrapper.callEmit(this._subject, url); }
private _afterPromiseFinishNavigating(promise: Promise<any>): Promise<any> { private _afterPromiseFinishNavigating(promise: Promise<any>): Promise<any> {

View File

@ -9,6 +9,12 @@ import {
ROUTER_DIRECTIVES ROUTER_DIRECTIVES
} from 'angular2/router'; } from 'angular2/router';
import {PromiseWrapper} from 'angular2/src/facade/async'; import {PromiseWrapper} from 'angular2/src/facade/async';
import {isPresent} from 'angular2/src/facade/lang';
import {
DynamicComponentLoader,
ComponentRef
} from 'angular2/src/core/linker/dynamic_component_loader';
import {ElementRef} from 'angular2/src/core/linker/element_ref';
@Component({selector: 'goodbye-cmp', template: `{{farewell}}`}) @Component({selector: 'goodbye-cmp', template: `{{farewell}}`})
export class GoodbyeCmp { export class GoodbyeCmp {
@ -135,3 +141,31 @@ export function asyncRouteDataCmp() {
@RouteConfig([new Redirect({path: '/child-redirect', redirectTo: ['../HelloSib']})]) @RouteConfig([new Redirect({path: '/child-redirect', redirectTo: ['../HelloSib']})])
export class RedirectToParentCmp { export class RedirectToParentCmp {
} }
@Component({selector: 'dynamic-loader-cmp', template: `{ <div #viewport></div> }`})
@RouteConfig([new Route({path: '/', component: HelloCmp})])
export class DynamicLoaderCmp {
private _componentRef: ComponentRef = null;
constructor(private _dynamicComponentLoader: DynamicComponentLoader,
private _elementRef: ElementRef) {}
onSomeAction(): Promise<any> {
if (isPresent(this._componentRef)) {
this._componentRef.dispose();
this._componentRef = null;
}
return this._dynamicComponentLoader.loadIntoLocation(DynamicallyLoadedComponent,
this._elementRef, 'viewport')
.then((cmp) => { this._componentRef = cmp; });
}
}
@Component({
selector: 'loaded-cmp',
template: '<router-outlet></router-outlet>',
directives: [ROUTER_DIRECTIVES]
})
class DynamicallyLoadedComponent {
}

View File

@ -17,7 +17,16 @@ import {specs, compile, TEST_ROUTER_PROVIDERS, clickOnElement, getHref} from '..
import {By} from 'angular2/platform/common_dom'; import {By} from 'angular2/platform/common_dom';
import {Router, Route, Location} from 'angular2/router'; import {Router, Route, Location} from 'angular2/router';
import {HelloCmp, UserCmp, TeamCmp, ParentCmp, ParentWithDefaultCmp} from './fixture_components'; import {
HelloCmp,
UserCmp,
TeamCmp,
ParentCmp,
ParentWithDefaultCmp,
DynamicLoaderCmp
} from './fixture_components';
import {PromiseWrapper} from 'angular2/src/facade/async';
function getLinkElement(rtc: ComponentFixture) { function getLinkElement(rtc: ComponentFixture) {
@ -420,6 +429,55 @@ function syncRoutesWithSyncChildrenWithDefaultRoutesWithoutParams() {
})); }));
} }
function syncRoutesWithDynamicComponents() {
var fixture;
var tcb;
var rtr: Router;
beforeEachProviders(() => TEST_ROUTER_PROVIDERS);
beforeEach(inject([TestComponentBuilder, Router], (tcBuilder, router) => {
tcb = tcBuilder;
rtr = router;
}));
it('should work',
inject([AsyncTestCompleter],
(async) => {tcb.createAsync(DynamicLoaderCmp)
.then((rtc) => {fixture = rtc})
.then((_) => rtr.config([new Route({path: '/', component: HelloCmp})]))
.then((_) => {
fixture.detectChanges();
expect(fixture.debugElement.nativeElement).toHaveText('{ }');
return fixture.componentInstance.onSomeAction();
})
.then((_) => {
fixture.detectChanges();
return rtr.navigateByUrl('/');
})
.then((_) => {
fixture.detectChanges();
expect(fixture.debugElement.nativeElement).toHaveText('{ hello }');
return fixture.componentInstance.onSomeAction();
})
.then((_) => {
// TODO(i): This should be rewritten to use NgZone#onStable or
// something
// similar basically the assertion needs to run when the world is
// stable and we don't know when that is, only zones know.
PromiseWrapper.resolve(null).then((_) => {
fixture.detectChanges();
expect(fixture.debugElement.nativeElement).toHaveText('{ hello }');
async.done();
});
})}));
}
export function registerSpecs() { export function registerSpecs() {
specs['syncRoutesWithoutChildrenWithoutParams'] = syncRoutesWithoutChildrenWithoutParams; specs['syncRoutesWithoutChildrenWithoutParams'] = syncRoutesWithoutChildrenWithoutParams;
specs['syncRoutesWithoutChildrenWithParams'] = syncRoutesWithoutChildrenWithParams; specs['syncRoutesWithoutChildrenWithParams'] = syncRoutesWithoutChildrenWithParams;
@ -429,4 +487,5 @@ export function registerSpecs() {
syncRoutesWithSyncChildrenWithoutDefaultRoutesWithParams; syncRoutesWithSyncChildrenWithoutDefaultRoutesWithParams;
specs['syncRoutesWithSyncChildrenWithDefaultRoutesWithoutParams'] = specs['syncRoutesWithSyncChildrenWithDefaultRoutesWithoutParams'] =
syncRoutesWithSyncChildrenWithDefaultRoutesWithoutParams; syncRoutesWithSyncChildrenWithDefaultRoutesWithoutParams;
specs['syncRoutesWithDynamicComponents'] = syncRoutesWithDynamicComponents;
} }

View File

@ -20,5 +20,7 @@ export function main() {
describeWith('default routes', () => { describeWithout('params', itShouldRoute); }); describeWith('default routes', () => { describeWithout('params', itShouldRoute); });
}); });
describeWith('dynamic components', itShouldRoute);
}); });
} }