From d40af0c137fd43c415ae5cb566edea43ef0b9e01 Mon Sep 17 00:00:00 2001 From: Jason Aden Date: Thu, 29 Nov 2018 10:07:24 -0800 Subject: [PATCH] feat(router): add a Navigation type available during navigation (#27198) Provides target URLs, Navigation, and `NavigationExtras` data. FW-613 PR Close #27198 --- packages/router/src/directives/router_link.ts | 6 +- packages/router/src/events.ts | 10 +- packages/router/src/index.ts | 2 +- packages/router/src/router.ts | 101 ++++++++++++++++-- packages/router/test/integration.spec.ts | 94 ++++++++++------ tools/public_api_guard/router/router.d.ts | 22 ++++ 6 files changed, 186 insertions(+), 49 deletions(-) diff --git a/packages/router/src/directives/router_link.ts b/packages/router/src/directives/router_link.ts index 8b68e093f8..53fbdd040e 100644 --- a/packages/router/src/directives/router_link.ts +++ b/packages/router/src/directives/router_link.ts @@ -87,14 +87,14 @@ import {UrlTree} from '../url_tree'; * * ``` * - * 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: * * ``` * // Get NavigationStart events * router.events.pipe(filter(e => e instanceof NavigationStart)).subscribe(e => { - * const transition = router.getCurrentTransition(); - * tracingService.trace({id: transition.extras.state}); + * const navigation = router.getCurrentNavigation(); + * tracingService.trace({id: navigation.extras.state.tracingId}); * }); * ``` * diff --git a/packages/router/src/events.ts b/packages/router/src/events.ts index fb729882f2..178594672b 100644 --- a/packages/router/src/events.ts +++ b/packages/router/src/events.ts @@ -70,8 +70,8 @@ export class NavigationStart extends RouterEvent { 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. + * This reflects the state object that was previously supplied to the pushState call. 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 @@ -80,8 +80,10 @@ export class NavigationStart extends RouterEvent { * states * and popstate events. In the latter case you can restore some remembered state (e.g., scroll * position). + * + * See {@link NavigationExtras} for more information. */ - restoredState?: {navigationId: number}|null; + restoredState?: {[k: string]: any, navigationId: number}|null; constructor( /** @docsNotRequired */ @@ -91,7 +93,7 @@ export class NavigationStart extends RouterEvent { /** @docsNotRequired */ navigationTrigger: 'imperative'|'popstate'|'hashchange' = 'imperative', /** @docsNotRequired */ - restoredState: {navigationId: number}|null = null) { + restoredState: {[k: string]: any, navigationId: number}|null = null) { super(id, url); this.navigationTrigger = navigationTrigger; this.restoredState = restoredState; diff --git a/packages/router/src/index.ts b/packages/router/src/index.ts index 19cef3be6b..17abc4a990 100644 --- a/packages/router/src/index.ts +++ b/packages/router/src/index.ts @@ -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 {CanActivate, CanActivateChild, CanDeactivate, CanLoad, Resolve} from './interfaces'; 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 {ExtraOptions, ROUTER_CONFIGURATION, ROUTER_INITIALIZER, RouterModule, provideRoutes} from './router_module'; export {ChildrenOutletContexts, OutletContext} from './router_outlet_context'; diff --git a/packages/router/src/router.ts b/packages/router/src/router.ts index e6610ed48f..d398aa6fc2 100644 --- a/packages/router/src/router.ts +++ b/packages/router/src/router.ts @@ -147,7 +147,7 @@ export interface NavigationExtras { replaceUrl?: boolean; /** * 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` * 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 @@ -181,6 +181,57 @@ function defaultMalformedUriErrorHandler( 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 = { id: number, currentUrlTree: UrlTree, @@ -193,7 +244,7 @@ export type NavigationTransition = { reject: any, promise: Promise, source: NavigationTrigger, - restoredState: {navigationId: number} | null, + restoredState: RestoredState | null, currentSnapshot: RouterStateSnapshot, targetSnapshot: RouterStateSnapshot | null, currentRouterState: RouterState, @@ -242,6 +293,8 @@ export class Router { private rawUrlTree: UrlTree; private readonly transitions: BehaviorSubject; private navigations: Observable; + private lastSuccessfulNavigation: Navigation|null = null; + private currentNavigation: Navigation|null = null; // TODO(issue/24571): remove '!'. private locationSubscription !: Subscription; @@ -387,6 +440,20 @@ export class Router { ...t, extractedUrl: this.urlHandlingStrategy.extract(t.rawUrl) } 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 switchMap(t => { let completed = false; @@ -420,6 +487,15 @@ export class Router { applyRedirects( this.ngModule.injector, this.configLoader, this.urlSerializer, this.config), + + // Update the currentNavigation + tap(t => { + this.currentNavigation = { + ...this.currentNavigation !, + finalUrl: t.urlAfterRedirects + }; + }), + // Recognize recognize( this.rootComponentType, this.config, (url) => this.serializeUrl(url), @@ -617,6 +693,10 @@ export class Router { eventsSubject.next(navCancel); 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) => { errored = true; @@ -696,9 +776,8 @@ export class Router { // Navigations coming from Angular router have a navigationId state property. When this // exists, restore the state. const state = change.state && change.state.navigationId ? change.state : null; - setTimeout(() => { - this.scheduleNavigation(rawUrlTree, source, state, null, {replaceUrl: true}); - }, 0); + setTimeout( + () => { this.scheduleNavigation(rawUrlTree, source, state, {replaceUrl: true}); }, 0); }); } } @@ -706,6 +785,9 @@ export class Router { /** The current url */ get url(): string { return this.serializeUrl(this.currentUrlTree); } + /** The current Navigation object if one exists */ + getCurrentNavigation(): Navigation|null { return this.currentNavigation; } + /** @internal */ triggerEvent(event: Event): void { (this.events as Subject).next(event); } @@ -849,7 +931,7 @@ export class Router { const urlTree = isUrlTree(url) ? url : this.parseUrl(url); 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) .next(new NavigationEnd( t.id, this.serializeUrl(t.extractedUrl), this.serializeUrl(this.currentUrlTree))); + this.lastSuccessfulNavigation = this.currentNavigation; + this.currentNavigation = null; t.resolve(true); }, e => { this.console.warn(`Unhandled Navigation Error: `); }); } private scheduleNavigation( - rawUrl: UrlTree, source: NavigationTrigger, restoredState: {navigationId: number}|null, - futureState: {[key: string]: any}|null, extras: NavigationExtras): Promise { + rawUrl: UrlTree, source: NavigationTrigger, restoredState: RestoredState|null, + extras: NavigationExtras): Promise { const lastNavigation = this.getTransition(); // If the user triggers a navigation imperatively (e.g., by using navigateByUrl), // 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); state = state || {}; if (this.location.isCurrentPathEqualTo(path) || replaceUrl) { + // TODO(jasonaden): Remove first `navigationId` and rely on `ng` namespace. this.location.replaceState(path, '', {...state, navigationId: id}); } else { this.location.go(path, '', {...state, navigationId: id}); diff --git a/packages/router/test/integration.spec.ts b/packages/router/test/integration.spec.ts index 2cd8b2bd3f..3c1dfaf0c0 100644 --- a/packages/router/test/integration.spec.ts +++ b/packages/router/test/integration.spec.ts @@ -13,7 +13,7 @@ import {ComponentFixture, TestBed, fakeAsync, inject, tick} from '@angular/core/ import {By} from '@angular/platform-browser/src/dom/debug/by'; import {expect} from '@angular/platform-browser/testing/src/matchers'; 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 {filter, first, map, tap} from 'rxjs/operators'; @@ -142,21 +142,22 @@ describe('Integration', () => { ]); const fixture = createRoot(router, RootCmp); - // let transition: NavigationTransitionx = null !; - // router.events.subscribe(e => { - // if (e instanceof NavigationStart) { - // transition = router.getCurrentTransition(); - // } - // }); + let navigation: Navigation = null !; + router.events.subscribe(e => { + if (e instanceof NavigationStart) { + navigation = router.getCurrentNavigation() !; + } + }); router.navigateByUrl('/simple', {state: {foo: 'bar'}}); tick(); const history = (location as any)._history; expect(history[history.length - 1].state.foo).toBe('bar'); - expect(history[history.length - 1].state).toEqual({foo: 'bar', navigationId: history.length}); - // expect(transition.state).toBeDefined(); - // expect(transition.state).toEqual({foo: 'bar'}); + expect(history[history.length - 1].state) + .toEqual({foo: 'bar', navigationId: history.length}); + expect(navigation.extras.state).toBeDefined(); + expect(navigation.extras.state).toEqual({foo: 'bar'}); }))); it('should not pollute browser history when replaceUrl is set to true', @@ -1879,35 +1880,35 @@ describe('Integration', () => { expect(location.path()).toEqual('/team/22/simple?q=1#f'); }))); - it('should support history state', - fakeAsync(inject([Router, Location], (router: Router, location: SpyLocation) => { - const fixture = createRoot(router, RootCmp); + it('should support history state', + fakeAsync(inject([Router, Location], (router: Router, location: SpyLocation) => { + const fixture = createRoot(router, RootCmp); - router.resetConfig([{ - path: 'team/:id', - component: TeamCmp, - children: [ - {path: 'link', component: LinkWithState}, - {path: 'simple', component: SimpleCmp} - ] - }]); + router.resetConfig([{ + path: 'team/:id', + component: TeamCmp, + children: [ + {path: 'link', component: LinkWithState}, {path: 'simple', component: SimpleCmp} + ] + }]); - router.navigateByUrl('/team/22/link'); - advance(fixture); + router.navigateByUrl('/team/22/link'); + advance(fixture); - const native = fixture.nativeElement.querySelector('a'); - expect(native.getAttribute('href')).toEqual('/team/22/simple'); - native.click(); - advance(fixture); + const native = fixture.nativeElement.querySelector('a'); + expect(native.getAttribute('href')).toEqual('/team/22/simple'); + native.click(); + advance(fixture); - expect(fixture.nativeElement).toHaveText('team 22 [ simple, right: ]'); + expect(fixture.nativeElement).toHaveText('team 22 [ simple, right: ]'); - // Check the history entry - const history = (location as any)._history; + // Check the history entry + const history = (location as any)._history; - 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.foo).toBe('bar'); + expect(history[history.length - 1].state) + .toEqual({foo: 'bar', navigationId: history.length}); + }))); }); describe('redirects', () => { @@ -1924,6 +1925,33 @@ describe('Integration', () => { 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', fakeAsync(inject([Router, Location], (router: Router, location: Location) => { const fixture = TestBed.createComponent(RootCmp); diff --git a/tools/public_api_guard/router/router.d.ts b/tools/public_api_guard/router/router.d.ts index 10ef4c557d..6678230347 100644 --- a/tools/public_api_guard/router/router.d.ts +++ b/tools/public_api_guard/router/router.d.ts @@ -149,6 +149,16 @@ export declare type LoadChildren = string | LoadChildrenCallback; export declare type LoadChildrenCallback = () => Type | NgModuleFactory | Promise> | Observable>; +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 { reason: string; constructor( @@ -185,11 +195,15 @@ export interface NavigationExtras { relativeTo?: ActivatedRoute | null; replaceUrl?: boolean; skipLocationChange?: boolean; + state?: { + [k: string]: any; + }; } export declare class NavigationStart extends RouterEvent { navigationTrigger?: 'imperative' | 'popstate' | 'hashchange'; restoredState?: { + [k: string]: any; navigationId: number; } | null; constructor( @@ -197,6 +211,7 @@ export declare class NavigationStart extends RouterEvent { url: string, navigationTrigger?: 'imperative' | 'popstate' | 'hashchange', restoredState?: { + [k: string]: any; navigationId: number; } | null); toString(): string; @@ -316,6 +331,7 @@ export declare class Router { constructor(rootComponentType: Type | null, urlSerializer: UrlSerializer, rootContexts: ChildrenOutletContexts, location: Location, injector: Injector, loader: NgModuleFactoryLoader, compiler: Compiler, config: Routes); createUrlTree(commands: any[], navigationExtras?: NavigationExtras): UrlTree; dispose(): void; + getCurrentNavigation(): Navigation | null; initialNavigation(): void; isActive(url: string | UrlTree, exact: boolean): boolean; navigate(commands: any[], extras?: NavigationExtras): Promise; @@ -358,6 +374,9 @@ export declare class RouterLink { replaceUrl: boolean; routerLink: any[] | string; skipLocationChange: boolean; + state?: { + [k: string]: any; + }; readonly urlTree: UrlTree; constructor(router: Router, route: ActivatedRoute, tabIndex: string, renderer: Renderer2, el: ElementRef); onClick(): boolean; @@ -389,6 +408,9 @@ export declare class RouterLinkWithHref implements OnChanges, OnDestroy { replaceUrl: boolean; routerLink: any[] | string; skipLocationChange: boolean; + state?: { + [k: string]: any; + }; target: string; readonly urlTree: UrlTree; constructor(router: Router, route: ActivatedRoute, locationStrategy: LocationStrategy);