Skip to content

Commit c40ae7f

Browse files
vsavkinjasonaden
authored andcommitted
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
1 parent 5bd93b1 commit c40ae7f

File tree

10 files changed

+183
-40
lines changed

10 files changed

+183
-40
lines changed

packages/common/src/location/location.ts

+6-4
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {LocationStrategy} from './location_strategy';
1414
/** @experimental */
1515
export interface PopStateEvent {
1616
pop?: boolean;
17+
state?: any;
1718
type?: string;
1819
url?: string;
1920
}
@@ -56,6 +57,7 @@ export class Location {
5657
this._subject.emit({
5758
'url': this.path(true),
5859
'pop': true,
60+
'state': ev.state,
5961
'type': ev.type,
6062
});
6163
});
@@ -103,16 +105,16 @@ export class Location {
103105
* Changes the browsers URL to the normalized version of the given URL, and pushes a
104106
* new item onto the platform's history.
105107
*/
106-
go(path: string, query: string = ''): void {
107-
this._platformStrategy.pushState(null, '', path, query);
108+
go(path: string, query: string = '', state: any = null): void {
109+
this._platformStrategy.pushState(state, '', path, query);
108110
}
109111

110112
/**
111113
* Changes the browsers URL to the normalized version of the given URL, and replaces
112114
* the top item on the platform's history stack.
113115
*/
114-
replaceState(path: string, query: string = ''): void {
115-
this._platformStrategy.replaceState(null, '', path, query);
116+
replaceState(path: string, query: string = '', state: any = null): void {
117+
this._platformStrategy.replaceState(state, '', path, query);
116118
}
117119

118120
/**

packages/common/src/location/platform_location.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,10 @@ export const LOCATION_INITIALIZED = new InjectionToken<Promise<any>>('Location I
5858
*
5959
* @experimental
6060
*/
61-
export interface LocationChangeEvent { type: string; }
61+
export interface LocationChangeEvent {
62+
type: string;
63+
state: any;
64+
}
6265

6366
/**
6467
* @experimental

packages/common/testing/src/location_mock.ts

+10-12
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import {ISubscription} from 'rxjs/Subscription';
1919
@Injectable()
2020
export class SpyLocation implements Location {
2121
urlChanges: string[] = [];
22-
private _history: LocationState[] = [new LocationState('', '')];
22+
private _history: LocationState[] = [new LocationState('', '', null)];
2323
private _historyIndex: number = 0;
2424
/** @internal */
2525
_subject: EventEmitter<any> = new EventEmitter();
@@ -34,6 +34,8 @@ export class SpyLocation implements Location {
3434

3535
path(): string { return this._history[this._historyIndex].path; }
3636

37+
private state(): string { return this._history[this._historyIndex].state; }
38+
3739
isCurrentPathEqualTo(path: string, query: string = ''): boolean {
3840
const givenPath = path.endsWith('/') ? path.substring(0, path.length - 1) : path;
3941
const currPath =
@@ -60,13 +62,13 @@ export class SpyLocation implements Location {
6062
return this._baseHref + url;
6163
}
6264

63-
go(path: string, query: string = '') {
65+
go(path: string, query: string = '', state: any = null) {
6466
path = this.prepareExternalUrl(path);
6567

6668
if (this._historyIndex > 0) {
6769
this._history.splice(this._historyIndex + 1);
6870
}
69-
this._history.push(new LocationState(path, query));
71+
this._history.push(new LocationState(path, query, state));
7072
this._historyIndex = this._history.length - 1;
7173

7274
const locationState = this._history[this._historyIndex - 1];
@@ -79,7 +81,7 @@ export class SpyLocation implements Location {
7981
this._subject.emit({'url': url, 'pop': false});
8082
}
8183

82-
replaceState(path: string, query: string = '') {
84+
replaceState(path: string, query: string = '', state: any = null) {
8385
path = this.prepareExternalUrl(path);
8486

8587
const history = this._history[this._historyIndex];
@@ -89,6 +91,7 @@ export class SpyLocation implements Location {
8991

9092
history.path = path;
9193
history.query = query;
94+
history.state = state;
9295

9396
const url = path + (query.length > 0 ? ('?' + query) : '');
9497
this.urlChanges.push('replace: ' + url);
@@ -97,14 +100,14 @@ export class SpyLocation implements Location {
97100
forward() {
98101
if (this._historyIndex < (this._history.length - 1)) {
99102
this._historyIndex++;
100-
this._subject.emit({'url': this.path(), 'pop': true});
103+
this._subject.emit({'url': this.path(), 'state': this.state(), 'pop': true});
101104
}
102105
}
103106

104107
back() {
105108
if (this._historyIndex > 0) {
106109
this._historyIndex--;
107-
this._subject.emit({'url': this.path(), 'pop': true});
110+
this._subject.emit({'url': this.path(), 'state': this.state(), 'pop': true});
108111
}
109112
}
110113

@@ -118,10 +121,5 @@ export class SpyLocation implements Location {
118121
}
119122

120123
class LocationState {
121-
path: string;
122-
query: string;
123-
constructor(path: string, query: string) {
124-
this.path = path;
125-
this.query = query;
126-
}
124+
constructor(public path: string, public query: string, public state: any) {}
127125
}

packages/platform-server/src/location.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,9 @@ export class ServerPlatformLocation implements PlatformLocation {
6363
}
6464
(this as{hash: string}).hash = value;
6565
const newUrl = this.url;
66-
scheduleMicroTask(
67-
() => this._hashUpdate.next({ type: 'hashchange', oldUrl, newUrl } as LocationChangeEvent));
66+
scheduleMicroTask(() => this._hashUpdate.next({
67+
type: 'hashchange', state: null, oldUrl, newUrl
68+
} as LocationChangeEvent));
6869
}
6970

7071
replaceState(state: any, title: string, newUrl: string): void {

packages/router/src/events.ts

+47
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,16 @@
99
import {Route} from './config';
1010
import {ActivatedRouteSnapshot, RouterStateSnapshot} from './router_state';
1111

12+
/**
13+
* @whatItDoes Identifies the trigger of the navigation.
14+
*
15+
* * 'imperative'--triggered by `router.navigateByUrl` or `router.navigate`.
16+
* * 'popstate'--triggered by a popstate event
17+
* * 'hashchange'--triggered by a hashchange event
18+
*
19+
* @experimental
20+
*/
21+
export type NavigationTrigger = 'imperative' | 'popstate' | 'hashchange';
1222

1323
/**
1424
* @whatItDoes Base for events the Router goes through, as opposed to events tied to a specific
@@ -42,6 +52,43 @@ export class RouterEvent {
4252
* @stable
4353
*/
4454
export class NavigationStart extends RouterEvent {
55+
/**
56+
* Identifies the trigger of the navigation.
57+
*
58+
* * 'imperative'--triggered by `router.navigateByUrl` or `router.navigate`.
59+
* * 'popstate'--triggered by a popstate event
60+
* * 'hashchange'--triggered by a hashchange event
61+
*/
62+
navigationTrigger?: 'imperative'|'popstate'|'hashchange';
63+
64+
/**
65+
* This contains the navigation id that pushed the history record that the router navigates
66+
* back to. This is not null only when the navigation is triggered by a popstate event.
67+
*
68+
* The router assigns a navigationId to every router transition/navigation. Even when the user
69+
* clicks on the back button in the browser, a new navigation id will be created. So from
70+
* the perspective of the router, the router never "goes back". By using the `restoredState`
71+
* and its navigationId, you can implement behavior that differentiates between creating new
72+
* states
73+
* and popstate events. In the latter case you can restore some remembered state (e.g., scroll
74+
* position).
75+
*/
76+
restoredState?: {navigationId: number}|null;
77+
78+
constructor(
79+
/** @docsNotRequired */
80+
id: number,
81+
/** @docsNotRequired */
82+
url: string,
83+
/** @docsNotRequired */
84+
navigationTrigger: 'imperative'|'popstate'|'hashchange' = 'imperative',
85+
/** @docsNotRequired */
86+
restoredState: {navigationId: number}|null = null) {
87+
super(id, url);
88+
this.navigationTrigger = navigationTrigger;
89+
this.restoredState = restoredState;
90+
}
91+
4592
/** @docsNotRequired */
4693
toString(): string { return `NavigationStart(id: ${this.id}, url: '${this.url}')`; }
4794
}

packages/router/src/router.ts

+27-17
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import {applyRedirects} from './apply_redirects';
2121
import {LoadedRouterConfig, QueryParamsHandling, Route, Routes, validateConfig} from './config';
2222
import {createRouterState} from './create_router_state';
2323
import {createUrlTree} from './create_url_tree';
24-
import {ActivationEnd, ChildActivationEnd, Event, GuardsCheckEnd, GuardsCheckStart, NavigationCancel, NavigationEnd, NavigationError, NavigationStart, ResolveEnd, ResolveStart, RouteConfigLoadEnd, RouteConfigLoadStart, RoutesRecognized} from './events';
24+
import {ActivationEnd, ChildActivationEnd, Event, GuardsCheckEnd, GuardsCheckStart, NavigationCancel, NavigationEnd, NavigationError, NavigationStart, NavigationTrigger, ResolveEnd, ResolveStart, RouteConfigLoadEnd, RouteConfigLoadStart, RoutesRecognized} from './events';
2525
import {PreActivation} from './pre_activation';
2626
import {recognize} from './recognize';
2727
import {DefaultRouteReuseStrategy, DetachedRouteHandleInternal, RouteReuseStrategy} from './route_reuse_strategy';
@@ -164,16 +164,15 @@ function defaultErrorHandler(error: any): any {
164164
throw error;
165165
}
166166

167-
type NavigationSource = 'imperative' | 'popstate' | 'hashchange';
168-
169167
type NavigationParams = {
170168
id: number,
171169
rawUrl: UrlTree,
172170
extras: NavigationExtras,
173171
resolve: any,
174172
reject: any,
175173
promise: Promise<boolean>,
176-
source: NavigationSource,
174+
source: NavigationTrigger,
175+
state: {navigationId: number} | null
177176
};
178177

179178
/**
@@ -223,6 +222,7 @@ export class Router {
223222
* Indicates if at least one navigation happened.
224223
*/
225224
navigated: boolean = false;
225+
private lastSuccessfulId: number = -1;
226226

227227
/**
228228
* Used by RouterModule. This allows us to
@@ -311,8 +311,12 @@ export class Router {
311311
if (!this.locationSubscription) {
312312
this.locationSubscription = <any>this.location.subscribe(Zone.current.wrap((change: any) => {
313313
const rawUrlTree = this.urlSerializer.parse(change['url']);
314-
const source: NavigationSource = change['type'] === 'popstate' ? 'popstate' : 'hashchange';
315-
setTimeout(() => { this.scheduleNavigation(rawUrlTree, source, {replaceUrl: true}); }, 0);
314+
const source: NavigationTrigger = change['type'] === 'popstate' ? 'popstate' : 'hashchange';
315+
const state = change.state && change.state.navigationId ?
316+
{navigationId: change.state.navigationId} :
317+
null;
318+
setTimeout(
319+
() => { this.scheduleNavigation(rawUrlTree, source, state, {replaceUrl: true}); }, 0);
316320
}));
317321
}
318322
}
@@ -341,6 +345,7 @@ export class Router {
341345
validateConfig(config);
342346
this.config = config;
343347
this.navigated = false;
348+
this.lastSuccessfulId = -1;
344349
}
345350

346351
/** @docsNotRequired */
@@ -449,7 +454,7 @@ export class Router {
449454
const urlTree = url instanceof UrlTree ? url : this.parseUrl(url);
450455
const mergedTree = this.urlHandlingStrategy.merge(urlTree, this.rawUrlTree);
451456

452-
return this.scheduleNavigation(mergedTree, 'imperative', extras);
457+
return this.scheduleNavigation(mergedTree, 'imperative', null, extras);
453458
}
454459

455460
/**
@@ -522,8 +527,9 @@ export class Router {
522527
.subscribe(() => {});
523528
}
524529

525-
private scheduleNavigation(rawUrl: UrlTree, source: NavigationSource, extras: NavigationExtras):
526-
Promise<boolean> {
530+
private scheduleNavigation(
531+
rawUrl: UrlTree, source: NavigationTrigger, state: {navigationId: number}|null,
532+
extras: NavigationExtras): Promise<boolean> {
527533
const lastNavigation = this.navigations.value;
528534

529535
// If the user triggers a navigation imperatively (e.g., by using navigateByUrl),
@@ -558,21 +564,22 @@ export class Router {
558564
});
559565

560566
const id = ++this.navigationId;
561-
this.navigations.next({id, source, rawUrl, extras, resolve, reject, promise});
567+
this.navigations.next({id, source, state, rawUrl, extras, resolve, reject, promise});
562568

563569
// Make sure that the error is propagated even though `processNavigations` catch
564570
// handler does not rethrow
565571
return promise.catch((e: any) => Promise.reject(e));
566572
}
567573

568-
private executeScheduledNavigation({id, rawUrl, extras, resolve, reject}: NavigationParams):
569-
void {
574+
private executeScheduledNavigation({id, rawUrl, extras, resolve, reject, source,
575+
state}: NavigationParams): void {
570576
const url = this.urlHandlingStrategy.extract(rawUrl);
571577
const urlTransition = !this.navigated || url.toString() !== this.currentUrlTree.toString();
572578

573579
if ((this.onSameUrlNavigation === 'reload' ? true : urlTransition) &&
574580
this.urlHandlingStrategy.shouldProcessUrl(rawUrl)) {
575-
(this.events as Subject<Event>).next(new NavigationStart(id, this.serializeUrl(url)));
581+
(this.events as Subject<Event>)
582+
.next(new NavigationStart(id, this.serializeUrl(url), source, state));
576583
Promise.resolve()
577584
.then(
578585
(_) => this.runNavigate(
@@ -584,7 +591,8 @@ export class Router {
584591
} else if (
585592
urlTransition && this.rawUrlTree &&
586593
this.urlHandlingStrategy.shouldProcessUrl(this.rawUrlTree)) {
587-
(this.events as Subject<Event>).next(new NavigationStart(id, this.serializeUrl(url)));
594+
(this.events as Subject<Event>)
595+
.next(new NavigationStart(id, this.serializeUrl(url), source, state));
588596
Promise.resolve()
589597
.then(
590598
(_) => this.runNavigate(
@@ -727,9 +735,9 @@ export class Router {
727735
if (!skipLocationChange) {
728736
const path = this.urlSerializer.serialize(this.rawUrlTree);
729737
if (this.location.isCurrentPathEqualTo(path) || replaceUrl) {
730-
this.location.replaceState(path);
738+
this.location.replaceState(path, '', {navigationId: id});
731739
} else {
732-
this.location.go(path);
740+
this.location.go(path, '', {navigationId: id});
733741
}
734742
}
735743

@@ -743,6 +751,7 @@ export class Router {
743751
() => {
744752
if (navigationIsSuccessful) {
745753
this.navigated = true;
754+
this.lastSuccessfulId = id;
746755
(this.events as Subject<Event>)
747756
.next(new NavigationEnd(
748757
id, this.serializeUrl(url), this.serializeUrl(this.currentUrlTree)));
@@ -784,7 +793,8 @@ export class Router {
784793
}
785794

786795
private resetUrlToCurrentUrlTree(): void {
787-
this.location.replaceState(this.urlSerializer.serialize(this.rawUrlTree));
796+
this.location.replaceState(
797+
this.urlSerializer.serialize(this.rawUrlTree), '', {navigationId: this.lastSuccessfulId});
788798
}
789799
}
790800

0 commit comments

Comments
 (0)