Navigation Menu

Skip to content

Commit

Permalink
feat(router): add navigationSource and restoredState to NavigationSta…
Browse files Browse the repository at this point in the history
…rt 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).
  • Loading branch information
vsavkin authored and jasonaden committed Jan 26, 2018
1 parent 95fbb7d commit af2c0b3
Show file tree
Hide file tree
Showing 10 changed files with 183 additions and 40 deletions.
10 changes: 6 additions & 4 deletions packages/common/src/location/location.ts
Expand Up @@ -14,6 +14,7 @@ import {LocationStrategy} from './location_strategy';
/** @experimental */
export interface PopStateEvent {
pop?: boolean;
state?: any;
type?: string;
url?: string;
}
Expand Down Expand Up @@ -56,6 +57,7 @@ export class Location {
this._subject.emit({
'url': this.path(true),
'pop': true,
'state': ev.state,
'type': ev.type,
});
});
Expand Down Expand Up @@ -103,16 +105,16 @@ export class Location {
* Changes the browsers URL to the normalized version of the given URL, and pushes a
* new item onto the platform's history.
*/
go(path: string, query: string = ''): void {
this._platformStrategy.pushState(null, '', path, query);
go(path: string, query: string = '', state: any = null): void {
this._platformStrategy.pushState(state, '', path, query);
}

/**
* Changes the browsers URL to the normalized version of the given URL, and replaces
* the top item on the platform's history stack.
*/
replaceState(path: string, query: string = ''): void {
this._platformStrategy.replaceState(null, '', path, query);
replaceState(path: string, query: string = '', state: any = null): void {
this._platformStrategy.replaceState(state, '', path, query);
}

/**
Expand Down
5 changes: 4 additions & 1 deletion packages/common/src/location/platform_location.ts
Expand Up @@ -58,7 +58,10 @@ export const LOCATION_INITIALIZED = new InjectionToken<Promise<any>>('Location I
*
* @experimental
*/
export interface LocationChangeEvent { type: string; }
export interface LocationChangeEvent {
type: string;
state: any;
}

/**
* @experimental
Expand Down
22 changes: 10 additions & 12 deletions packages/common/testing/src/location_mock.ts
Expand Up @@ -19,7 +19,7 @@ import {ISubscription} from 'rxjs/Subscription';
@Injectable()
export class SpyLocation implements Location {
urlChanges: string[] = [];
private _history: LocationState[] = [new LocationState('', '')];
private _history: LocationState[] = [new LocationState('', '', null)];
private _historyIndex: number = 0;
/** @internal */
_subject: EventEmitter<any> = new EventEmitter();
Expand All @@ -34,6 +34,8 @@ export class SpyLocation implements Location {

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

private state(): string { return this._history[this._historyIndex].state; }

isCurrentPathEqualTo(path: string, query: string = ''): boolean {
const givenPath = path.endsWith('/') ? path.substring(0, path.length - 1) : path;
const currPath =
Expand All @@ -60,13 +62,13 @@ export class SpyLocation implements Location {
return this._baseHref + url;
}

go(path: string, query: string = '') {
go(path: string, query: string = '', state: any = null) {
path = this.prepareExternalUrl(path);

if (this._historyIndex > 0) {
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;

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

replaceState(path: string, query: string = '') {
replaceState(path: string, query: string = '', state: any = null) {
path = this.prepareExternalUrl(path);

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

history.path = path;
history.query = query;
history.state = state;

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

back() {
if (this._historyIndex > 0) {
this._historyIndex--;
this._subject.emit({'url': this.path(), 'pop': true});
this._subject.emit({'url': this.path(), 'state': this.state(), 'pop': true});
}
}

Expand All @@ -118,10 +121,5 @@ export class SpyLocation implements Location {
}

class LocationState {
path: string;
query: string;
constructor(path: string, query: string) {
this.path = path;
this.query = query;
}
constructor(public path: string, public query: string, public state: any) {}
}
5 changes: 3 additions & 2 deletions packages/platform-server/src/location.ts
Expand Up @@ -63,8 +63,9 @@ export class ServerPlatformLocation implements PlatformLocation {
}
(this as{hash: string}).hash = value;
const newUrl = this.url;
scheduleMicroTask(
() => this._hashUpdate.next({ type: 'hashchange', oldUrl, newUrl } as LocationChangeEvent));
scheduleMicroTask(() => this._hashUpdate.next({
type: 'hashchange', state: null, oldUrl, newUrl
} as LocationChangeEvent));
}

replaceState(state: any, title: string, newUrl: string): void {
Expand Down
47 changes: 47 additions & 0 deletions packages/router/src/events.ts
Expand Up @@ -9,6 +9,16 @@
import {Route} from './config';
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
Expand Down Expand Up @@ -42,6 +52,43 @@ export class RouterEvent {
* @stable
*/
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 */
toString(): string { return `NavigationStart(id: ${this.id}, url: '${this.url}')`; }
}
Expand Down
44 changes: 27 additions & 17 deletions packages/router/src/router.ts
Expand Up @@ -21,7 +21,7 @@ import {applyRedirects} from './apply_redirects';
import {LoadedRouterConfig, QueryParamsHandling, Route, Routes, validateConfig} from './config';
import {createRouterState} from './create_router_state';
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 {recognize} from './recognize';
import {DefaultRouteReuseStrategy, DetachedRouteHandleInternal, RouteReuseStrategy} from './route_reuse_strategy';
Expand Down Expand Up @@ -164,16 +164,15 @@ function defaultErrorHandler(error: any): any {
throw error;
}

type NavigationSource = 'imperative' | 'popstate' | 'hashchange';

type NavigationParams = {
id: number,
rawUrl: UrlTree,
extras: NavigationExtras,
resolve: any,
reject: any,
promise: Promise<boolean>,
source: NavigationSource,
source: NavigationTrigger,
state: {navigationId: number} | null
};

/**
Expand Down Expand Up @@ -223,6 +222,7 @@ export class Router {
* Indicates if at least one navigation happened.
*/
navigated: boolean = false;
private lastSuccessfulId: number = -1;

/**
* Used by RouterModule. This allows us to
Expand Down Expand Up @@ -311,8 +311,12 @@ export class Router {
if (!this.locationSubscription) {
this.locationSubscription = <any>this.location.subscribe(Zone.current.wrap((change: any) => {
const rawUrlTree = this.urlSerializer.parse(change['url']);
const source: NavigationSource = change['type'] === 'popstate' ? 'popstate' : 'hashchange';
setTimeout(() => { this.scheduleNavigation(rawUrlTree, source, {replaceUrl: true}); }, 0);
const source: NavigationTrigger = change['type'] === 'popstate' ? 'popstate' : 'hashchange';
const state = change.state && change.state.navigationId ?
{navigationId: change.state.navigationId} :
null;
setTimeout(
() => { this.scheduleNavigation(rawUrlTree, source, state, {replaceUrl: true}); }, 0);
}));
}
}
Expand Down Expand Up @@ -341,6 +345,7 @@ export class Router {
validateConfig(config);
this.config = config;
this.navigated = false;
this.lastSuccessfulId = -1;
}

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

return this.scheduleNavigation(mergedTree, 'imperative', extras);
return this.scheduleNavigation(mergedTree, 'imperative', null, extras);
}

/**
Expand Down Expand Up @@ -522,8 +527,9 @@ export class Router {
.subscribe(() => {});
}

private scheduleNavigation(rawUrl: UrlTree, source: NavigationSource, extras: NavigationExtras):
Promise<boolean> {
private scheduleNavigation(
rawUrl: UrlTree, source: NavigationTrigger, state: {navigationId: number}|null,
extras: NavigationExtras): Promise<boolean> {
const lastNavigation = this.navigations.value;

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

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
// handler does not rethrow
return promise.catch((e: any) => Promise.reject(e));
}

private executeScheduledNavigation({id, rawUrl, extras, resolve, reject}: NavigationParams):
void {
private executeScheduledNavigation({id, rawUrl, extras, resolve, reject, source,
state}: NavigationParams): void {
const url = this.urlHandlingStrategy.extract(rawUrl);
const urlTransition = !this.navigated || url.toString() !== this.currentUrlTree.toString();

if ((this.onSameUrlNavigation === 'reload' ? true : urlTransition) &&
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()
.then(
(_) => this.runNavigate(
Expand All @@ -584,7 +591,8 @@ export class Router {
} else if (
urlTransition && 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()
.then(
(_) => this.runNavigate(
Expand Down Expand Up @@ -727,9 +735,9 @@ export class Router {
if (!skipLocationChange) {
const path = this.urlSerializer.serialize(this.rawUrlTree);
if (this.location.isCurrentPathEqualTo(path) || replaceUrl) {
this.location.replaceState(path);
this.location.replaceState(path, '', {navigationId: id});
} else {
this.location.go(path);
this.location.go(path, '', {navigationId: id});
}
}

Expand All @@ -743,6 +751,7 @@ export class Router {
() => {
if (navigationIsSuccessful) {
this.navigated = true;
this.lastSuccessfulId = id;
(this.events as Subject<Event>)
.next(new NavigationEnd(
id, this.serializeUrl(url), this.serializeUrl(this.currentUrlTree)));
Expand Down Expand Up @@ -784,7 +793,8 @@ export class Router {
}

private resetUrlToCurrentUrlTree(): void {
this.location.replaceState(this.urlSerializer.serialize(this.rawUrlTree));
this.location.replaceState(
this.urlSerializer.serialize(this.rawUrlTree), '', {navigationId: this.lastSuccessfulId});
}
}

Expand Down

0 comments on commit af2c0b3

Please sign in to comment.