fix(router): ensure duplicate popstate/hashchange events are handled correctly (#37674)
The current method of handling duplicate navigations caused by 'hashchange' and 'popstate' events for the same url change does not correctly handle cancelled navigations. Because `scheduleNavigation` is called in a `setTimeout` in the location change subscription, the duplicate navigations are not flushed at the same time. This means that if the initial navigation hits a guard that schedules a new navigation, the navigation for the duplicate event will not compare to the correct transition (because we inserted another navigation between the duplicates). See https://github.com/angular/angular/issues/16710#issuecomment-646919529 Fixes #16710 PR Close #37674
This commit is contained in:
parent
69472a1ed0
commit
9185c6e971
|
@ -39,7 +39,7 @@
|
||||||
"master": {
|
"master": {
|
||||||
"uncompressed": {
|
"uncompressed": {
|
||||||
"runtime-es2015": 2289,
|
"runtime-es2015": 2289,
|
||||||
"main-es2015": 245488,
|
"main-es2015": 246044,
|
||||||
"polyfills-es2015": 36938,
|
"polyfills-es2015": 36938,
|
||||||
"5-es2015": 751
|
"5-es2015": 751
|
||||||
}
|
}
|
||||||
|
@ -49,7 +49,7 @@
|
||||||
"master": {
|
"master": {
|
||||||
"uncompressed": {
|
"uncompressed": {
|
||||||
"runtime-es2015": 2289,
|
"runtime-es2015": 2289,
|
||||||
"main-es2015": 221268,
|
"main-es2015": 221897,
|
||||||
"polyfills-es2015": 36938,
|
"polyfills-es2015": 36938,
|
||||||
"5-es2015": 779
|
"5-es2015": 779
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,9 +6,9 @@
|
||||||
* found in the LICENSE file at https://angular.io/license
|
* found in the LICENSE file at https://angular.io/license
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {Location} from '@angular/common';
|
import {Location, PopStateEvent} from '@angular/common';
|
||||||
import {Compiler, Injectable, Injector, isDevMode, NgModuleFactoryLoader, NgModuleRef, NgZone, Type, ɵConsole as Console} from '@angular/core';
|
import {Compiler, Injectable, Injector, isDevMode, NgModuleFactoryLoader, NgModuleRef, NgZone, Type, ɵConsole as Console} from '@angular/core';
|
||||||
import {BehaviorSubject, EMPTY, Observable, of, Subject, Subscription} from 'rxjs';
|
import {BehaviorSubject, EMPTY, Observable, of, Subject, SubscriptionLike} from 'rxjs';
|
||||||
import {catchError, filter, finalize, map, switchMap, tap} from 'rxjs/operators';
|
import {catchError, filter, finalize, map, switchMap, tap} from 'rxjs/operators';
|
||||||
|
|
||||||
import {QueryParamsHandling, Route, Routes, standardizeConfig, validateConfig} from './config';
|
import {QueryParamsHandling, Route, Routes, standardizeConfig, validateConfig} from './config';
|
||||||
|
@ -276,6 +276,16 @@ function defaultRouterHook(snapshot: RouterStateSnapshot, runExtras: {
|
||||||
return of(null) as any;
|
return of(null) as any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Information related to a location change, necessary for scheduling follow-up Router navigations.
|
||||||
|
*/
|
||||||
|
type LocationChangeInfo = {
|
||||||
|
source: 'popstate'|'hashchange',
|
||||||
|
urlTree: UrlTree,
|
||||||
|
state: RestoredState|null,
|
||||||
|
transitionId: number
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description
|
* @description
|
||||||
*
|
*
|
||||||
|
@ -298,8 +308,12 @@ export class Router {
|
||||||
private lastSuccessfulNavigation: Navigation|null = null;
|
private lastSuccessfulNavigation: Navigation|null = null;
|
||||||
private currentNavigation: Navigation|null = null;
|
private currentNavigation: Navigation|null = null;
|
||||||
|
|
||||||
// TODO(issue/24571): remove '!'.
|
private locationSubscription?: SubscriptionLike;
|
||||||
private locationSubscription!: Subscription;
|
/**
|
||||||
|
* Tracks the previously seen location change from the location subscription so we can compare
|
||||||
|
* the two latest to see if they are duplicates. See setUpLocationChangeListener.
|
||||||
|
*/
|
||||||
|
private lastLocationChangeInfo: LocationChangeInfo|null = null;
|
||||||
private navigationId: number = 0;
|
private navigationId: number = 0;
|
||||||
private configLoader: RouterConfigLoader;
|
private configLoader: RouterConfigLoader;
|
||||||
private ngModule: NgModuleRef<any>;
|
private ngModule: NgModuleRef<any>;
|
||||||
|
@ -851,26 +865,66 @@ export class Router {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets up the location change listener.
|
* Sets up the location change listener. This listener detects navigations triggered from outside
|
||||||
|
* the Router (the browser back/forward buttons, for example) and schedules a corresponding Router
|
||||||
|
* navigation so that the correct events, guards, etc. are triggered.
|
||||||
*/
|
*/
|
||||||
setUpLocationChangeListener(): void {
|
setUpLocationChangeListener(): void {
|
||||||
// Don't need to use Zone.wrap any more, because zone.js
|
// Don't need to use Zone.wrap any more, because zone.js
|
||||||
// already patch onPopState, so location change callback will
|
// already patch onPopState, so location change callback will
|
||||||
// run into ngZone
|
// run into ngZone
|
||||||
if (!this.locationSubscription) {
|
if (!this.locationSubscription) {
|
||||||
this.locationSubscription = <any>this.location.subscribe((change: any) => {
|
this.locationSubscription = this.location.subscribe(event => {
|
||||||
let rawUrlTree = this.parseUrl(change['url']);
|
const currentChange = this.extractLocationChangeInfoFromEvent(event);
|
||||||
const source: NavigationTrigger = change['type'] === 'popstate' ? 'popstate' : 'hashchange';
|
if (this.shouldScheduleNavigation(this.lastLocationChangeInfo, currentChange)) {
|
||||||
// Navigations coming from Angular router have a navigationId state property. When this
|
// The `setTimeout` was added in #12160 and is likely to support Angular/AngularJS
|
||||||
// exists, restore the state.
|
// hybrid apps.
|
||||||
const state = change.state && change.state.navigationId ? change.state : null;
|
setTimeout(() => {
|
||||||
setTimeout(() => {
|
const {source, state, urlTree} = currentChange;
|
||||||
this.scheduleNavigation(rawUrlTree, source, state, {replaceUrl: true});
|
this.scheduleNavigation(urlTree, source, state, {replaceUrl: true});
|
||||||
}, 0);
|
}, 0);
|
||||||
|
}
|
||||||
|
this.lastLocationChangeInfo = currentChange;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Extracts router-related information from a `PopStateEvent`. */
|
||||||
|
private extractLocationChangeInfoFromEvent(change: PopStateEvent): LocationChangeInfo {
|
||||||
|
return {
|
||||||
|
source: change['type'] === 'popstate' ? 'popstate' : 'hashchange',
|
||||||
|
urlTree: this.parseUrl(change['url']!),
|
||||||
|
// Navigations coming from Angular router have a navigationId state
|
||||||
|
// property. When this exists, restore the state.
|
||||||
|
state: change.state?.navigationId ? change.state : null,
|
||||||
|
transitionId: this.getTransition().id
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines whether two events triggered by the Location subscription are due to the same
|
||||||
|
* navigation. The location subscription can fire two events (popstate and hashchange) for a
|
||||||
|
* single navigation. The second one should be ignored, that is, we should not schedule another
|
||||||
|
* navigation in the Router.
|
||||||
|
*/
|
||||||
|
private shouldScheduleNavigation(previous: LocationChangeInfo|null, current: LocationChangeInfo):
|
||||||
|
boolean {
|
||||||
|
if (!previous) return true;
|
||||||
|
|
||||||
|
const sameDestination = current.urlTree.toString() === previous.urlTree.toString();
|
||||||
|
const eventsOccurredAtSameTime = current.transitionId === previous.transitionId;
|
||||||
|
if (!eventsOccurredAtSameTime || !sameDestination) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((current.source === 'hashchange' && previous.source === 'popstate') ||
|
||||||
|
(current.source === 'popstate' && previous.source === 'hashchange')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/** The current URL. */
|
/** The current URL. */
|
||||||
get url(): string {
|
get url(): string {
|
||||||
return this.serializeUrl(this.currentUrlTree);
|
return this.serializeUrl(this.currentUrlTree);
|
||||||
|
@ -918,7 +972,7 @@ export class Router {
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
if (this.locationSubscription) {
|
if (this.locationSubscription) {
|
||||||
this.locationSubscription.unsubscribe();
|
this.locationSubscription.unsubscribe();
|
||||||
this.locationSubscription = null!;
|
this.locationSubscription = undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1151,21 +1205,6 @@ export class Router {
|
||||||
return Promise.resolve(true); // return value is not used
|
return Promise.resolve(true); // return value is not used
|
||||||
}
|
}
|
||||||
|
|
||||||
// Because of a bug in IE and Edge, the location class fires two events (popstate and
|
|
||||||
// hashchange) every single time. The second one should be ignored. Otherwise, the URL will
|
|
||||||
// flicker. Handles the case when a popstate was emitted first.
|
|
||||||
if (lastNavigation && source == 'hashchange' && lastNavigation.source === 'popstate' &&
|
|
||||||
lastNavigation.rawUrl.toString() === rawUrl.toString()) {
|
|
||||||
return Promise.resolve(true); // return value is not used
|
|
||||||
}
|
|
||||||
// Because of a bug in IE and Edge, the location class fires two events (popstate and
|
|
||||||
// hashchange) every single time. The second one should be ignored. Otherwise, the URL will
|
|
||||||
// flicker. Handles the case when a hashchange was emitted first.
|
|
||||||
if (lastNavigation && source == 'popstate' && lastNavigation.source === 'hashchange' &&
|
|
||||||
lastNavigation.rawUrl.toString() === rawUrl.toString()) {
|
|
||||||
return Promise.resolve(true); // return value is not used
|
|
||||||
}
|
|
||||||
|
|
||||||
let resolve: any;
|
let resolve: any;
|
||||||
let reject: any;
|
let reject: any;
|
||||||
let promise: Promise<boolean>;
|
let promise: Promise<boolean>;
|
||||||
|
|
|
@ -910,17 +910,29 @@ describe('Integration', () => {
|
||||||
})));
|
})));
|
||||||
|
|
||||||
describe('duplicate in-flight navigations', () => {
|
describe('duplicate in-flight navigations', () => {
|
||||||
|
@Injectable()
|
||||||
|
class RedirectingGuard {
|
||||||
|
constructor(private router: Router) {}
|
||||||
|
canActivate() {
|
||||||
|
this.router.navigate(['/simple']);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
providers: [{
|
providers: [
|
||||||
provide: 'in1Second',
|
{
|
||||||
useValue: (c: any, a: ActivatedRouteSnapshot, b: RouterStateSnapshot) => {
|
provide: 'in1Second',
|
||||||
let res: any = null;
|
useValue: (c: any, a: ActivatedRouteSnapshot, b: RouterStateSnapshot) => {
|
||||||
const p = new Promise(_ => res = _);
|
let res: any = null;
|
||||||
setTimeout(() => res(true), 1000);
|
const p = new Promise(_ => res = _);
|
||||||
return p;
|
setTimeout(() => res(true), 1000);
|
||||||
}
|
return p;
|
||||||
}]
|
}
|
||||||
|
},
|
||||||
|
RedirectingGuard
|
||||||
|
]
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -966,6 +978,28 @@ describe('Integration', () => {
|
||||||
|
|
||||||
expect(location.path()).toEqual('/simple');
|
expect(location.path()).toEqual('/simple');
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
it('should skip duplicate location events', fakeAsync(() => {
|
||||||
|
const router = TestBed.inject(Router);
|
||||||
|
const location = TestBed.inject(Location) as unknown as SpyLocation;
|
||||||
|
const fixture = createRoot(router, RootCmp);
|
||||||
|
|
||||||
|
router.resetConfig([
|
||||||
|
{path: 'blocked', component: SimpleCmp, canActivate: [RedirectingGuard]},
|
||||||
|
{path: 'simple', component: SimpleCmp}
|
||||||
|
]);
|
||||||
|
router.navigateByUrl('/simple');
|
||||||
|
advance(fixture);
|
||||||
|
|
||||||
|
const recordedEvents = [] as Event[];
|
||||||
|
router.events.forEach(e => onlyNavigationStartAndEnd(e) && recordedEvents.push(e));
|
||||||
|
|
||||||
|
location.simulateUrlPop('/blocked');
|
||||||
|
location.simulateHashChange('/blocked');
|
||||||
|
|
||||||
|
advance(fixture);
|
||||||
|
expectEvents(recordedEvents, [[NavigationStart, '/blocked']]);
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should support secondary routes', fakeAsync(inject([Router], (router: Router) => {
|
it('should support secondary routes', fakeAsync(inject([Router], (router: Router) => {
|
||||||
|
@ -3878,8 +3912,8 @@ describe('Integration', () => {
|
||||||
|
|
||||||
expectEvents(recordedEvents, [
|
expectEvents(recordedEvents, [
|
||||||
[NavigationStart, '/lazyFalse/loaded'],
|
[NavigationStart, '/lazyFalse/loaded'],
|
||||||
// No GuardCheck events as `canLoad` is a special guard that's not actually part of the
|
// No GuardCheck events as `canLoad` is a special guard that's not actually part of
|
||||||
// guard lifecycle.
|
// the guard lifecycle.
|
||||||
[NavigationCancel, '/lazyFalse/loaded'],
|
[NavigationCancel, '/lazyFalse/loaded'],
|
||||||
|
|
||||||
[NavigationStart, '/blank'], [RoutesRecognized, '/blank'],
|
[NavigationStart, '/blank'], [RoutesRecognized, '/blank'],
|
||||||
|
@ -4842,8 +4876,8 @@ describe('Integration', () => {
|
||||||
constructor(
|
constructor(
|
||||||
lazy: LazyParentComponent, // should be able to inject lazy/direct parent
|
lazy: LazyParentComponent, // should be able to inject lazy/direct parent
|
||||||
lazyService: LazyLoadedServiceDefinedInModule, // should be able to inject lazy service
|
lazyService: LazyLoadedServiceDefinedInModule, // should be able to inject lazy service
|
||||||
eager:
|
eager: EagerParentComponent // should use the injector of the location to create a
|
||||||
EagerParentComponent // should use the injector of the location to create a parent
|
// parent
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue