Skip to content

Commit 73f03ad

Browse files
atscottdylhunn
authored andcommitted
feat(router): Add new NavigationSkipped event for ignored navigations (#48024)
The Router currently silently ignores navigations for two reasons: 1. By default, same URL navigations are ignored. When this situation is encountered, the navigation is ignored without any events 2. A `UrlHandlingStrategy` may ignore some URLs. For situations when the strategy returns `false` for `shouldProcessUrl`, the Router silently ignores the URL and updates its internal state without running matching, guards, or resolver logic. This commit adds new `NavigationSkipped` events for the above two situations. PR Close #48024
1 parent ffc427b commit 73f03ad

File tree

6 files changed

+140
-44
lines changed

6 files changed

+140
-44
lines changed

goldens/public-api/router/index.md

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -248,7 +248,7 @@ export type DisabledInitialNavigationFeature = RouterFeature<RouterFeatureKind.D
248248
export type EnabledBlockingInitialNavigationFeature = RouterFeature<RouterFeatureKind.EnabledBlockingInitialNavigationFeature>;
249249

250250
// @public
251-
type Event_2 = RouterEvent | NavigationStart | NavigationEnd | NavigationCancel | NavigationError | RoutesRecognized | GuardsCheckStart | GuardsCheckEnd | RouteConfigLoadStart | RouteConfigLoadEnd | ChildActivationStart | ChildActivationEnd | ActivationStart | ActivationEnd | Scroll | ResolveStart | ResolveEnd;
251+
type Event_2 = RouterEvent | NavigationStart | NavigationEnd | NavigationCancel | NavigationError | RoutesRecognized | GuardsCheckStart | GuardsCheckEnd | RouteConfigLoadStart | RouteConfigLoadEnd | ChildActivationStart | ChildActivationEnd | ActivationStart | ActivationEnd | Scroll | ResolveStart | ResolveEnd | NavigationSkipped;
252252
export { Event_2 as Event }
253253

254254
// @public
@@ -272,6 +272,8 @@ export const enum EventType {
272272
// (undocumented)
273273
NavigationError = 3,
274274
// (undocumented)
275+
NavigationSkipped = 16,
276+
// (undocumented)
275277
NavigationStart = 0,
276278
// (undocumented)
277279
ResolveEnd = 6,
@@ -441,6 +443,25 @@ export class NavigationError extends RouterEvent {
441443
export interface NavigationExtras extends UrlCreationOptions, NavigationBehaviorOptions {
442444
}
443445

446+
// @public
447+
export class NavigationSkipped extends RouterEvent {
448+
constructor(
449+
id: number,
450+
url: string,
451+
reason: string,
452+
code?: NavigationSkippedCode | undefined);
453+
readonly code?: NavigationSkippedCode | undefined;
454+
reason: string;
455+
// (undocumented)
456+
readonly type = EventType.NavigationSkipped;
457+
}
458+
459+
// @public
460+
export const enum NavigationSkippedCode {
461+
IgnoredByUrlHandlingStrategy = 1,
462+
IgnoredSameUrlNavigation = 0
463+
}
464+
444465
// @public
445466
export class NavigationStart extends RouterEvent {
446467
constructor(

packages/core/test/bundling/router/bundle.golden_symbols.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,9 @@
350350
{
351351
"name": "NavigationError"
352352
},
353+
{
354+
"name": "NavigationSkipped"
355+
},
353356
{
354357
"name": "NavigationStart"
355358
},

packages/router/src/events.ts

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export const enum EventType {
4242
ActivationStart,
4343
ActivationEnd,
4444
Scroll,
45+
NavigationSkipped,
4546
}
4647

4748
/**
@@ -187,6 +188,26 @@ export const enum NavigationCancellationCode {
187188
GuardRejected,
188189
}
189190

191+
/**
192+
* A code for the `NavigationSkipped` event of the `Router` to indicate the
193+
* reason a navigation was skipped.
194+
*
195+
* @publicApi
196+
*/
197+
export const enum NavigationSkippedCode {
198+
/**
199+
* A navigation was skipped because the navigation URL was the same as the current Router URL.
200+
*/
201+
IgnoredSameUrlNavigation,
202+
/**
203+
* A navigation was skipped because the configured `UrlHandlingStrategy` return `false` for both
204+
* the current Router URL and the target of the navigation.
205+
*
206+
* @see UrlHandlingStrategy
207+
*/
208+
IgnoredByUrlHandlingStrategy,
209+
}
210+
190211
/**
191212
* An event triggered when a navigation is canceled, directly or indirectly.
192213
* This can happen for several reasons including when a route guard
@@ -226,6 +247,37 @@ export class NavigationCancel extends RouterEvent {
226247
}
227248
}
228249

250+
/**
251+
* An event triggered when a navigation is skipped.
252+
* This can happen for a couple reasons including onSameUrlHandling
253+
* is set to `ignore` and the navigation URL is not different than the
254+
* current state.
255+
*
256+
* @publicApi
257+
*/
258+
export class NavigationSkipped extends RouterEvent {
259+
readonly type = EventType.NavigationSkipped;
260+
261+
constructor(
262+
/** @docsNotRequired */
263+
id: number,
264+
/** @docsNotRequired */
265+
url: string,
266+
/**
267+
* A description of why the navigation was skipped. For debug purposes only. Use `code`
268+
* instead for a stable skipped reason that can be used in production.
269+
*/
270+
public reason: string,
271+
/**
272+
* A code to indicate why the navigation was skipped. This code is stable for
273+
* the reason and can be relied on whereas the `reason` string could change and should not be
274+
* used in production.
275+
*/
276+
readonly code?: NavigationSkippedCode) {
277+
super(id, url);
278+
}
279+
}
280+
229281
/**
230282
* An event triggered when a navigation fails due to an unexpected error.
231283
*
@@ -576,10 +628,10 @@ export class Scroll {
576628
*
577629
* @publicApi
578630
*/
579-
export type Event =
580-
RouterEvent|NavigationStart|NavigationEnd|NavigationCancel|NavigationError|RoutesRecognized|
581-
GuardsCheckStart|GuardsCheckEnd|RouteConfigLoadStart|RouteConfigLoadEnd|ChildActivationStart|
582-
ChildActivationEnd|ActivationStart|ActivationEnd|Scroll|ResolveStart|ResolveEnd;
631+
export type Event = RouterEvent|NavigationStart|NavigationEnd|NavigationCancel|NavigationError|
632+
RoutesRecognized|GuardsCheckStart|GuardsCheckEnd|RouteConfigLoadStart|RouteConfigLoadEnd|
633+
ChildActivationStart|ChildActivationEnd|ActivationStart|ActivationEnd|Scroll|ResolveStart|
634+
ResolveEnd|NavigationSkipped;
583635

584636

585637
export function stringifyEvent(routerEvent: Event): string {
@@ -605,6 +657,8 @@ export function stringifyEvent(routerEvent: Event): string {
605657
routerEvent.state})`;
606658
case EventType.NavigationCancel:
607659
return `NavigationCancel(id: ${routerEvent.id}, url: '${routerEvent.url}')`;
660+
case EventType.NavigationSkipped:
661+
return `NavigationSkipped(id: ${routerEvent.id}, url: '${routerEvent.url}')`;
608662
case EventType.NavigationEnd:
609663
return `NavigationEnd(id: ${routerEvent.id}, url: '${routerEvent.url}', urlAfterRedirects: '${
610664
routerEvent.urlAfterRedirects}')`;

packages/router/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ export {createUrlTreeFromSnapshot} from './create_url_tree';
1111
export {RouterLink, RouterLinkWithHref} from './directives/router_link';
1212
export {RouterLinkActive} from './directives/router_link_active';
1313
export {RouterOutlet, RouterOutletContract} from './directives/router_outlet';
14-
export {ActivationEnd, ActivationStart, ChildActivationEnd, ChildActivationStart, Event, EventType, GuardsCheckEnd, GuardsCheckStart, NavigationCancel, NavigationCancellationCode as NavigationCancellationCode, NavigationEnd, NavigationError, NavigationStart, ResolveEnd, ResolveStart, RouteConfigLoadEnd, RouteConfigLoadStart, RouterEvent, RoutesRecognized, Scroll} from './events';
14+
export {ActivationEnd, ActivationStart, ChildActivationEnd, ChildActivationStart, Event, EventType, GuardsCheckEnd, GuardsCheckStart, NavigationCancel, NavigationCancellationCode as NavigationCancellationCode, NavigationEnd, NavigationError, NavigationSkipped, NavigationSkippedCode, NavigationStart, ResolveEnd, ResolveStart, RouteConfigLoadEnd, RouteConfigLoadStart, RouterEvent, RoutesRecognized, Scroll} from './events';
1515
export {CanActivate, CanActivateChild, CanActivateChildFn, CanActivateFn, CanDeactivate, CanDeactivateFn, CanLoad, CanLoadFn, CanMatch, CanMatchFn, Data, DefaultExport, LoadChildren, LoadChildrenCallback, NavigationBehaviorOptions, QueryParamsHandling, Resolve, ResolveData, ResolveFn, Route, Routes, RunGuardsAndResolvers, UrlMatcher, UrlMatchResult} from './models';
1616
export {DefaultTitleStrategy, TitleStrategy} from './page_title_strategy';
1717
export {DebugTracingFeature, DisabledInitialNavigationFeature, EnabledBlockingInitialNavigationFeature, InitialNavigationFeature, InMemoryScrollingFeature, PreloadingFeature, provideRouter, provideRoutes, RouterConfigurationFeature, RouterFeature, RouterFeatures, withDebugTracing, withDisabledInitialNavigation, withEnabledBlockingInitialNavigation, withInMemoryScrolling, withPreloading, withRouterConfig} from './provide_router';

packages/router/src/router.ts

Lines changed: 51 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {catchError, defaultIfEmpty, filter, finalize, map, switchMap, take, tap}
1414
import {createRouterState} from './create_router_state';
1515
import {createUrlTree} from './create_url_tree';
1616
import {RuntimeErrorCode} from './errors';
17-
import {Event, GuardsCheckEnd, GuardsCheckStart, NavigationCancel, NavigationCancellationCode, NavigationEnd, NavigationError, NavigationStart, NavigationTrigger, ResolveEnd, ResolveStart, RouteConfigLoadEnd, RouteConfigLoadStart, RoutesRecognized} from './events';
17+
import {Event, GuardsCheckEnd, GuardsCheckStart, NavigationCancel, NavigationCancellationCode, NavigationEnd, NavigationError, NavigationSkipped, NavigationSkippedCode, NavigationStart, NavigationTrigger, ResolveEnd, ResolveStart, RouteConfigLoadEnd, RouteConfigLoadStart, RoutesRecognized} from './events';
1818
import {NavigationBehaviorOptions, QueryParamsHandling, Route, Routes} from './models';
1919
import {isNavigationCancelingError, isRedirectingNavigationCancelingError, redirectingNavigationError} from './navigation_canceling_error';
2020
import {activateRoutes} from './operators/activate_routes';
@@ -671,12 +671,21 @@ export class Router {
671671
// matching. If this is not the case, assume something went wrong and
672672
// try processing the URL again.
673673
browserUrlTree !== this.currentUrlTree.toString();
674-
const processCurrentUrl =
675-
(this.onSameUrlNavigation === 'reload' ? true : urlTransition) &&
676-
this.urlHandlingStrategy.shouldProcessUrl(t.rawUrl);
677674

675+
if (!urlTransition && this.onSameUrlNavigation !== 'reload') {
676+
const reason = NG_DEV_MODE ?
677+
`Navigation to ${
678+
t.rawUrl} was ignored because it is the same as the current Router URL.` :
679+
'';
680+
this.triggerEvent(new NavigationSkipped(
681+
t.id, this.serializeUrl(overallTransitionState.rawUrl), reason,
682+
NavigationSkippedCode.IgnoredSameUrlNavigation));
683+
this.rawUrlTree = t.rawUrl;
684+
t.resolve(null);
685+
return EMPTY;
686+
}
678687

679-
if (processCurrentUrl) {
688+
if (this.urlHandlingStrategy.shouldProcessUrl(t.rawUrl)) {
680689
// If the source of the navigation is from a browser event, the URL is
681690
// already updated. We already need to sync the internal state.
682691
if (isBrowserTriggeredNavigation(t.source)) {
@@ -736,37 +745,44 @@ export class Router {
736745
this.serializeUrl(t.urlAfterRedirects!), t.targetSnapshot!);
737746
eventsSubject.next(routesRecognized);
738747
}));
748+
} else if (
749+
urlTransition &&
750+
this.urlHandlingStrategy.shouldProcessUrl(this.rawUrlTree)) {
751+
// When the current URL shouldn't be processed, but the previous one
752+
// was, we handle this by navigating from the current URL to an empty
753+
// state so deactivate guards can run.
754+
const {id, extractedUrl, source, restoredState, extras} = t;
755+
const navStart = new NavigationStart(
756+
id, this.serializeUrl(extractedUrl), source, restoredState);
757+
eventsSubject.next(navStart);
758+
const targetSnapshot =
759+
createEmptyState(extractedUrl, this.rootComponentType).snapshot;
760+
761+
overallTransitionState = {
762+
...t,
763+
targetSnapshot,
764+
urlAfterRedirects: extractedUrl,
765+
extras: {...extras, skipLocationChange: false, replaceUrl: false},
766+
};
767+
return of(overallTransitionState);
739768
} else {
740-
const processPreviousUrl = urlTransition && this.rawUrlTree &&
741-
this.urlHandlingStrategy.shouldProcessUrl(this.rawUrlTree);
742-
/* When the current URL shouldn't be processed, but the previous one
743-
* was, we handle this "error condition" by navigating to the
744-
* previously successful URL, but leaving the URL intact.*/
745-
if (processPreviousUrl) {
746-
const {id, extractedUrl, source, restoredState, extras} = t;
747-
const navStart = new NavigationStart(
748-
id, this.serializeUrl(extractedUrl), source, restoredState);
749-
eventsSubject.next(navStart);
750-
const targetSnapshot =
751-
createEmptyState(extractedUrl, this.rootComponentType).snapshot;
752-
753-
overallTransitionState = {
754-
...t,
755-
targetSnapshot,
756-
urlAfterRedirects: extractedUrl,
757-
extras: {...extras, skipLocationChange: false, replaceUrl: false},
758-
};
759-
return of(overallTransitionState);
760-
} else {
761-
/* When neither the current or previous URL can be processed, do
762-
* nothing other than update router's internal reference to the
763-
* current "settled" URL. This way the next navigation will be coming
764-
* from the current URL in the browser.
765-
*/
766-
this.rawUrlTree = t.rawUrl;
767-
t.resolve(null);
768-
return EMPTY;
769-
}
769+
/* When neither the current or previous URL can be processed, do
770+
* nothing other than update router's internal reference to the
771+
* current "settled" URL. This way the next navigation will be coming
772+
* from the current URL in the browser.
773+
*/
774+
const reason = NG_DEV_MODE ?
775+
`Navigation was ignored because the UrlHandlingStrategy` +
776+
` indicated neither the current URL ${
777+
this.rawUrlTree} nor target URL ${
778+
t.rawUrl} should be processed.` :
779+
'';
780+
this.triggerEvent(new NavigationSkipped(
781+
t.id, this.serializeUrl(overallTransitionState.extractedUrl),
782+
reason, NavigationSkippedCode.IgnoredByUrlHandlingStrategy));
783+
this.rawUrlTree = t.rawUrl;
784+
t.resolve(null);
785+
return EMPTY;
770786
}
771787
}),
772788

packages/router/test/integration.spec.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {ChangeDetectionStrategy, Component, EnvironmentInjector, inject as coreI
1212
import {ComponentFixture, fakeAsync, inject, TestBed, tick} from '@angular/core/testing';
1313
import {By} from '@angular/platform-browser/src/dom/debug/by';
1414
import {expect} from '@angular/platform-browser/testing/src/matchers';
15-
import {ActivatedRoute, ActivatedRouteSnapshot, ActivationEnd, ActivationStart, CanActivate, CanDeactivate, ChildActivationEnd, ChildActivationStart, DefaultUrlSerializer, DetachedRouteHandle, Event, GuardsCheckEnd, GuardsCheckStart, Navigation, NavigationCancel, NavigationCancellationCode, NavigationEnd, NavigationError, NavigationStart, ParamMap, Params, PreloadAllModules, PreloadingStrategy, PRIMARY_OUTLET, Resolve, ResolveEnd, ResolveStart, RouteConfigLoadEnd, RouteConfigLoadStart, Router, RouteReuseStrategy, RouterEvent, RouterLink, RouterLinkActive, RouterModule, RouterOutlet, RouterPreloader, RouterStateSnapshot, RoutesRecognized, RunGuardsAndResolvers, UrlHandlingStrategy, UrlSegmentGroup, UrlSerializer, UrlTree} from '@angular/router';
15+
import {ActivatedRoute, ActivatedRouteSnapshot, ActivationEnd, ActivationStart, CanActivate, CanDeactivate, ChildActivationEnd, ChildActivationStart, DefaultUrlSerializer, DetachedRouteHandle, Event, GuardsCheckEnd, GuardsCheckStart, Navigation, NavigationCancel, NavigationCancellationCode, NavigationEnd, NavigationError, NavigationSkipped, NavigationStart, ParamMap, Params, PreloadAllModules, PreloadingStrategy, PRIMARY_OUTLET, Resolve, ResolveEnd, ResolveStart, RouteConfigLoadEnd, RouteConfigLoadStart, Router, RouteReuseStrategy, RouterEvent, RouterLink, RouterLinkActive, RouterModule, RouterOutlet, RouterPreloader, RouterStateSnapshot, RoutesRecognized, RunGuardsAndResolvers, UrlHandlingStrategy, UrlSegmentGroup, UrlSerializer, UrlTree} from '@angular/router';
1616
import {concat, EMPTY, Observable, Observer, of, Subscription} from 'rxjs';
1717
import {delay, filter, first, last, map, mapTo, takeWhile, tap} from 'rxjs/operators';
1818

@@ -6352,7 +6352,8 @@ describe('Integration', () => {
63526352
advance(fixture);
63536353

63546354
expect(location.path()).toEqual('/exclude/two');
6355-
expectEvents(events, []);
6355+
expectEvents(events, [[NavigationSkipped, '/exclude/two']]);
6356+
events.splice(0);
63566357

63576358
// back to a supported URL
63586359
location.simulateHashChange('/include/simple');
@@ -6397,7 +6398,8 @@ describe('Integration', () => {
63976398

63986399
location.simulateHashChange('/include/user/kate(aux:excluded2)');
63996400
advance(fixture);
6400-
expectEvents(events, []);
6401+
expectEvents(events, [[NavigationSkipped, '/include/user/kate(aux:excluded2)']]);
6402+
events.splice(0);
64016403

64026404
router.navigateByUrl('/include/simple');
64036405
advance(fixture);

0 commit comments

Comments
 (0)