fix(router): do not finish bootstrap until all the routes are resolved (#14608)

Fixes #12162
closes #14155
This commit is contained in:
Victor Berchet 2017-02-20 18:37:38 -08:00 committed by GitHub
parent c2e0f71a78
commit 2a191cae2d
12 changed files with 304 additions and 154 deletions

View File

@ -6,6 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/
import {InjectionToken} from '@angular/core';
/**
* This class should not be used directly by an application developer. Instead, use
* {@link Location}.
@ -50,6 +51,12 @@ export abstract class PlatformLocation {
abstract back(): void;
}
/**
* @whatItDoes indicates when a location is initialized
* @experimental
*/
export const LOCATION_INITIALIZED = new InjectionToken<Promise<any>>('Location Initialized');
/**
* A serializable version of the event from onPopState or onHashChange
*

View File

@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/
import {PlatformLocation} from '@angular/common';
import {LOCATION_INITIALIZED, PlatformLocation} from '@angular/common';
import {APP_INITIALIZER, InjectionToken, NgZone} from '@angular/core';
import {WebWorkerPlatformLocation} from './platform_location';
@ -25,9 +25,18 @@ export const WORKER_APP_LOCATION_PROVIDERS = [
multi: true,
deps: [PlatformLocation, NgZone],
},
{
provide: LOCATION_INITIALIZED,
useFactory: locationInitialized,
deps: [PlatformLocation],
},
];
function appInitFnFactory(platformLocation: WebWorkerPlatformLocation, zone: NgZone): () =>
export function locationInitialized(platformLocation: WebWorkerPlatformLocation) {
return platformLocation.initialized;
}
export function appInitFnFactory(platformLocation: WebWorkerPlatformLocation, zone: NgZone): () =>
Promise<boolean> {
return () => zone.runGuarded(() => platformLocation.init());
}

View File

@ -22,6 +22,8 @@ export class WebWorkerPlatformLocation extends PlatformLocation {
private _hashChangeListeners: Array<Function> = [];
private _location: LocationType = null;
private _channelSource: EventEmitter<Object>;
public initialized: Promise<any>;
private initializedResolve: () => void;
constructor(
brokerFactory: ClientMessageBrokerFactory, bus: MessageBus, private _serializer: Serializer) {
@ -48,6 +50,7 @@ export class WebWorkerPlatformLocation extends PlatformLocation {
}
}
});
this.initialized = new Promise(res => this.initializedResolve = res);
}
/** @internal **/
@ -58,6 +61,7 @@ export class WebWorkerPlatformLocation extends PlatformLocation {
.then(
(val: LocationType) => {
this._location = val;
this.initializedResolve();
return true;
},
err => { throw new Error(err); });

View File

@ -180,6 +180,18 @@ type NavigationParams = {
source: NavigationSource,
};
/**
* @internal
*/
export type RouterHook = (snapshot: RouterStateSnapshot) => Observable<void>;
/**
* @internal
*/
function defaultRouterHook(snapshot: RouterStateSnapshot): Observable<void> {
return of (null);
}
/**
* Does not detach any subtrees. Reuses routes as long as their route config is the same.
*/
@ -221,11 +233,23 @@ export class Router {
*/
errorHandler: ErrorHandler = defaultErrorHandler;
/**
* Indicates if at least one navigation happened.
*/
navigated: boolean = false;
/**
* Used by RouterModule. This allows us to
* pause the navigation either before preactivation or after it.
* @internal
*/
hooks: {beforePreactivation: RouterHook, afterPreactivation: RouterHook} = {
beforePreactivation: defaultRouterHook,
afterPreactivation: defaultRouterHook
};
/**
* Extracts and merges URLs. Used for AngularJS to Angular migrations.
*/
@ -602,18 +626,25 @@ export class Router {
urlAndSnapshot$ = of ({appliedUrl: url, snapshot: precreatedState});
}
const beforePreactivationDone$ = mergeMap.call(
urlAndSnapshot$, (p: {appliedUrl: string, snapshot: RouterStateSnapshot}) => {
return map.call(this.hooks.beforePreactivation(p.snapshot), () => p);
});
// run preactivation: guards and data resolvers
let preActivation: PreActivation;
const preactivationTraverse$ = map.call(urlAndSnapshot$, ({appliedUrl, snapshot}: any) => {
const preactivationTraverse$ = map.call(
beforePreactivationDone$,
({appliedUrl, snapshot}: {appliedUrl: string, snapshot: RouterStateSnapshot}) => {
preActivation =
new PreActivation(snapshot, this.currentRouterState.snapshot, this.injector);
preActivation.traverse(this.outletMap);
return {appliedUrl, snapshot};
});
const preactivationCheckGuards =
mergeMap.call(preactivationTraverse$, ({appliedUrl, snapshot}: any) => {
const preactivationCheckGuards$ = mergeMap.call(
preactivationTraverse$,
({appliedUrl, snapshot}: {appliedUrl: string, snapshot: RouterStateSnapshot}) => {
if (this.navigationId !== id) return of (false);
return map.call(preActivation.checkGuards(), (shouldActivate: boolean) => {
@ -621,7 +652,7 @@ export class Router {
});
});
const preactivationResolveData$ = mergeMap.call(preactivationCheckGuards, (p: any) => {
const preactivationResolveData$ = mergeMap.call(preactivationCheckGuards$, (p: any) => {
if (this.navigationId !== id) return of (false);
if (p.shouldActivate) {
@ -631,11 +662,15 @@ export class Router {
}
});
const preactivationDone$ = mergeMap.call(preactivationResolveData$, (p: any) => {
return map.call(this.hooks.afterPreactivation(p.snapshot), () => p);
});
// create router state
// this operation has side effects => route state is being affected
const routerState$ =
map.call(preactivationResolveData$, ({appliedUrl, snapshot, shouldActivate}: any) => {
map.call(preactivationDone$, ({appliedUrl, snapshot, shouldActivate}: any) => {
if (shouldActivate) {
const state =
createRouterState(this.routeReuseStrategy, snapshot, this.currentRouterState);

View File

@ -58,10 +58,13 @@ export class RouterConfigLoader {
if (typeof loadChildren === 'string') {
return fromPromise(this.loader.load(loadChildren));
} else {
const offlineMode = this.compiler instanceof Compiler;
return mergeMap.call(
wrapIntoObservable(loadChildren()),
(t: any) => offlineMode ? of (<any>t) : fromPromise(this.compiler.compileModuleAsync(t)));
return mergeMap.call(wrapIntoObservable(loadChildren()), (t: any) => {
if (t instanceof NgModuleFactory) {
return of (t);
} else {
return fromPromise(this.compiler.compileModuleAsync(t));
}
});
}
}
}

View File

@ -6,8 +6,10 @@
* found in the LICENSE file at https://angular.io/license
*/
import {APP_BASE_HREF, HashLocationStrategy, Location, LocationStrategy, PathLocationStrategy, PlatformLocation} from '@angular/common';
import {ANALYZE_FOR_ENTRY_COMPONENTS, APP_BOOTSTRAP_LISTENER, ApplicationRef, Compiler, ComponentRef, Inject, InjectionToken, Injector, ModuleWithProviders, NgModule, NgModuleFactoryLoader, NgProbeToken, Optional, Provider, SkipSelf, SystemJsNgModuleLoader} from '@angular/core';
import {APP_BASE_HREF, HashLocationStrategy, LOCATION_INITIALIZED, Location, LocationStrategy, PathLocationStrategy, PlatformLocation} from '@angular/common';
import {ANALYZE_FOR_ENTRY_COMPONENTS, APP_BOOTSTRAP_LISTENER, APP_INITIALIZER, ApplicationRef, Compiler, ComponentRef, Inject, Injectable, InjectionToken, Injector, ModuleWithProviders, NgModule, NgModuleFactoryLoader, NgProbeToken, Optional, Provider, SkipSelf, SystemJsNgModuleLoader} from '@angular/core';
import {Subject} from 'rxjs/Subject';
import {of } from 'rxjs/observable/of';
import {Route, Routes} from './config';
import {RouterLink, RouterLinkWithHref} from './directives/router_link';
@ -19,7 +21,7 @@ import {ErrorHandler, Router} from './router';
import {ROUTES} from './router_config_loader';
import {RouterOutletMap} from './router_outlet_map';
import {NoPreloading, PreloadAllModules, PreloadingStrategy, RouterPreloader} from './router_preloader';
import {ActivatedRoute} from './router_state';
import {ActivatedRoute, RouterStateSnapshot} from './router_state';
import {UrlHandlingStrategy} from './url_handling_strategy';
import {DefaultUrlSerializer, UrlSerializer} from './url_tree';
import {flatten} from './utils/collection';
@ -278,22 +280,77 @@ export function rootRoute(router: Router): ActivatedRoute {
return router.routerState.root;
}
export function initialRouterNavigation(
router: Router, ref: ApplicationRef, preloader: RouterPreloader, opts: ExtraOptions) {
return (bootstrappedComponentRef: ComponentRef<any>) => {
/**
* To initialize the router properly we need to do in two steps:
*
* We need to start the navigation in a APP_INITIALIZER to block the bootstrap if
* a resolver or a guards executes asynchronously. Second, we need to actually run
* activation in a BOOTSTRAP_LISTENER. We utilize the afterPreactivation
* hook provided by the router to do that.
*
* The router navigation starts, reaches the point when preactivation is done, and then
* pauses. It waits for the hook to be resolved. We then resolve it only in a bootstrap listener.
*/
@Injectable()
export class RouterInitializer {
private initNavigation: boolean;
private resultOfPreactivationDone = new Subject<void>();
constructor(private injector: Injector) {}
appInitializer(): Promise<any> {
const p: Promise<any> = this.injector.get(LOCATION_INITIALIZED, Promise.resolve(null));
return p.then(() => {
let resolve: Function = null;
const res = new Promise(r => resolve = r);
const router = this.injector.get(Router);
const opts = this.injector.get(ROUTER_CONFIGURATION);
if (opts.initialNavigation === false) {
router.setUpLocationChangeListener();
} else {
router.hooks.afterPreactivation = () => {
// only the initial navigation should be delayed
if (!this.initNavigation) {
this.initNavigation = true;
resolve(true);
return this.resultOfPreactivationDone;
// subsequent navigations should not be delayed
} else {
return of (null);
}
};
router.initialNavigation();
}
return res;
});
}
bootstrapListener(bootstrappedComponentRef: ComponentRef<any>): void {
const ref = this.injector.get(ApplicationRef);
if (bootstrappedComponentRef !== ref.components[0]) {
return;
}
router.resetRootComponentType(ref.componentTypes[0]);
const preloader = this.injector.get(RouterPreloader);
preloader.setUpPreloading();
if (opts.initialNavigation === false) {
router.setUpLocationChangeListener();
} else {
router.initialNavigation();
const router = this.injector.get(Router);
router.resetRootComponentType(ref.componentTypes[0]);
this.resultOfPreactivationDone.next(null);
this.resultOfPreactivationDone.complete();
}
};
}
export function getAppInitializer(r: RouterInitializer) {
return r.appInitializer.bind(r);
}
export function getBootstrapListener(r: RouterInitializer) {
return r.bootstrapListener.bind(r);
}
/**
@ -306,11 +363,14 @@ export const ROUTER_INITIALIZER =
export function provideRouterInitializer() {
return [
RouterInitializer,
{
provide: ROUTER_INITIALIZER,
useFactory: initialRouterNavigation,
deps: [Router, ApplicationRef, RouterPreloader, ROUTER_CONFIGURATION]
provide: APP_INITIALIZER,
multi: true,
useFactory: getAppInitializer,
deps: [RouterInitializer]
},
{provide: ROUTER_INITIALIZER, useFactory: getBootstrapListener, deps: [RouterInitializer]},
{provide: APP_BOOTSTRAP_LISTENER, multi: true, useExisting: ROUTER_INITIALIZER},
];
}

View File

@ -0,0 +1,128 @@
/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {APP_BASE_HREF} from '@angular/common';
import {ApplicationRef, CUSTOM_ELEMENTS_SCHEMA, Component, NgModule, destroyPlatform} from '@angular/core';
import {BrowserModule, DOCUMENT} from '@angular/platform-browser';
import {platformBrowserDynamic} from '@angular/platform-browser-dynamic';
import {getDOM} from '@angular/platform-browser/src/dom/dom_adapter';
import {Resolve, Router, RouterModule} from '@angular/router';
describe('bootstrap', () => {
@Component({selector: 'test-app', template: 'root <router-outlet></router-outlet>'})
class RootCmp {
}
@Component({selector: 'test-app2', template: 'root <router-outlet></router-outlet>'})
class SecondRootCmp {
}
@Component({selector: 'test', template: 'test'})
class TestCmp {
}
class TestResolver implements Resolve<any> {
resolve() {
let resolve: any = null;
const res = new Promise(r => resolve = r);
setTimeout(() => resolve('test-data'), 0);
return res;
}
}
let testProviders: any[] = null;
beforeEach(() => {
destroyPlatform();
const fakeDoc = getDOM().createHtmlDocument();
const el1 = getDOM().createElement('test-app', fakeDoc);
const el2 = getDOM().createElement('test-app2', fakeDoc);
getDOM().appendChild(fakeDoc.body, el1);
getDOM().appendChild(fakeDoc.body, el2);
testProviders =
[{provide: DOCUMENT, useValue: fakeDoc}, {provide: APP_BASE_HREF, useValue: ''}];
});
it('should wait for resolvers to complete', (done) => {
@NgModule({
imports: [
BrowserModule,
RouterModule.forRoot(
[{path: '**', component: TestCmp, resolve: {test: TestResolver}}], {useHash: true})
],
declarations: [SecondRootCmp, RootCmp, TestCmp],
bootstrap: [RootCmp],
providers: [...testProviders, TestResolver],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
class TestModule {
}
platformBrowserDynamic([]).bootstrapModule(TestModule).then(res => {
const router = res.injector.get(Router);
const data = router.routerState.snapshot.root.firstChild.data;
expect(data['test']).toEqual('test-data');
done();
});
});
it('should not init router navigation listeners if a non root component is bootstrapped',
(done) => {
@NgModule({
imports: [BrowserModule, RouterModule.forRoot([], {useHash: true})],
declarations: [SecondRootCmp, RootCmp],
entryComponents: [SecondRootCmp],
bootstrap: [RootCmp],
providers: testProviders,
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
class TestModule {
}
platformBrowserDynamic([]).bootstrapModule(TestModule).then(res => {
const router = res.injector.get(Router);
spyOn(router, 'resetRootComponentType').and.callThrough();
const appRef: ApplicationRef = res.injector.get(ApplicationRef);
appRef.bootstrap(SecondRootCmp);
expect(router.resetRootComponentType).not.toHaveBeenCalled();
done();
});
});
it('should reinit router navigation listeners if a previously bootstrapped root component is destroyed',
(done) => {
@NgModule({
imports: [BrowserModule, RouterModule.forRoot([], {useHash: true})],
declarations: [SecondRootCmp, RootCmp],
entryComponents: [SecondRootCmp],
bootstrap: [RootCmp],
providers: testProviders,
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
class TestModule {
}
platformBrowserDynamic([]).bootstrapModule(TestModule).then(res => {
const router = res.injector.get(Router);
spyOn(router, 'resetRootComponentType').and.callThrough();
const appRef: ApplicationRef = res.injector.get(ApplicationRef);
appRef.components[0].onDestroy(() => {
appRef.bootstrap(SecondRootCmp);
expect(router.resetRootComponentType).toHaveBeenCalled();
done();
});
appRef.components[0].destroy();
});
});
});

View File

@ -1,91 +0,0 @@
/**
* @license
* Copyright Google Inc. All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {APP_BASE_HREF} from '@angular/common';
import {ApplicationRef, Component, NgModule} from '@angular/core';
import {TestBed, inject} from '@angular/core/testing';
import {DOCUMENT} from '@angular/platform-browser';
import {Router, RouterModule, Routes} from '@angular/router';
@Component({selector: 'app-root', template: ''})
export class AppRootComponent {
}
@Component({selector: 'bootstrappable-component', template: ''})
export class BootstrappableComponent {
}
export const appRoutes: Routes = [{path: '**', redirectTo: ''}];
@NgModule({
imports: [RouterModule.forRoot(appRoutes)],
declarations: [AppRootComponent, BootstrappableComponent],
entryComponents: [AppRootComponent, BootstrappableComponent],
providers: [{provide: APP_BASE_HREF, useValue: '/'}]
})
export class RouterInitTestModule {
}
describe('RouterModule', () => {
describe('RouterInitializer', () => {
beforeEach(() => { TestBed.configureTestingModule({imports: [RouterInitTestModule]}); });
beforeEach(inject([DOCUMENT], function(doc: HTMLDocument) {
const elRootApp = doc.createElement('app-root');
doc.body.appendChild(elRootApp);
const elBootComp = doc.createElement('bootstrappable-component');
doc.body.appendChild(elBootComp);
}));
it('should not init router navigation listeners if a non root component is bootstrapped',
() => {
const appRef: ApplicationRef = TestBed.get(ApplicationRef);
const r: Router = TestBed.get(Router);
const spy = spyOn(r, 'resetRootComponentType').and.callThrough();
appRef.bootstrap(AppRootComponent);
expect(r.resetRootComponentType).toHaveBeenCalled();
spy.calls.reset();
appRef.bootstrap(BootstrappableComponent);
expect(r.resetRootComponentType).not.toHaveBeenCalled();
});
it('should reinit router navigation listeners if a previously bootstrapped root component is destroyed',
(done) => {
const appRef: ApplicationRef = TestBed.get(ApplicationRef);
const r: Router = TestBed.get(Router);
const spy = spyOn(r, 'resetRootComponentType').and.callThrough();
const compRef = appRef.bootstrap(AppRootComponent);
expect(r.resetRootComponentType).toHaveBeenCalled();
spy.calls.reset();
compRef.onDestroy(() => {
appRef.bootstrap(BootstrappableComponent);
expect(r.resetRootComponentType).toHaveBeenCalled();
done();
});
compRef.destroy();
});
});
});

View File

@ -6,8 +6,8 @@
* found in the LICENSE file at https://angular.io/license
*/
import {APP_BOOTSTRAP_LISTENER, ApplicationRef, ComponentRef, InjectionToken} from '@angular/core';
import {ExtraOptions, ROUTER_CONFIGURATION, ROUTER_INITIALIZER, Router, RouterPreloader} from '@angular/router';
import {APP_BOOTSTRAP_LISTENER, ComponentRef, InjectionToken} from '@angular/core';
import {Router} from '@angular/router';
import {UpgradeModule} from '@angular/upgrade/static';
@ -36,38 +36,17 @@ import {UpgradeModule} from '@angular/upgrade/static';
* @experimental
*/
export const RouterUpgradeInitializer = {
provide: ROUTER_INITIALIZER,
useFactory: initialRouterNavigation,
deps: [UpgradeModule, ApplicationRef, RouterPreloader, ROUTER_CONFIGURATION]
provide: APP_BOOTSTRAP_LISTENER,
multi: true,
useFactory: locationSyncBootstrapListener,
deps: [UpgradeModule]
};
/**
* @internal
*/
export function initialRouterNavigation(
ngUpgrade: UpgradeModule, ref: ApplicationRef, preloader: RouterPreloader,
opts: ExtraOptions): Function {
return () => {
if (!ngUpgrade.$injector) {
throw new Error(`
RouterUpgradeInitializer can be used only after UpgradeModule.bootstrap has been called.
Remove RouterUpgradeInitializer and call setUpLocationSync after UpgradeModule.bootstrap.
`);
}
const router = ngUpgrade.injector.get(Router);
const ref = ngUpgrade.injector.get(ApplicationRef);
(router as any).resetRootComponentType(ref.componentTypes[0]);
preloader.setUpPreloading();
if (opts.initialNavigation === false) {
router.setUpLocationChangeListener();
} else {
router.initialNavigation();
}
setUpLocationSync(ngUpgrade);
};
export function locationSyncBootstrapListener(ngUpgrade: UpgradeModule) {
return () => { setUpLocationSync(ngUpgrade); };
}
/**
@ -78,7 +57,14 @@ export function initialRouterNavigation(
*
* @experimental
*/
export function setUpLocationSync(ngUpgrade: UpgradeModule): void {
export function setUpLocationSync(ngUpgrade: UpgradeModule) {
if (!ngUpgrade.$injector) {
throw new Error(`
RouterUpgradeInitializer can be used only after UpgradeModule.bootstrap has been called.
Remove RouterUpgradeInitializer and call setUpLocationSync after UpgradeModule.bootstrap.
`);
}
const router: Router = ngUpgrade.injector.get(Router);
const url = document.createElement('a');

View File

@ -25,7 +25,9 @@ export const ROUTES = [
@NgModule({
imports: [WorkerAppModule, RouterModule.forRoot(ROUTES, {useHash: true})],
providers: [WORKER_APP_LOCATION_PROVIDERS],
providers: [
WORKER_APP_LOCATION_PROVIDERS,
],
bootstrap: [App],
declarations: [App, Start, Contact, About]
})

View File

@ -86,6 +86,9 @@ export declare class Location {
static stripTrailingSlash(url: string): string;
}
/** @experimental */
export declare const LOCATION_INITIALIZED: InjectionToken<Promise<any>>;
/** @experimental */
export interface LocationChangeEvent {
type: string;

View File

@ -93,6 +93,10 @@ export declare const WORKER_APP_LOCATION_PROVIDERS: ({
useFactory: (platformLocation: WebWorkerPlatformLocation, zone: NgZone) => () => Promise<boolean>;
multi: boolean;
deps: (typeof NgZone | typeof PlatformLocation)[];
} | {
provide: InjectionToken<Promise<any>>;
useFactory: (platformLocation: WebWorkerPlatformLocation) => Promise<any>;
deps: typeof PlatformLocation[];
})[];
/** @experimental */