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

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 #21728
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

View File

@ -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);
} }
/** /**

View File

@ -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

View File

@ -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;
}
} }

View File

@ -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 {

View File

@ -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}')`; }
} }

View File

@ -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});
} }
} }

View File

@ -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);

View File

@ -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;
} }

View File

@ -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;

View File

@ -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;
} }