feat(router): add navigationSource and restoredState to NavigationStart event ()

Currently, NavigationStart there is no way to know if an navigation was triggered imperatively or via the location change. These two use cases should be handled differently for a variety of use cases (e.g., scroll position restoration). This PR adds a navigation source field and restored navigation id (passed to navigations triggered by a URL change).

PR Close 
This commit is contained in:
vsavkin 2018-01-24 12:19:59 -05:00 committed by Miško Hevery
parent 108fa15792
commit 3b7bab7d22
10 changed files with 183 additions and 40 deletions
packages
tools/public_api_guard

@ -14,6 +14,7 @@ import {LocationStrategy} from './location_strategy';
/** @experimental */ /** @experimental */
export interface PopStateEvent { export interface PopStateEvent {
pop?: boolean; pop?: boolean;
state?: any;
type?: string; type?: string;
url?: string; url?: string;
} }
@ -56,6 +57,7 @@ export class Location {
this._subject.emit({ this._subject.emit({
'url': this.path(true), 'url': this.path(true),
'pop': true, 'pop': true,
'state': ev.state,
'type': ev.type, 'type': ev.type,
}); });
}); });
@ -103,16 +105,16 @@ export class Location {
* Changes the browsers URL to the normalized version of the given URL, and pushes a * Changes the browsers URL to the normalized version of the given URL, and pushes a
* new item onto the platform's history. * new item onto the platform's history.
*/ */
go(path: string, query: string = ''): void { go(path: string, query: string = '', state: any = null): void {
this._platformStrategy.pushState(null, '', path, query); this._platformStrategy.pushState(state, '', path, query);
} }
/** /**
* Changes the browsers URL to the normalized version of the given URL, and replaces * Changes the browsers URL to the normalized version of the given URL, and replaces
* the top item on the platform's history stack. * the top item on the platform's history stack.
*/ */
replaceState(path: string, query: string = ''): void { replaceState(path: string, query: string = '', state: any = null): void {
this._platformStrategy.replaceState(null, '', path, query); this._platformStrategy.replaceState(state, '', path, query);
} }
/** /**

@ -58,7 +58,10 @@ export const LOCATION_INITIALIZED = new InjectionToken<Promise<any>>('Location I
* *
* @experimental * @experimental
*/ */
export interface LocationChangeEvent { type: string; } export interface LocationChangeEvent {
type: string;
state: any;
}
/** /**
* @experimental * @experimental

@ -19,7 +19,7 @@ import {ISubscription} from 'rxjs/Subscription';
@Injectable() @Injectable()
export class SpyLocation implements Location { export class SpyLocation implements Location {
urlChanges: string[] = []; urlChanges: string[] = [];
private _history: LocationState[] = [new LocationState('', '')]; private _history: LocationState[] = [new LocationState('', '', null)];
private _historyIndex: number = 0; private _historyIndex: number = 0;
/** @internal */ /** @internal */
_subject: EventEmitter<any> = new EventEmitter(); _subject: EventEmitter<any> = new EventEmitter();
@ -34,6 +34,8 @@ export class SpyLocation implements Location {
path(): string { return this._history[this._historyIndex].path; } path(): string { return this._history[this._historyIndex].path; }
private state(): string { return this._history[this._historyIndex].state; }
isCurrentPathEqualTo(path: string, query: string = ''): boolean { isCurrentPathEqualTo(path: string, query: string = ''): boolean {
const givenPath = path.endsWith('/') ? path.substring(0, path.length - 1) : path; const givenPath = path.endsWith('/') ? path.substring(0, path.length - 1) : path;
const currPath = const currPath =
@ -60,13 +62,13 @@ export class SpyLocation implements Location {
return this._baseHref + url; return this._baseHref + url;
} }
go(path: string, query: string = '') { go(path: string, query: string = '', state: any = null) {
path = this.prepareExternalUrl(path); path = this.prepareExternalUrl(path);
if (this._historyIndex > 0) { if (this._historyIndex > 0) {
this._history.splice(this._historyIndex + 1); this._history.splice(this._historyIndex + 1);
} }
this._history.push(new LocationState(path, query)); this._history.push(new LocationState(path, query, state));
this._historyIndex = this._history.length - 1; this._historyIndex = this._history.length - 1;
const locationState = this._history[this._historyIndex - 1]; const locationState = this._history[this._historyIndex - 1];
@ -79,7 +81,7 @@ export class SpyLocation implements Location {
this._subject.emit({'url': url, 'pop': false}); this._subject.emit({'url': url, 'pop': false});
} }
replaceState(path: string, query: string = '') { replaceState(path: string, query: string = '', state: any = null) {
path = this.prepareExternalUrl(path); path = this.prepareExternalUrl(path);
const history = this._history[this._historyIndex]; const history = this._history[this._historyIndex];
@ -89,6 +91,7 @@ export class SpyLocation implements Location {
history.path = path; history.path = path;
history.query = query; history.query = query;
history.state = state;
const url = path + (query.length > 0 ? ('?' + query) : ''); const url = path + (query.length > 0 ? ('?' + query) : '');
this.urlChanges.push('replace: ' + url); this.urlChanges.push('replace: ' + url);
@ -97,14 +100,14 @@ export class SpyLocation implements Location {
forward() { forward() {
if (this._historyIndex < (this._history.length - 1)) { if (this._historyIndex < (this._history.length - 1)) {
this._historyIndex++; this._historyIndex++;
this._subject.emit({'url': this.path(), 'pop': true}); this._subject.emit({'url': this.path(), 'state': this.state(), 'pop': true});
} }
} }
back() { back() {
if (this._historyIndex > 0) { if (this._historyIndex > 0) {
this._historyIndex--; this._historyIndex--;
this._subject.emit({'url': this.path(), 'pop': true}); this._subject.emit({'url': this.path(), 'state': this.state(), 'pop': true});
} }
} }
@ -118,10 +121,5 @@ export class SpyLocation implements Location {
} }
class LocationState { class LocationState {
path: string; constructor(public path: string, public query: string, public state: any) {}
query: string;
constructor(path: string, query: string) {
this.path = path;
this.query = query;
}
} }

@ -63,8 +63,9 @@ export class ServerPlatformLocation implements PlatformLocation {
} }
(this as{hash: string}).hash = value; (this as{hash: string}).hash = value;
const newUrl = this.url; const newUrl = this.url;
scheduleMicroTask( scheduleMicroTask(() => this._hashUpdate.next({
() => this._hashUpdate.next({ type: 'hashchange', oldUrl, newUrl } as LocationChangeEvent)); type: 'hashchange', state: null, oldUrl, newUrl
} as LocationChangeEvent));
} }
replaceState(state: any, title: string, newUrl: string): void { replaceState(state: any, title: string, newUrl: string): void {

@ -9,6 +9,16 @@
import {Route} from './config'; import {Route} from './config';
import {ActivatedRouteSnapshot, RouterStateSnapshot} from './router_state'; import {ActivatedRouteSnapshot, RouterStateSnapshot} from './router_state';
/**
* @whatItDoes Identifies the trigger of the navigation.
*
* * 'imperative'--triggered by `router.navigateByUrl` or `router.navigate`.
* * 'popstate'--triggered by a popstate event
* * 'hashchange'--triggered by a hashchange event
*
* @experimental
*/
export type NavigationTrigger = 'imperative' | 'popstate' | 'hashchange';
/** /**
* @whatItDoes Base for events the Router goes through, as opposed to events tied to a specific * @whatItDoes Base for events the Router goes through, as opposed to events tied to a specific
@ -42,6 +52,43 @@ export class RouterEvent {
* @stable * @stable
*/ */
export class NavigationStart extends RouterEvent { export class NavigationStart extends RouterEvent {
/**
* Identifies the trigger of the navigation.
*
* * 'imperative'--triggered by `router.navigateByUrl` or `router.navigate`.
* * 'popstate'--triggered by a popstate event
* * 'hashchange'--triggered by a hashchange event
*/
navigationTrigger?: 'imperative'|'popstate'|'hashchange';
/**
* This contains the navigation id that pushed the history record that the router navigates
* back to. This is not null only when the navigation is triggered by a popstate event.
*
* The router assigns a navigationId to every router transition/navigation. Even when the user
* clicks on the back button in the browser, a new navigation id will be created. So from
* the perspective of the router, the router never "goes back". By using the `restoredState`
* and its navigationId, you can implement behavior that differentiates between creating new
* states
* and popstate events. In the latter case you can restore some remembered state (e.g., scroll
* position).
*/
restoredState?: {navigationId: number}|null;
constructor(
/** @docsNotRequired */
id: number,
/** @docsNotRequired */
url: string,
/** @docsNotRequired */
navigationTrigger: 'imperative'|'popstate'|'hashchange' = 'imperative',
/** @docsNotRequired */
restoredState: {navigationId: number}|null = null) {
super(id, url);
this.navigationTrigger = navigationTrigger;
this.restoredState = restoredState;
}
/** @docsNotRequired */ /** @docsNotRequired */
toString(): string { return `NavigationStart(id: ${this.id}, url: '${this.url}')`; } toString(): string { return `NavigationStart(id: ${this.id}, url: '${this.url}')`; }
} }

@ -21,7 +21,7 @@ import {applyRedirects} from './apply_redirects';
import {LoadedRouterConfig, QueryParamsHandling, Route, Routes, validateConfig} from './config'; import {LoadedRouterConfig, QueryParamsHandling, Route, Routes, validateConfig} from './config';
import {createRouterState} from './create_router_state'; import {createRouterState} from './create_router_state';
import {createUrlTree} from './create_url_tree'; import {createUrlTree} from './create_url_tree';
import {ActivationEnd, ChildActivationEnd, Event, GuardsCheckEnd, GuardsCheckStart, NavigationCancel, NavigationEnd, NavigationError, NavigationStart, ResolveEnd, ResolveStart, RouteConfigLoadEnd, RouteConfigLoadStart, RoutesRecognized} from './events'; import {ActivationEnd, ChildActivationEnd, Event, GuardsCheckEnd, GuardsCheckStart, NavigationCancel, NavigationEnd, NavigationError, NavigationStart, NavigationTrigger, ResolveEnd, ResolveStart, RouteConfigLoadEnd, RouteConfigLoadStart, RoutesRecognized} from './events';
import {PreActivation} from './pre_activation'; import {PreActivation} from './pre_activation';
import {recognize} from './recognize'; import {recognize} from './recognize';
import {DefaultRouteReuseStrategy, DetachedRouteHandleInternal, RouteReuseStrategy} from './route_reuse_strategy'; import {DefaultRouteReuseStrategy, DetachedRouteHandleInternal, RouteReuseStrategy} from './route_reuse_strategy';
@ -164,8 +164,6 @@ function defaultErrorHandler(error: any): any {
throw error; throw error;
} }
type NavigationSource = 'imperative' | 'popstate' | 'hashchange';
type NavigationParams = { type NavigationParams = {
id: number, id: number,
rawUrl: UrlTree, rawUrl: UrlTree,
@ -173,7 +171,8 @@ type NavigationParams = {
resolve: any, resolve: any,
reject: any, reject: any,
promise: Promise<boolean>, promise: Promise<boolean>,
source: NavigationSource, source: NavigationTrigger,
state: {navigationId: number} | null
}; };
/** /**
@ -223,6 +222,7 @@ export class Router {
* Indicates if at least one navigation happened. * Indicates if at least one navigation happened.
*/ */
navigated: boolean = false; navigated: boolean = false;
private lastSuccessfulId: number = -1;
/** /**
* Used by RouterModule. This allows us to * Used by RouterModule. This allows us to
@ -311,8 +311,12 @@ export class Router {
if (!this.locationSubscription) { if (!this.locationSubscription) {
this.locationSubscription = <any>this.location.subscribe(Zone.current.wrap((change: any) => { this.locationSubscription = <any>this.location.subscribe(Zone.current.wrap((change: any) => {
const rawUrlTree = this.urlSerializer.parse(change['url']); const rawUrlTree = this.urlSerializer.parse(change['url']);
const source: NavigationSource = change['type'] === 'popstate' ? 'popstate' : 'hashchange'; const source: NavigationTrigger = change['type'] === 'popstate' ? 'popstate' : 'hashchange';
setTimeout(() => { this.scheduleNavigation(rawUrlTree, source, {replaceUrl: true}); }, 0); const state = change.state && change.state.navigationId ?
{navigationId: change.state.navigationId} :
null;
setTimeout(
() => { this.scheduleNavigation(rawUrlTree, source, state, {replaceUrl: true}); }, 0);
})); }));
} }
} }
@ -341,6 +345,7 @@ export class Router {
validateConfig(config); validateConfig(config);
this.config = config; this.config = config;
this.navigated = false; this.navigated = false;
this.lastSuccessfulId = -1;
} }
/** @docsNotRequired */ /** @docsNotRequired */
@ -449,7 +454,7 @@ export class Router {
const urlTree = url instanceof UrlTree ? url : this.parseUrl(url); const urlTree = url instanceof UrlTree ? url : this.parseUrl(url);
const mergedTree = this.urlHandlingStrategy.merge(urlTree, this.rawUrlTree); const mergedTree = this.urlHandlingStrategy.merge(urlTree, this.rawUrlTree);
return this.scheduleNavigation(mergedTree, 'imperative', extras); return this.scheduleNavigation(mergedTree, 'imperative', null, extras);
} }
/** /**
@ -522,8 +527,9 @@ export class Router {
.subscribe(() => {}); .subscribe(() => {});
} }
private scheduleNavigation(rawUrl: UrlTree, source: NavigationSource, extras: NavigationExtras): private scheduleNavigation(
Promise<boolean> { rawUrl: UrlTree, source: NavigationTrigger, state: {navigationId: number}|null,
extras: NavigationExtras): Promise<boolean> {
const lastNavigation = this.navigations.value; const lastNavigation = this.navigations.value;
// If the user triggers a navigation imperatively (e.g., by using navigateByUrl), // If the user triggers a navigation imperatively (e.g., by using navigateByUrl),
@ -558,21 +564,22 @@ export class Router {
}); });
const id = ++this.navigationId; const id = ++this.navigationId;
this.navigations.next({id, source, rawUrl, extras, resolve, reject, promise}); this.navigations.next({id, source, state, rawUrl, extras, resolve, reject, promise});
// Make sure that the error is propagated even though `processNavigations` catch // Make sure that the error is propagated even though `processNavigations` catch
// handler does not rethrow // handler does not rethrow
return promise.catch((e: any) => Promise.reject(e)); return promise.catch((e: any) => Promise.reject(e));
} }
private executeScheduledNavigation({id, rawUrl, extras, resolve, reject}: NavigationParams): private executeScheduledNavigation({id, rawUrl, extras, resolve, reject, source,
void { state}: NavigationParams): void {
const url = this.urlHandlingStrategy.extract(rawUrl); const url = this.urlHandlingStrategy.extract(rawUrl);
const urlTransition = !this.navigated || url.toString() !== this.currentUrlTree.toString(); const urlTransition = !this.navigated || url.toString() !== this.currentUrlTree.toString();
if ((this.onSameUrlNavigation === 'reload' ? true : urlTransition) && if ((this.onSameUrlNavigation === 'reload' ? true : urlTransition) &&
this.urlHandlingStrategy.shouldProcessUrl(rawUrl)) { this.urlHandlingStrategy.shouldProcessUrl(rawUrl)) {
(this.events as Subject<Event>).next(new NavigationStart(id, this.serializeUrl(url))); (this.events as Subject<Event>)
.next(new NavigationStart(id, this.serializeUrl(url), source, state));
Promise.resolve() Promise.resolve()
.then( .then(
(_) => this.runNavigate( (_) => this.runNavigate(
@ -584,7 +591,8 @@ export class Router {
} else if ( } else if (
urlTransition && this.rawUrlTree && urlTransition && this.rawUrlTree &&
this.urlHandlingStrategy.shouldProcessUrl(this.rawUrlTree)) { this.urlHandlingStrategy.shouldProcessUrl(this.rawUrlTree)) {
(this.events as Subject<Event>).next(new NavigationStart(id, this.serializeUrl(url))); (this.events as Subject<Event>)
.next(new NavigationStart(id, this.serializeUrl(url), source, state));
Promise.resolve() Promise.resolve()
.then( .then(
(_) => this.runNavigate( (_) => this.runNavigate(
@ -727,9 +735,9 @@ export class Router {
if (!skipLocationChange) { if (!skipLocationChange) {
const path = this.urlSerializer.serialize(this.rawUrlTree); const path = this.urlSerializer.serialize(this.rawUrlTree);
if (this.location.isCurrentPathEqualTo(path) || replaceUrl) { if (this.location.isCurrentPathEqualTo(path) || replaceUrl) {
this.location.replaceState(path); this.location.replaceState(path, '', {navigationId: id});
} else { } else {
this.location.go(path); this.location.go(path, '', {navigationId: id});
} }
} }
@ -743,6 +751,7 @@ export class Router {
() => { () => {
if (navigationIsSuccessful) { if (navigationIsSuccessful) {
this.navigated = true; this.navigated = true;
this.lastSuccessfulId = id;
(this.events as Subject<Event>) (this.events as Subject<Event>)
.next(new NavigationEnd( .next(new NavigationEnd(
id, this.serializeUrl(url), this.serializeUrl(this.currentUrlTree))); id, this.serializeUrl(url), this.serializeUrl(this.currentUrlTree)));
@ -784,7 +793,8 @@ export class Router {
} }
private resetUrlToCurrentUrlTree(): void { private resetUrlToCurrentUrlTree(): void {
this.location.replaceState(this.urlSerializer.serialize(this.rawUrlTree)); this.location.replaceState(
this.urlSerializer.serialize(this.rawUrlTree), '', {navigationId: this.lastSuccessfulId});
} }
} }

@ -76,6 +76,28 @@ describe('Integration', () => {
]); ]);
}))); })));
it('should set the restoredState to null when executing imperative navigations',
fakeAsync(inject([Router], (router: Router) => {
router.resetConfig([
{path: '', component: SimpleCmp},
{path: 'simple', component: SimpleCmp},
]);
const fixture = createRoot(router, RootCmp);
let event: NavigationStart;
router.events.subscribe(e => {
if (e instanceof NavigationStart) {
event = e;
}
});
router.navigateByUrl('/simple');
tick();
expect(event !.navigationTrigger).toEqual('imperative');
expect(event !.restoredState).toEqual(null);
})));
it('should not pollute browser history when replaceUrl is set to true', it('should not pollute browser history when replaceUrl is set to true',
fakeAsync(inject([Router, Location], (router: Router, location: SpyLocation) => { fakeAsync(inject([Router, Location], (router: Router, location: SpyLocation) => {
router.resetConfig([ router.resetConfig([
@ -466,21 +488,34 @@ describe('Integration', () => {
[{path: 'simple', component: SimpleCmp}, {path: 'user/:name', component: UserCmp}] [{path: 'simple', component: SimpleCmp}, {path: 'user/:name', component: UserCmp}]
}]); }]);
let event: NavigationStart;
router.events.subscribe(e => {
if (e instanceof NavigationStart) {
event = e;
}
});
router.navigateByUrl('/team/33/simple'); router.navigateByUrl('/team/33/simple');
advance(fixture); advance(fixture);
expect(location.path()).toEqual('/team/33/simple'); expect(location.path()).toEqual('/team/33/simple');
const simpleNavStart = event !;
router.navigateByUrl('/team/22/user/victor'); router.navigateByUrl('/team/22/user/victor');
advance(fixture); advance(fixture);
const userVictorNavStart = event !;
location.back(); location.back();
advance(fixture); advance(fixture);
expect(location.path()).toEqual('/team/33/simple'); expect(location.path()).toEqual('/team/33/simple');
expect(event !.navigationTrigger).toEqual('hashchange');
expect(event !.restoredState !.navigationId).toEqual(simpleNavStart.id);
location.forward(); location.forward();
advance(fixture); advance(fixture);
expect(location.path()).toEqual('/team/22/user/victor'); expect(location.path()).toEqual('/team/22/user/victor');
expect(event !.navigationTrigger).toEqual('hashchange');
expect(event !.restoredState !.navigationId).toEqual(userVictorNavStart.id);
}))); })));
it('should navigate to the same url when config changes', it('should navigate to the same url when config changes',
@ -868,6 +903,40 @@ describe('Integration', () => {
expect(locationUrlBeforeEmittingError).toEqual('/simple'); expect(locationUrlBeforeEmittingError).toEqual('/simple');
})); }));
it('should reset the url with the right state when navigation errors', fakeAsync(() => {
const router: Router = TestBed.get(Router);
const location: SpyLocation = TestBed.get(Location);
const fixture = createRoot(router, RootCmp);
router.resetConfig([
{path: 'simple1', component: SimpleCmp}, {path: 'simple2', component: SimpleCmp},
{path: 'throwing', component: ThrowingCmp}
]);
let event: NavigationStart;
router.events.subscribe(e => {
if (e instanceof NavigationStart) {
event = e;
}
});
router.navigateByUrl('/simple1');
advance(fixture);
const simple1NavStart = event !;
router.navigateByUrl('/throwing').catch(() => null);
advance(fixture);
router.navigateByUrl('/simple2');
advance(fixture);
location.back();
tick();
expect(event !.restoredState !.navigationId).toEqual(simple1NavStart.id);
}));
it('should not trigger another navigation when resetting the url back due to a NavigationError', it('should not trigger another navigation when resetting the url back due to a NavigationError',
fakeAsync(() => { fakeAsync(() => {
const router = TestBed.get(Router); const router = TestBed.get(Router);

@ -182,12 +182,12 @@ export declare class Location {
constructor(platformStrategy: LocationStrategy); constructor(platformStrategy: LocationStrategy);
back(): void; back(): void;
forward(): void; forward(): void;
go(path: string, query?: string): void; go(path: string, query?: string, state?: any): void;
isCurrentPathEqualTo(path: string, query?: string): boolean; isCurrentPathEqualTo(path: string, query?: string): boolean;
normalize(url: string): string; normalize(url: string): string;
path(includeHash?: boolean): string; path(includeHash?: boolean): string;
prepareExternalUrl(url: string): string; prepareExternalUrl(url: string): string;
replaceState(path: string, query?: string): void; replaceState(path: string, query?: string, state?: any): void;
subscribe(onNext: (value: PopStateEvent) => void, onThrow?: ((exception: any) => void) | null, onReturn?: (() => void) | null): ISubscription; subscribe(onNext: (value: PopStateEvent) => void, onThrow?: ((exception: any) => void) | null, onReturn?: (() => void) | null): ISubscription;
static joinWithSlash(start: string, end: string): string; static joinWithSlash(start: string, end: string): string;
static normalizeQueryParams(params: string): string; static normalizeQueryParams(params: string): string;
@ -199,6 +199,7 @@ export declare const LOCATION_INITIALIZED: InjectionToken<Promise<any>>;
/** @experimental */ /** @experimental */
export interface LocationChangeEvent { export interface LocationChangeEvent {
state: any;
type: string; type: string;
} }
@ -415,6 +416,7 @@ export declare enum Plural {
/** @experimental */ /** @experimental */
export interface PopStateEvent { export interface PopStateEvent {
pop?: boolean; pop?: boolean;
state?: any;
type?: string; type?: string;
url?: string; url?: string;
} }

@ -21,12 +21,12 @@ export declare class SpyLocation implements Location {
urlChanges: string[]; urlChanges: string[];
back(): void; back(): void;
forward(): void; forward(): void;
go(path: string, query?: string): void; go(path: string, query?: string, state?: any): void;
isCurrentPathEqualTo(path: string, query?: string): boolean; isCurrentPathEqualTo(path: string, query?: string): boolean;
normalize(url: string): string; normalize(url: string): string;
path(): string; path(): string;
prepareExternalUrl(url: string): string; prepareExternalUrl(url: string): string;
replaceState(path: string, query?: string): void; replaceState(path: string, query?: string, state?: any): void;
setBaseHref(url: string): void; setBaseHref(url: string): void;
setInitialPath(url: string): void; setInitialPath(url: string): void;
simulateHashChange(pathname: string): void; simulateHashChange(pathname: string): void;

@ -208,6 +208,17 @@ export interface NavigationExtras {
/** @stable */ /** @stable */
export declare class NavigationStart extends RouterEvent { export declare class NavigationStart extends RouterEvent {
navigationTrigger?: 'imperative' | 'popstate' | 'hashchange';
restoredState?: {
navigationId: number;
} | null;
constructor(
id: number,
url: string,
navigationTrigger?: 'imperative' | 'popstate' | 'hashchange',
restoredState?: {
navigationId: number;
} | null);
toString(): string; toString(): string;
} }