Skip to content

Commit d40af0c

Browse files
jasonadenIgorMinar
authored andcommitted
feat(router): add a Navigation type available during navigation (angular#27198)
Provides target URLs, Navigation, and `NavigationExtras` data. FW-613 PR Close angular#27198
1 parent 73f6ed9 commit d40af0c

File tree

6 files changed

+186
-49
lines changed

6 files changed

+186
-49
lines changed

packages/router/src/directives/router_link.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,14 +87,14 @@ import {UrlTree} from '../url_tree';
8787
* </a>
8888
* ```
8989
*
90-
* And later the value can be read from the router through `router.getCurrentTransition.
90+
* And later the value can be read from the router through `router.getCurrentNavigation.
9191
* For example, to capture the `tracingId` above during the `NavigationStart` event:
9292
*
9393
* ```
9494
* // Get NavigationStart events
9595
* router.events.pipe(filter(e => e instanceof NavigationStart)).subscribe(e => {
96-
* const transition = router.getCurrentTransition();
97-
* tracingService.trace({id: transition.extras.state});
96+
* const navigation = router.getCurrentNavigation();
97+
* tracingService.trace({id: navigation.extras.state.tracingId});
9898
* });
9999
* ```
100100
*

packages/router/src/events.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,8 @@ export class NavigationStart extends RouterEvent {
7070
navigationTrigger?: 'imperative'|'popstate'|'hashchange';
7171

7272
/**
73-
* This contains the navigation id that pushed the history record that the router navigates
74-
* back to. This is not null only when the navigation is triggered by a popstate event.
73+
* This reflects the state object that was previously supplied to the pushState call. This is
74+
* not null only when the navigation is triggered by a popstate event.
7575
*
7676
* The router assigns a navigationId to every router transition/navigation. Even when the user
7777
* 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 {
8080
* states
8181
* and popstate events. In the latter case you can restore some remembered state (e.g., scroll
8282
* position).
83+
*
84+
* See {@link NavigationExtras} for more information.
8385
*/
84-
restoredState?: {navigationId: number}|null;
86+
restoredState?: {[k: string]: any, navigationId: number}|null;
8587

8688
constructor(
8789
/** @docsNotRequired */
@@ -91,7 +93,7 @@ export class NavigationStart extends RouterEvent {
9193
/** @docsNotRequired */
9294
navigationTrigger: 'imperative'|'popstate'|'hashchange' = 'imperative',
9395
/** @docsNotRequired */
94-
restoredState: {navigationId: number}|null = null) {
96+
restoredState: {[k: string]: any, navigationId: number}|null = null) {
9597
super(id, url);
9698
this.navigationTrigger = navigationTrigger;
9799
this.restoredState = restoredState;

packages/router/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export {RouterOutlet} from './directives/router_outlet';
1414
export {ActivationEnd, ActivationStart, ChildActivationEnd, ChildActivationStart, Event, GuardsCheckEnd, GuardsCheckStart, NavigationCancel, NavigationEnd, NavigationError, NavigationStart, ResolveEnd, ResolveStart, RouteConfigLoadEnd, RouteConfigLoadStart, RouterEvent, RoutesRecognized, Scroll} from './events';
1515
export {CanActivate, CanActivateChild, CanDeactivate, CanLoad, Resolve} from './interfaces';
1616
export {DetachedRouteHandle, RouteReuseStrategy} from './route_reuse_strategy';
17-
export {NavigationExtras, Router} from './router';
17+
export {Navigation, NavigationExtras, Router} from './router';
1818
export {ROUTES} from './router_config_loader';
1919
export {ExtraOptions, ROUTER_CONFIGURATION, ROUTER_INITIALIZER, RouterModule, provideRoutes} from './router_module';
2020
export {ChildrenOutletContexts, OutletContext} from './router_outlet_context';

packages/router/src/router.ts

Lines changed: 93 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ export interface NavigationExtras {
147147
replaceUrl?: boolean;
148148
/**
149149
* State passed to any navigation. This value will be accessible through the `extras` object
150-
* returned from `router.getCurrentTransition()` while a navigation is executing. Once a
150+
* returned from `router.getCurrentNavigation()` while a navigation is executing. Once a
151151
* navigation completes, this value will be written to `history.state` when the `location.go`
152152
* or `location.replaceState` method is called before activating of this route. Note that
153153
* `history.state` will not pass an object equality test because the `navigationId` will be
@@ -181,6 +181,57 @@ function defaultMalformedUriErrorHandler(
181181
return urlSerializer.parse('/');
182182
}
183183

184+
export type RestoredState = {
185+
[k: string]: any; navigationId: number;
186+
};
187+
188+
/**
189+
* @description
190+
*
191+
* Information about any given navigation. This information can be gotten from the router at
192+
* any time using the `router.getCurrentNavigation()` method.
193+
*
194+
* @publicApi
195+
*/
196+
export type Navigation = {
197+
/**
198+
* The ID of the current navigation.
199+
*/
200+
id: number;
201+
/**
202+
* Target URL passed into the {@link Router#navigateByUrl} call before navigation. This is
203+
* the value before the router has parsed or applied redirects to it.
204+
*/
205+
initialUrl: string | UrlTree;
206+
/**
207+
* The initial target URL after being parsed with {@link UrlSerializer.extract()}.
208+
*/
209+
extractedUrl: UrlTree;
210+
/**
211+
* Extracted URL after redirects have been applied. This URL may not be available immediately,
212+
* therefore this property can be `undefined`. It is guaranteed to be set after the
213+
* {@link RoutesRecognized} event fires.
214+
*/
215+
finalUrl?: UrlTree;
216+
/**
217+
* Identifies the trigger of the navigation.
218+
*
219+
* * 'imperative'--triggered by `router.navigateByUrl` or `router.navigate`.
220+
* * 'popstate'--triggered by a popstate event
221+
* * 'hashchange'--triggered by a hashchange event
222+
*/
223+
trigger: 'imperative' | 'popstate' | 'hashchange';
224+
/**
225+
* The NavigationExtras used in this navigation. See {@link NavigationExtras} for more info.
226+
*/
227+
extras: NavigationExtras;
228+
/**
229+
* Previously successful Navigation object. Only a single previous Navigation is available,
230+
* therefore this previous Navigation will always have a `null` value for `previousNavigation`.
231+
*/
232+
previousNavigation: Navigation | null;
233+
};
234+
184235
export type NavigationTransition = {
185236
id: number,
186237
currentUrlTree: UrlTree,
@@ -193,7 +244,7 @@ export type NavigationTransition = {
193244
reject: any,
194245
promise: Promise<boolean>,
195246
source: NavigationTrigger,
196-
restoredState: {navigationId: number} | null,
247+
restoredState: RestoredState | null,
197248
currentSnapshot: RouterStateSnapshot,
198249
targetSnapshot: RouterStateSnapshot | null,
199250
currentRouterState: RouterState,
@@ -242,6 +293,8 @@ export class Router {
242293
private rawUrlTree: UrlTree;
243294
private readonly transitions: BehaviorSubject<NavigationTransition>;
244295
private navigations: Observable<NavigationTransition>;
296+
private lastSuccessfulNavigation: Navigation|null = null;
297+
private currentNavigation: Navigation|null = null;
245298

246299
// TODO(issue/24571): remove '!'.
247300
private locationSubscription !: Subscription;
@@ -387,6 +440,20 @@ export class Router {
387440
...t, extractedUrl: this.urlHandlingStrategy.extract(t.rawUrl)
388441
} as NavigationTransition)),
389442

443+
// Store the Navigation object
444+
tap(t => {
445+
this.currentNavigation = {
446+
id: t.id,
447+
initialUrl: t.currentRawUrl,
448+
extractedUrl: t.extractedUrl,
449+
trigger: t.source,
450+
extras: t.extras,
451+
previousNavigation: this.lastSuccessfulNavigation ?
452+
{...this.lastSuccessfulNavigation, previousNavigation: null} :
453+
null
454+
};
455+
}),
456+
390457
// Using switchMap so we cancel executing navigations when a new one comes in
391458
switchMap(t => {
392459
let completed = false;
@@ -420,6 +487,15 @@ export class Router {
420487
applyRedirects(
421488
this.ngModule.injector, this.configLoader, this.urlSerializer,
422489
this.config),
490+
491+
// Update the currentNavigation
492+
tap(t => {
493+
this.currentNavigation = {
494+
...this.currentNavigation !,
495+
finalUrl: t.urlAfterRedirects
496+
};
497+
}),
498+
423499
// Recognize
424500
recognize(
425501
this.rootComponentType, this.config, (url) => this.serializeUrl(url),
@@ -617,6 +693,10 @@ export class Router {
617693
eventsSubject.next(navCancel);
618694
t.resolve(false);
619695
}
696+
// currentNavigation should always be reset to null here. If navigation was
697+
// successful, lastSuccessfulTransition will have already been set. Therefore we
698+
// can safely set currentNavigation to null here.
699+
this.currentNavigation = null;
620700
}),
621701
catchError((e) => {
622702
errored = true;
@@ -696,16 +776,18 @@ export class Router {
696776
// Navigations coming from Angular router have a navigationId state property. When this
697777
// exists, restore the state.
698778
const state = change.state && change.state.navigationId ? change.state : null;
699-
setTimeout(() => {
700-
this.scheduleNavigation(rawUrlTree, source, state, null, {replaceUrl: true});
701-
}, 0);
779+
setTimeout(
780+
() => { this.scheduleNavigation(rawUrlTree, source, state, {replaceUrl: true}); }, 0);
702781
});
703782
}
704783
}
705784

706785
/** The current url */
707786
get url(): string { return this.serializeUrl(this.currentUrlTree); }
708787

788+
/** The current Navigation object if one exists */
789+
getCurrentNavigation(): Navigation|null { return this.currentNavigation; }
790+
709791
/** @internal */
710792
triggerEvent(event: Event): void { (this.events as Subject<Event>).next(event); }
711793

@@ -849,7 +931,7 @@ export class Router {
849931
const urlTree = isUrlTree(url) ? url : this.parseUrl(url);
850932
const mergedTree = this.urlHandlingStrategy.merge(urlTree, this.rawUrlTree);
851933

852-
return this.scheduleNavigation(mergedTree, 'imperative', null, extras.state || null, extras);
934+
return this.scheduleNavigation(mergedTree, 'imperative', null, extras);
853935
}
854936

855937
/**
@@ -929,14 +1011,16 @@ export class Router {
9291011
(this.events as Subject<Event>)
9301012
.next(new NavigationEnd(
9311013
t.id, this.serializeUrl(t.extractedUrl), this.serializeUrl(this.currentUrlTree)));
1014+
this.lastSuccessfulNavigation = this.currentNavigation;
1015+
this.currentNavigation = null;
9321016
t.resolve(true);
9331017
},
9341018
e => { this.console.warn(`Unhandled Navigation Error: `); });
9351019
}
9361020

9371021
private scheduleNavigation(
938-
rawUrl: UrlTree, source: NavigationTrigger, restoredState: {navigationId: number}|null,
939-
futureState: {[key: string]: any}|null, extras: NavigationExtras): Promise<boolean> {
1022+
rawUrl: UrlTree, source: NavigationTrigger, restoredState: RestoredState|null,
1023+
extras: NavigationExtras): Promise<boolean> {
9401024
const lastNavigation = this.getTransition();
9411025
// If the user triggers a navigation imperatively (e.g., by using navigateByUrl),
9421026
// and that navigation results in 'replaceState' that leads to the same URL,
@@ -990,6 +1074,7 @@ export class Router {
9901074
const path = this.urlSerializer.serialize(url);
9911075
state = state || {};
9921076
if (this.location.isCurrentPathEqualTo(path) || replaceUrl) {
1077+
// TODO(jasonaden): Remove first `navigationId` and rely on `ng` namespace.
9931078
this.location.replaceState(path, '', {...state, navigationId: id});
9941079
} else {
9951080
this.location.go(path, '', {...state, navigationId: id});

packages/router/test/integration.spec.ts

Lines changed: 61 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {ComponentFixture, TestBed, fakeAsync, inject, tick} from '@angular/core/
1313
import {By} from '@angular/platform-browser/src/dom/debug/by';
1414
import {expect} from '@angular/platform-browser/testing/src/matchers';
1515
import {fixmeIvy} from '@angular/private/testing';
16-
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';
16+
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';
1717
import {Observable, Observer, Subscription, of } from 'rxjs';
1818
import {filter, first, map, tap} from 'rxjs/operators';
1919

@@ -142,21 +142,22 @@ describe('Integration', () => {
142142
]);
143143

144144
const fixture = createRoot(router, RootCmp);
145-
// let transition: NavigationTransitionx = null !;
146-
// router.events.subscribe(e => {
147-
// if (e instanceof NavigationStart) {
148-
// transition = router.getCurrentTransition();
149-
// }
150-
// });
145+
let navigation: Navigation = null !;
146+
router.events.subscribe(e => {
147+
if (e instanceof NavigationStart) {
148+
navigation = router.getCurrentNavigation() !;
149+
}
150+
});
151151

152152
router.navigateByUrl('/simple', {state: {foo: 'bar'}});
153153
tick();
154154

155155
const history = (location as any)._history;
156156
expect(history[history.length - 1].state.foo).toBe('bar');
157-
expect(history[history.length - 1].state).toEqual({foo: 'bar', navigationId: history.length});
158-
// expect(transition.state).toBeDefined();
159-
// expect(transition.state).toEqual({foo: 'bar'});
157+
expect(history[history.length - 1].state)
158+
.toEqual({foo: 'bar', navigationId: history.length});
159+
expect(navigation.extras.state).toBeDefined();
160+
expect(navigation.extras.state).toEqual({foo: 'bar'});
160161
})));
161162

162163
it('should not pollute browser history when replaceUrl is set to true',
@@ -1879,35 +1880,35 @@ describe('Integration', () => {
18791880
expect(location.path()).toEqual('/team/22/simple?q=1#f');
18801881
})));
18811882

1882-
it('should support history state',
1883-
fakeAsync(inject([Router, Location], (router: Router, location: SpyLocation) => {
1884-
const fixture = createRoot(router, RootCmp);
1883+
it('should support history state',
1884+
fakeAsync(inject([Router, Location], (router: Router, location: SpyLocation) => {
1885+
const fixture = createRoot(router, RootCmp);
18851886

1886-
router.resetConfig([{
1887-
path: 'team/:id',
1888-
component: TeamCmp,
1889-
children: [
1890-
{path: 'link', component: LinkWithState},
1891-
{path: 'simple', component: SimpleCmp}
1892-
]
1893-
}]);
1887+
router.resetConfig([{
1888+
path: 'team/:id',
1889+
component: TeamCmp,
1890+
children: [
1891+
{path: 'link', component: LinkWithState}, {path: 'simple', component: SimpleCmp}
1892+
]
1893+
}]);
18941894

1895-
router.navigateByUrl('/team/22/link');
1896-
advance(fixture);
1895+
router.navigateByUrl('/team/22/link');
1896+
advance(fixture);
18971897

1898-
const native = fixture.nativeElement.querySelector('a');
1899-
expect(native.getAttribute('href')).toEqual('/team/22/simple');
1900-
native.click();
1901-
advance(fixture);
1898+
const native = fixture.nativeElement.querySelector('a');
1899+
expect(native.getAttribute('href')).toEqual('/team/22/simple');
1900+
native.click();
1901+
advance(fixture);
19021902

1903-
expect(fixture.nativeElement).toHaveText('team 22 [ simple, right: ]');
1903+
expect(fixture.nativeElement).toHaveText('team 22 [ simple, right: ]');
19041904

1905-
// Check the history entry
1906-
const history = (location as any)._history;
1905+
// Check the history entry
1906+
const history = (location as any)._history;
19071907

1908-
expect(history[history.length - 1].state.foo).toBe('bar');
1909-
expect(history[history.length - 1].state).toEqual({foo: 'bar', navigationId: history.length});
1910-
})));
1908+
expect(history[history.length - 1].state.foo).toBe('bar');
1909+
expect(history[history.length - 1].state)
1910+
.toEqual({foo: 'bar', navigationId: history.length});
1911+
})));
19111912
});
19121913

19131914
describe('redirects', () => {
@@ -1924,6 +1925,33 @@ describe('Integration', () => {
19241925
expect(location.path()).toEqual('/team/22');
19251926
})));
19261927

1928+
it('should update Navigation object after redirects are applied',
1929+
fakeAsync(inject([Router, Location], (router: Router, location: Location) => {
1930+
const fixture = createRoot(router, RootCmp);
1931+
let initialUrl, afterRedirectUrl;
1932+
1933+
router.resetConfig([
1934+
{path: 'old/team/:id', redirectTo: 'team/:id'}, {path: 'team/:id', component: TeamCmp}
1935+
]);
1936+
1937+
router.events.subscribe(e => {
1938+
if (e instanceof NavigationStart) {
1939+
const navigation = router.getCurrentNavigation();
1940+
initialUrl = navigation && navigation.finalUrl;
1941+
}
1942+
if (e instanceof RoutesRecognized) {
1943+
const navigation = router.getCurrentNavigation();
1944+
afterRedirectUrl = navigation && navigation.finalUrl;
1945+
}
1946+
});
1947+
1948+
router.navigateByUrl('old/team/22');
1949+
advance(fixture);
1950+
1951+
expect(initialUrl).toBeUndefined();
1952+
expect(router.serializeUrl(afterRedirectUrl as any)).toBe('/team/22');
1953+
})));
1954+
19271955
it('should not break the back button when trigger by location change',
19281956
fakeAsync(inject([Router, Location], (router: Router, location: Location) => {
19291957
const fixture = TestBed.createComponent(RootCmp);

0 commit comments

Comments
 (0)