feat(router): add a Navigation type available during navigation (#27198)

Provides target URLs, Navigation, and `NavigationExtras` data.

FW-613

PR Close #27198
This commit is contained in:
Jason Aden 2018-11-29 10:07:24 -08:00 committed by Igor Minar
parent 73f6ed9be1
commit d40af0c137
6 changed files with 186 additions and 49 deletions

View File

@ -87,14 +87,14 @@ import {UrlTree} from '../url_tree';
* </a> * </a>
* ``` * ```
* *
* And later the value can be read from the router through `router.getCurrentTransition. * And later the value can be read from the router through `router.getCurrentNavigation.
* For example, to capture the `tracingId` above during the `NavigationStart` event: * For example, to capture the `tracingId` above during the `NavigationStart` event:
* *
* ``` * ```
* // Get NavigationStart events * // Get NavigationStart events
* router.events.pipe(filter(e => e instanceof NavigationStart)).subscribe(e => { * router.events.pipe(filter(e => e instanceof NavigationStart)).subscribe(e => {
* const transition = router.getCurrentTransition(); * const navigation = router.getCurrentNavigation();
* tracingService.trace({id: transition.extras.state}); * tracingService.trace({id: navigation.extras.state.tracingId});
* }); * });
* ``` * ```
* *

View File

@ -70,8 +70,8 @@ export class NavigationStart extends RouterEvent {
navigationTrigger?: 'imperative'|'popstate'|'hashchange'; navigationTrigger?: 'imperative'|'popstate'|'hashchange';
/** /**
* This contains the navigation id that pushed the history record that the router navigates * This reflects the state object that was previously supplied to the pushState call. This is
* back to. This is not null only when the navigation is triggered by a popstate event. * 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 * 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 * clicks on the back button in the browser, a new navigation id will be created. So from
@ -80,8 +80,10 @@ export class NavigationStart extends RouterEvent {
* states * states
* and popstate events. In the latter case you can restore some remembered state (e.g., scroll * and popstate events. In the latter case you can restore some remembered state (e.g., scroll
* position). * position).
*
* See {@link NavigationExtras} for more information.
*/ */
restoredState?: {navigationId: number}|null; restoredState?: {[k: string]: any, navigationId: number}|null;
constructor( constructor(
/** @docsNotRequired */ /** @docsNotRequired */
@ -91,7 +93,7 @@ export class NavigationStart extends RouterEvent {
/** @docsNotRequired */ /** @docsNotRequired */
navigationTrigger: 'imperative'|'popstate'|'hashchange' = 'imperative', navigationTrigger: 'imperative'|'popstate'|'hashchange' = 'imperative',
/** @docsNotRequired */ /** @docsNotRequired */
restoredState: {navigationId: number}|null = null) { restoredState: {[k: string]: any, navigationId: number}|null = null) {
super(id, url); super(id, url);
this.navigationTrigger = navigationTrigger; this.navigationTrigger = navigationTrigger;
this.restoredState = restoredState; this.restoredState = restoredState;

View File

@ -14,7 +14,7 @@ export {RouterOutlet} from './directives/router_outlet';
export {ActivationEnd, ActivationStart, ChildActivationEnd, ChildActivationStart, Event, GuardsCheckEnd, GuardsCheckStart, NavigationCancel, NavigationEnd, NavigationError, NavigationStart, ResolveEnd, ResolveStart, RouteConfigLoadEnd, RouteConfigLoadStart, RouterEvent, RoutesRecognized, Scroll} from './events'; export {ActivationEnd, ActivationStart, ChildActivationEnd, ChildActivationStart, Event, GuardsCheckEnd, GuardsCheckStart, NavigationCancel, NavigationEnd, NavigationError, NavigationStart, ResolveEnd, ResolveStart, RouteConfigLoadEnd, RouteConfigLoadStart, RouterEvent, RoutesRecognized, Scroll} from './events';
export {CanActivate, CanActivateChild, CanDeactivate, CanLoad, Resolve} from './interfaces'; export {CanActivate, CanActivateChild, CanDeactivate, CanLoad, Resolve} from './interfaces';
export {DetachedRouteHandle, RouteReuseStrategy} from './route_reuse_strategy'; export {DetachedRouteHandle, RouteReuseStrategy} from './route_reuse_strategy';
export {NavigationExtras, Router} from './router'; export {Navigation, NavigationExtras, Router} from './router';
export {ROUTES} from './router_config_loader'; export {ROUTES} from './router_config_loader';
export {ExtraOptions, ROUTER_CONFIGURATION, ROUTER_INITIALIZER, RouterModule, provideRoutes} from './router_module'; export {ExtraOptions, ROUTER_CONFIGURATION, ROUTER_INITIALIZER, RouterModule, provideRoutes} from './router_module';
export {ChildrenOutletContexts, OutletContext} from './router_outlet_context'; export {ChildrenOutletContexts, OutletContext} from './router_outlet_context';

View File

@ -147,7 +147,7 @@ export interface NavigationExtras {
replaceUrl?: boolean; replaceUrl?: boolean;
/** /**
* State passed to any navigation. This value will be accessible through the `extras` object * State passed to any navigation. This value will be accessible through the `extras` object
* returned from `router.getCurrentTransition()` while a navigation is executing. Once a * returned from `router.getCurrentNavigation()` while a navigation is executing. Once a
* navigation completes, this value will be written to `history.state` when the `location.go` * navigation completes, this value will be written to `history.state` when the `location.go`
* or `location.replaceState` method is called before activating of this route. Note that * or `location.replaceState` method is called before activating of this route. Note that
* `history.state` will not pass an object equality test because the `navigationId` will be * `history.state` will not pass an object equality test because the `navigationId` will be
@ -181,6 +181,57 @@ function defaultMalformedUriErrorHandler(
return urlSerializer.parse('/'); return urlSerializer.parse('/');
} }
export type RestoredState = {
[k: string]: any; navigationId: number;
};
/**
* @description
*
* Information about any given navigation. This information can be gotten from the router at
* any time using the `router.getCurrentNavigation()` method.
*
* @publicApi
*/
export type Navigation = {
/**
* The ID of the current navigation.
*/
id: number;
/**
* Target URL passed into the {@link Router#navigateByUrl} call before navigation. This is
* the value before the router has parsed or applied redirects to it.
*/
initialUrl: string | UrlTree;
/**
* The initial target URL after being parsed with {@link UrlSerializer.extract()}.
*/
extractedUrl: UrlTree;
/**
* Extracted URL after redirects have been applied. This URL may not be available immediately,
* therefore this property can be `undefined`. It is guaranteed to be set after the
* {@link RoutesRecognized} event fires.
*/
finalUrl?: UrlTree;
/**
* 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
*/
trigger: 'imperative' | 'popstate' | 'hashchange';
/**
* The NavigationExtras used in this navigation. See {@link NavigationExtras} for more info.
*/
extras: NavigationExtras;
/**
* Previously successful Navigation object. Only a single previous Navigation is available,
* therefore this previous Navigation will always have a `null` value for `previousNavigation`.
*/
previousNavigation: Navigation | null;
};
export type NavigationTransition = { export type NavigationTransition = {
id: number, id: number,
currentUrlTree: UrlTree, currentUrlTree: UrlTree,
@ -193,7 +244,7 @@ export type NavigationTransition = {
reject: any, reject: any,
promise: Promise<boolean>, promise: Promise<boolean>,
source: NavigationTrigger, source: NavigationTrigger,
restoredState: {navigationId: number} | null, restoredState: RestoredState | null,
currentSnapshot: RouterStateSnapshot, currentSnapshot: RouterStateSnapshot,
targetSnapshot: RouterStateSnapshot | null, targetSnapshot: RouterStateSnapshot | null,
currentRouterState: RouterState, currentRouterState: RouterState,
@ -242,6 +293,8 @@ export class Router {
private rawUrlTree: UrlTree; private rawUrlTree: UrlTree;
private readonly transitions: BehaviorSubject<NavigationTransition>; private readonly transitions: BehaviorSubject<NavigationTransition>;
private navigations: Observable<NavigationTransition>; private navigations: Observable<NavigationTransition>;
private lastSuccessfulNavigation: Navigation|null = null;
private currentNavigation: Navigation|null = null;
// TODO(issue/24571): remove '!'. // TODO(issue/24571): remove '!'.
private locationSubscription !: Subscription; private locationSubscription !: Subscription;
@ -387,6 +440,20 @@ export class Router {
...t, extractedUrl: this.urlHandlingStrategy.extract(t.rawUrl) ...t, extractedUrl: this.urlHandlingStrategy.extract(t.rawUrl)
} as NavigationTransition)), } as NavigationTransition)),
// Store the Navigation object
tap(t => {
this.currentNavigation = {
id: t.id,
initialUrl: t.currentRawUrl,
extractedUrl: t.extractedUrl,
trigger: t.source,
extras: t.extras,
previousNavigation: this.lastSuccessfulNavigation ?
{...this.lastSuccessfulNavigation, previousNavigation: null} :
null
};
}),
// Using switchMap so we cancel executing navigations when a new one comes in // Using switchMap so we cancel executing navigations when a new one comes in
switchMap(t => { switchMap(t => {
let completed = false; let completed = false;
@ -420,6 +487,15 @@ export class Router {
applyRedirects( applyRedirects(
this.ngModule.injector, this.configLoader, this.urlSerializer, this.ngModule.injector, this.configLoader, this.urlSerializer,
this.config), this.config),
// Update the currentNavigation
tap(t => {
this.currentNavigation = {
...this.currentNavigation !,
finalUrl: t.urlAfterRedirects
};
}),
// Recognize // Recognize
recognize( recognize(
this.rootComponentType, this.config, (url) => this.serializeUrl(url), this.rootComponentType, this.config, (url) => this.serializeUrl(url),
@ -617,6 +693,10 @@ export class Router {
eventsSubject.next(navCancel); eventsSubject.next(navCancel);
t.resolve(false); t.resolve(false);
} }
// currentNavigation should always be reset to null here. If navigation was
// successful, lastSuccessfulTransition will have already been set. Therefore we
// can safely set currentNavigation to null here.
this.currentNavigation = null;
}), }),
catchError((e) => { catchError((e) => {
errored = true; errored = true;
@ -696,9 +776,8 @@ export class Router {
// Navigations coming from Angular router have a navigationId state property. When this // Navigations coming from Angular router have a navigationId state property. When this
// exists, restore the state. // exists, restore the state.
const state = change.state && change.state.navigationId ? change.state : null; const state = change.state && change.state.navigationId ? change.state : null;
setTimeout(() => { setTimeout(
this.scheduleNavigation(rawUrlTree, source, state, null, {replaceUrl: true}); () => { this.scheduleNavigation(rawUrlTree, source, state, {replaceUrl: true}); }, 0);
}, 0);
}); });
} }
} }
@ -706,6 +785,9 @@ export class Router {
/** The current url */ /** The current url */
get url(): string { return this.serializeUrl(this.currentUrlTree); } get url(): string { return this.serializeUrl(this.currentUrlTree); }
/** The current Navigation object if one exists */
getCurrentNavigation(): Navigation|null { return this.currentNavigation; }
/** @internal */ /** @internal */
triggerEvent(event: Event): void { (this.events as Subject<Event>).next(event); } triggerEvent(event: Event): void { (this.events as Subject<Event>).next(event); }
@ -849,7 +931,7 @@ export class Router {
const urlTree = isUrlTree(url) ? url : this.parseUrl(url); const urlTree = isUrlTree(url) ? 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', null, extras.state || null, extras); return this.scheduleNavigation(mergedTree, 'imperative', null, extras);
} }
/** /**
@ -929,14 +1011,16 @@ export class Router {
(this.events as Subject<Event>) (this.events as Subject<Event>)
.next(new NavigationEnd( .next(new NavigationEnd(
t.id, this.serializeUrl(t.extractedUrl), this.serializeUrl(this.currentUrlTree))); t.id, this.serializeUrl(t.extractedUrl), this.serializeUrl(this.currentUrlTree)));
this.lastSuccessfulNavigation = this.currentNavigation;
this.currentNavigation = null;
t.resolve(true); t.resolve(true);
}, },
e => { this.console.warn(`Unhandled Navigation Error: `); }); e => { this.console.warn(`Unhandled Navigation Error: `); });
} }
private scheduleNavigation( private scheduleNavigation(
rawUrl: UrlTree, source: NavigationTrigger, restoredState: {navigationId: number}|null, rawUrl: UrlTree, source: NavigationTrigger, restoredState: RestoredState|null,
futureState: {[key: string]: any}|null, extras: NavigationExtras): Promise<boolean> { extras: NavigationExtras): Promise<boolean> {
const lastNavigation = this.getTransition(); const lastNavigation = this.getTransition();
// If the user triggers a navigation imperatively (e.g., by using navigateByUrl), // If the user triggers a navigation imperatively (e.g., by using navigateByUrl),
// and that navigation results in 'replaceState' that leads to the same URL, // and that navigation results in 'replaceState' that leads to the same URL,
@ -990,6 +1074,7 @@ export class Router {
const path = this.urlSerializer.serialize(url); const path = this.urlSerializer.serialize(url);
state = state || {}; state = state || {};
if (this.location.isCurrentPathEqualTo(path) || replaceUrl) { if (this.location.isCurrentPathEqualTo(path) || replaceUrl) {
// TODO(jasonaden): Remove first `navigationId` and rely on `ng` namespace.
this.location.replaceState(path, '', {...state, navigationId: id}); this.location.replaceState(path, '', {...state, navigationId: id});
} else { } else {
this.location.go(path, '', {...state, navigationId: id}); this.location.go(path, '', {...state, navigationId: id});

View File

@ -13,7 +13,7 @@ import {ComponentFixture, TestBed, fakeAsync, inject, tick} from '@angular/core/
import {By} from '@angular/platform-browser/src/dom/debug/by'; import {By} from '@angular/platform-browser/src/dom/debug/by';
import {expect} from '@angular/platform-browser/testing/src/matchers'; import {expect} from '@angular/platform-browser/testing/src/matchers';
import {fixmeIvy} from '@angular/private/testing'; import {fixmeIvy} from '@angular/private/testing';
import {ActivatedRoute, ActivatedRouteSnapshot, ActivationEnd, ActivationStart, CanActivate, CanDeactivate, ChildActivationEnd, ChildActivationStart, DetachedRouteHandle, Event, GuardsCheckEnd, GuardsCheckStart, NavigationCancel, NavigationEnd, NavigationError, NavigationStart, PRIMARY_OUTLET, ParamMap, Params, PreloadAllModules, PreloadingStrategy, Resolve, ResolveEnd, ResolveStart, RouteConfigLoadEnd, RouteConfigLoadStart, RouteReuseStrategy, Router, RouterEvent, RouterModule, RouterPreloader, RouterStateSnapshot, RoutesRecognized, RunGuardsAndResolvers, UrlHandlingStrategy, UrlSegmentGroup, UrlSerializer, UrlTree} from '@angular/router'; import {ActivatedRoute, ActivatedRouteSnapshot, ActivationEnd, ActivationStart, CanActivate, CanDeactivate, ChildActivationEnd, ChildActivationStart, DetachedRouteHandle, Event, GuardsCheckEnd, GuardsCheckStart, Navigation, NavigationCancel, NavigationEnd, NavigationError, NavigationStart, PRIMARY_OUTLET, ParamMap, Params, PreloadAllModules, PreloadingStrategy, Resolve, ResolveEnd, ResolveStart, RouteConfigLoadEnd, RouteConfigLoadStart, RouteReuseStrategy, Router, RouterEvent, RouterModule, RouterPreloader, RouterStateSnapshot, RoutesRecognized, RunGuardsAndResolvers, UrlHandlingStrategy, UrlSegmentGroup, UrlSerializer, UrlTree} from '@angular/router';
import {Observable, Observer, Subscription, of } from 'rxjs'; import {Observable, Observer, Subscription, of } from 'rxjs';
import {filter, first, map, tap} from 'rxjs/operators'; import {filter, first, map, tap} from 'rxjs/operators';
@ -142,21 +142,22 @@ describe('Integration', () => {
]); ]);
const fixture = createRoot(router, RootCmp); const fixture = createRoot(router, RootCmp);
// let transition: NavigationTransitionx = null !; let navigation: Navigation = null !;
// router.events.subscribe(e => { router.events.subscribe(e => {
// if (e instanceof NavigationStart) { if (e instanceof NavigationStart) {
// transition = router.getCurrentTransition(); navigation = router.getCurrentNavigation() !;
// } }
// }); });
router.navigateByUrl('/simple', {state: {foo: 'bar'}}); router.navigateByUrl('/simple', {state: {foo: 'bar'}});
tick(); tick();
const history = (location as any)._history; const history = (location as any)._history;
expect(history[history.length - 1].state.foo).toBe('bar'); expect(history[history.length - 1].state.foo).toBe('bar');
expect(history[history.length - 1].state).toEqual({foo: 'bar', navigationId: history.length}); expect(history[history.length - 1].state)
// expect(transition.state).toBeDefined(); .toEqual({foo: 'bar', navigationId: history.length});
// expect(transition.state).toEqual({foo: 'bar'}); expect(navigation.extras.state).toBeDefined();
expect(navigation.extras.state).toEqual({foo: 'bar'});
}))); })));
it('should not pollute browser history when replaceUrl is set to true', it('should not pollute browser history when replaceUrl is set to true',
@ -1887,8 +1888,7 @@ describe('Integration', () => {
path: 'team/:id', path: 'team/:id',
component: TeamCmp, component: TeamCmp,
children: [ children: [
{path: 'link', component: LinkWithState}, {path: 'link', component: LinkWithState}, {path: 'simple', component: SimpleCmp}
{path: 'simple', component: SimpleCmp}
] ]
}]); }]);
@ -1906,7 +1906,8 @@ describe('Integration', () => {
const history = (location as any)._history; const history = (location as any)._history;
expect(history[history.length - 1].state.foo).toBe('bar'); expect(history[history.length - 1].state.foo).toBe('bar');
expect(history[history.length - 1].state).toEqual({foo: 'bar', navigationId: history.length}); expect(history[history.length - 1].state)
.toEqual({foo: 'bar', navigationId: history.length});
}))); })));
}); });
@ -1924,6 +1925,33 @@ describe('Integration', () => {
expect(location.path()).toEqual('/team/22'); expect(location.path()).toEqual('/team/22');
}))); })));
it('should update Navigation object after redirects are applied',
fakeAsync(inject([Router, Location], (router: Router, location: Location) => {
const fixture = createRoot(router, RootCmp);
let initialUrl, afterRedirectUrl;
router.resetConfig([
{path: 'old/team/:id', redirectTo: 'team/:id'}, {path: 'team/:id', component: TeamCmp}
]);
router.events.subscribe(e => {
if (e instanceof NavigationStart) {
const navigation = router.getCurrentNavigation();
initialUrl = navigation && navigation.finalUrl;
}
if (e instanceof RoutesRecognized) {
const navigation = router.getCurrentNavigation();
afterRedirectUrl = navigation && navigation.finalUrl;
}
});
router.navigateByUrl('old/team/22');
advance(fixture);
expect(initialUrl).toBeUndefined();
expect(router.serializeUrl(afterRedirectUrl as any)).toBe('/team/22');
})));
it('should not break the back button when trigger by location change', it('should not break the back button when trigger by location change',
fakeAsync(inject([Router, Location], (router: Router, location: Location) => { fakeAsync(inject([Router, Location], (router: Router, location: Location) => {
const fixture = TestBed.createComponent(RootCmp); const fixture = TestBed.createComponent(RootCmp);

View File

@ -149,6 +149,16 @@ export declare type LoadChildren = string | LoadChildrenCallback;
export declare type LoadChildrenCallback = () => Type<any> | NgModuleFactory<any> | Promise<Type<any>> | Observable<Type<any>>; export declare type LoadChildrenCallback = () => Type<any> | NgModuleFactory<any> | Promise<Type<any>> | Observable<Type<any>>;
export declare type Navigation = {
id: number;
initialUrl: string | UrlTree;
extractedUrl: UrlTree;
finalUrl?: UrlTree;
trigger: 'imperative' | 'popstate' | 'hashchange';
extras: NavigationExtras;
previousNavigation: Navigation | null;
};
export declare class NavigationCancel extends RouterEvent { export declare class NavigationCancel extends RouterEvent {
reason: string; reason: string;
constructor( constructor(
@ -185,11 +195,15 @@ export interface NavigationExtras {
relativeTo?: ActivatedRoute | null; relativeTo?: ActivatedRoute | null;
replaceUrl?: boolean; replaceUrl?: boolean;
skipLocationChange?: boolean; skipLocationChange?: boolean;
state?: {
[k: string]: any;
};
} }
export declare class NavigationStart extends RouterEvent { export declare class NavigationStart extends RouterEvent {
navigationTrigger?: 'imperative' | 'popstate' | 'hashchange'; navigationTrigger?: 'imperative' | 'popstate' | 'hashchange';
restoredState?: { restoredState?: {
[k: string]: any;
navigationId: number; navigationId: number;
} | null; } | null;
constructor( constructor(
@ -197,6 +211,7 @@ export declare class NavigationStart extends RouterEvent {
url: string, url: string,
navigationTrigger?: 'imperative' | 'popstate' | 'hashchange', navigationTrigger?: 'imperative' | 'popstate' | 'hashchange',
restoredState?: { restoredState?: {
[k: string]: any;
navigationId: number; navigationId: number;
} | null); } | null);
toString(): string; toString(): string;
@ -316,6 +331,7 @@ export declare class Router {
constructor(rootComponentType: Type<any> | null, urlSerializer: UrlSerializer, rootContexts: ChildrenOutletContexts, location: Location, injector: Injector, loader: NgModuleFactoryLoader, compiler: Compiler, config: Routes); constructor(rootComponentType: Type<any> | null, urlSerializer: UrlSerializer, rootContexts: ChildrenOutletContexts, location: Location, injector: Injector, loader: NgModuleFactoryLoader, compiler: Compiler, config: Routes);
createUrlTree(commands: any[], navigationExtras?: NavigationExtras): UrlTree; createUrlTree(commands: any[], navigationExtras?: NavigationExtras): UrlTree;
dispose(): void; dispose(): void;
getCurrentNavigation(): Navigation | null;
initialNavigation(): void; initialNavigation(): void;
isActive(url: string | UrlTree, exact: boolean): boolean; isActive(url: string | UrlTree, exact: boolean): boolean;
navigate(commands: any[], extras?: NavigationExtras): Promise<boolean>; navigate(commands: any[], extras?: NavigationExtras): Promise<boolean>;
@ -358,6 +374,9 @@ export declare class RouterLink {
replaceUrl: boolean; replaceUrl: boolean;
routerLink: any[] | string; routerLink: any[] | string;
skipLocationChange: boolean; skipLocationChange: boolean;
state?: {
[k: string]: any;
};
readonly urlTree: UrlTree; readonly urlTree: UrlTree;
constructor(router: Router, route: ActivatedRoute, tabIndex: string, renderer: Renderer2, el: ElementRef); constructor(router: Router, route: ActivatedRoute, tabIndex: string, renderer: Renderer2, el: ElementRef);
onClick(): boolean; onClick(): boolean;
@ -389,6 +408,9 @@ export declare class RouterLinkWithHref implements OnChanges, OnDestroy {
replaceUrl: boolean; replaceUrl: boolean;
routerLink: any[] | string; routerLink: any[] | string;
skipLocationChange: boolean; skipLocationChange: boolean;
state?: {
[k: string]: any;
};
target: string; target: string;
readonly urlTree: UrlTree; readonly urlTree: UrlTree;
constructor(router: Router, route: ActivatedRoute, locationStrategy: LocationStrategy); constructor(router: Router, route: ActivatedRoute, locationStrategy: LocationStrategy);