Skip to content

Commit af8afee

Browse files
atscottdylhunn
authored andcommitted
fix(router): Delay router scroll event until navigated components have rendered (angular#47563)
Currently, the scroll event is fired immediately after the `NavigationEnd`. However, this is problematic because a change detection has not been able to run, meaning that Angular will not yet have run the update block of the component templates being rendered as part of the navigation. This change delays the scroll event using a `setTimeout`, which will allow Angular's change detection to run before the scroll restoration is performed. fixes angular#24547 PR Close angular#47563
1 parent 177c556 commit af8afee

File tree

5 files changed

+67
-31
lines changed

5 files changed

+67
-31
lines changed

packages/router/src/provide_router.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
*/
88

99
import {LOCATION_INITIALIZED, ViewportScroller} from '@angular/common';
10-
import {APP_BOOTSTRAP_LISTENER, APP_INITIALIZER, ApplicationRef, ComponentRef, ENVIRONMENT_INITIALIZER, EnvironmentProviders, inject, InjectFlags, InjectionToken, Injector, makeEnvironmentProviders, Provider, Type} from '@angular/core';
10+
import {APP_BOOTSTRAP_LISTENER, APP_INITIALIZER, ApplicationRef, ComponentRef, ENVIRONMENT_INITIALIZER, EnvironmentProviders, inject, InjectFlags, InjectionToken, Injector, makeEnvironmentProviders, NgZone, Provider, Type} from '@angular/core';
1111
import {of, Subject} from 'rxjs';
1212
import {filter, map, take} from 'rxjs/operators';
1313

@@ -155,7 +155,8 @@ export function withInMemoryScrolling(options: InMemoryScrollingOptions = {}):
155155
useFactory: () => {
156156
const router = inject(Router);
157157
const viewportScroller = inject(ViewportScroller);
158-
return new RouterScroller(router, viewportScroller, options);
158+
const zone = inject(NgZone);
159+
return new RouterScroller(router, viewportScroller, zone, options);
159160
},
160161
}];
161162
return routerFeature(RouterFeatureKind.InMemoryScrollingFeature, providers);

packages/router/src/router_module.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
*/
88

99
import {HashLocationStrategy, Location, LocationStrategy, PathLocationStrategy, ViewportScroller} from '@angular/common';
10-
import {APP_BOOTSTRAP_LISTENER, ComponentRef, inject, Inject, InjectionToken, ModuleWithProviders, NgModule, NgProbeToken, Optional, Provider, SkipSelf, ɵRuntimeError as RuntimeError} from '@angular/core';
10+
import {APP_BOOTSTRAP_LISTENER, ComponentRef, inject, Inject, InjectionToken, ModuleWithProviders, NgModule, NgProbeToken, NgZone, Optional, Provider, SkipSelf, ɵRuntimeError as RuntimeError} from '@angular/core';
1111

1212
import {EmptyOutletComponent} from './components/empty_outlet';
1313
import {RouterLink} from './directives/router_link';
@@ -154,11 +154,12 @@ export function provideRouterScroller(): Provider {
154154
useFactory: () => {
155155
const router = inject(Router);
156156
const viewportScroller = inject(ViewportScroller);
157+
const zone = inject(NgZone);
157158
const config: ExtraOptions = inject(ROUTER_CONFIGURATION);
158159
if (config.scrollOffset) {
159160
viewportScroller.setOffset(config.scrollOffset);
160161
}
161-
return new RouterScroller(router, viewportScroller, config);
162+
return new RouterScroller(router, viewportScroller, zone, config);
162163
},
163164
};
164165
}

packages/router/src/router_scroller.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
*/
88

99
import {ViewportScroller} from '@angular/common';
10-
import {Injectable, InjectionToken, OnDestroy} from '@angular/core';
10+
import {Injectable, InjectionToken, NgZone, OnDestroy} from '@angular/core';
1111
import {Unsubscribable} from 'rxjs';
1212

1313
import {NavigationEnd, NavigationStart, Scroll} from './events';
@@ -29,7 +29,8 @@ export class RouterScroller implements OnDestroy {
2929

3030
constructor(
3131
private router: Router,
32-
/** @docsNotRequired */ public readonly viewportScroller: ViewportScroller, private options: {
32+
/** @docsNotRequired */ public readonly viewportScroller: ViewportScroller,
33+
private readonly zone: NgZone, private options: {
3334
scrollPositionRestoration?: 'disabled'|'enabled'|'top',
3435
anchorScrolling?: 'disabled'|'enabled'
3536
} = {}) {
@@ -85,8 +86,18 @@ export class RouterScroller implements OnDestroy {
8586
}
8687

8788
private scheduleScrollEvent(routerEvent: NavigationEnd, anchor: string|null): void {
88-
this.router.triggerEvent(new Scroll(
89-
routerEvent, this.lastSource === 'popstate' ? this.store[this.restoredId] : null, anchor));
89+
this.zone.runOutsideAngular(() => {
90+
// The scroll event needs to be delayed until after change detection. Otherwise, we may
91+
// attempt to restore the scroll position before the router outlet has fully rendered the
92+
// component by executing its update block of the template function.
93+
setTimeout(() => {
94+
this.zone.run(() => {
95+
this.router.triggerEvent(new Scroll(
96+
routerEvent, this.lastSource === 'popstate' ? this.store[this.restoredId] : null,
97+
anchor));
98+
});
99+
}, 0);
100+
});
90101
}
91102

92103
/** @nodoc */

packages/router/test/bootstrap.spec.ts

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -205,21 +205,13 @@ describe('bootstrap', () => {
205205
schemas: [CUSTOM_ELEMENTS_SCHEMA]
206206
})
207207
class TestModule {
208-
constructor(router: Router) {
209-
log.push('TestModule');
210-
router.events.subscribe(e => log.push(e.constructor.name));
211-
}
208+
constructor(router: Router) {}
212209
}
213210

214211
platformBrowserDynamic([]).bootstrapModule(TestModule).then(res => {
215212
const router = res.injector.get(Router);
216213
const data = router.routerState.snapshot.root.firstChild!.data;
217214
expect(data['test']).toEqual('test-data');
218-
expect(log).toEqual([
219-
'TestModule', 'NavigationStart', 'RoutesRecognized', 'GuardsCheckStart',
220-
'ChildActivationStart', 'ActivationStart', 'GuardsCheckEnd', 'ResolveStart', 'ResolveEnd',
221-
'RootCmp', 'ActivationEnd', 'ChildActivationEnd', 'NavigationEnd', 'Scroll'
222-
]);
223215
done();
224216
});
225217
});
@@ -432,6 +424,14 @@ describe('bootstrap', () => {
432424
class TestModule {
433425
}
434426

427+
function resolveAfter(milliseconds: number) {
428+
return new Promise<void>((resolve) => {
429+
setTimeout(() => {
430+
resolve();
431+
}, milliseconds);
432+
});
433+
}
434+
435435
const res = await platformBrowserDynamic([]).bootstrapModule(TestModule);
436436
const router = res.injector.get(Router);
437437

@@ -447,13 +447,16 @@ describe('bootstrap', () => {
447447
expect(window.pageYOffset).toEqual(3000);
448448

449449
await router.navigateByUrl('/cc');
450+
await resolveAfter(100);
450451
expect(window.pageYOffset).toEqual(0);
451452

452453
await router.navigateByUrl('/aa#marker2');
454+
await resolveAfter(100);
453455
expect(window.pageYOffset).toBeGreaterThanOrEqual(5900);
454456
expect(window.pageYOffset).toBeLessThan(6000); // offset
455457

456458
await router.navigateByUrl('/aa#marker3');
459+
await resolveAfter(100);
457460
expect(window.pageYOffset).toBeGreaterThanOrEqual(8900);
458461
expect(window.pageYOffset).toBeLessThan(9000);
459462
});
@@ -506,9 +509,6 @@ describe('bootstrap', () => {
506509
(async () => {
507510
const res = await platformBrowserDynamic([]).bootstrapModule(TestModule);
508511
const router = res.injector.get(Router);
509-
router.events.subscribe(() => {
510-
expect(router.getCurrentNavigation()?.id).toBeDefined();
511-
});
512512
router.events.subscribe(async (e) => {
513513
if (e instanceof NavigationEnd && e.url === '/b') {
514514
await router.navigate(['a']);

packages/router/test/router_scroller.spec.ts

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,21 @@
77
*/
88

99
import {fakeAsync, tick} from '@angular/core/testing';
10-
import {DefaultUrlSerializer, NavigationEnd, NavigationStart, RouterEvent} from '@angular/router';
10+
import {DefaultUrlSerializer, Event, NavigationEnd, NavigationStart} from '@angular/router';
1111
import {Subject} from 'rxjs';
12-
import {filter, switchMap} from 'rxjs/operators';
12+
import {filter, switchMap, take} from 'rxjs/operators';
1313

1414
import {Scroll} from '../src/events';
1515
import {RouterScroller} from '../src/router_scroller';
1616

1717
// TODO: add tests that exercise the `withInMemoryScrolling` feature of the provideRouter function
18+
const fakeZone = {
19+
runOutsideAngular: (fn: any) => fn(),
20+
run: (fn: any) => fn()
21+
};
1822
describe('RouterScroller', () => {
1923
it('defaults to disabled', () => {
20-
const events = new Subject<RouterEvent>();
24+
const events = new Subject<Event>();
2125
const router = <any>{
2226
events,
2327
parseUrl: (url: any) => new DefaultUrlSerializer().parse(url),
@@ -28,87 +32,103 @@ describe('RouterScroller', () => {
2832
'viewportScroller',
2933
['getScrollPosition', 'scrollToPosition', 'scrollToAnchor', 'setHistoryScrollRestoration']);
3034
setScroll(viewportScroller, 0, 0);
31-
const scroller = new RouterScroller(router, router);
35+
const scroller = new RouterScroller(router, router, fakeZone as any);
3236

3337
expect((scroller as any).options.scrollPositionRestoration).toBe('disabled');
3438
expect((scroller as any).options.anchorScrolling).toBe('disabled');
3539
});
3640

41+
function nextScrollEvent(events: Subject<Event>): Promise<Scroll> {
42+
return events.pipe(filter((e): e is Scroll => e instanceof Scroll), take(1)).toPromise();
43+
}
44+
3745
describe('scroll to top', () => {
38-
it('should scroll to the top', () => {
46+
it('should scroll to the top', async () => {
3947
const {events, viewportScroller} =
4048
createRouterScroller({scrollPositionRestoration: 'top', anchorScrolling: 'disabled'});
4149

4250
events.next(new NavigationStart(1, '/a'));
4351
events.next(new NavigationEnd(1, '/a', '/a'));
52+
await nextScrollEvent(events);
4453
expect(viewportScroller.scrollToPosition).toHaveBeenCalledWith([0, 0]);
4554

4655
events.next(new NavigationStart(2, '/a'));
4756
events.next(new NavigationEnd(2, '/b', '/b'));
57+
await nextScrollEvent(events);
4858
expect(viewportScroller.scrollToPosition).toHaveBeenCalledWith([0, 0]);
4959

5060
events.next(new NavigationStart(3, '/a', 'popstate'));
5161
events.next(new NavigationEnd(3, '/a', '/a'));
62+
await nextScrollEvent(events);
5263
expect(viewportScroller.scrollToPosition).toHaveBeenCalledWith([0, 0]);
5364
});
5465
});
5566

5667
describe('scroll to the stored position', () => {
57-
it('should scroll to the stored position on popstate', () => {
68+
it('should scroll to the stored position on popstate', async () => {
5869
const {events, viewportScroller} =
5970
createRouterScroller({scrollPositionRestoration: 'enabled', anchorScrolling: 'disabled'});
6071

6172
events.next(new NavigationStart(1, '/a'));
6273
events.next(new NavigationEnd(1, '/a', '/a'));
74+
await nextScrollEvent(events);
6375
setScroll(viewportScroller, 10, 100);
6476
expect(viewportScroller.scrollToPosition).toHaveBeenCalledWith([0, 0]);
6577

6678
events.next(new NavigationStart(2, '/b'));
6779
events.next(new NavigationEnd(2, '/b', '/b'));
80+
await nextScrollEvent(events);
6881
setScroll(viewportScroller, 20, 200);
6982
expect(viewportScroller.scrollToPosition).toHaveBeenCalledWith([0, 0]);
7083

7184
events.next(new NavigationStart(3, '/a', 'popstate', {navigationId: 1}));
7285
events.next(new NavigationEnd(3, '/a', '/a'));
86+
await nextScrollEvent(events);
7387
expect(viewportScroller.scrollToPosition).toHaveBeenCalledWith([10, 100]);
7488
});
7589
});
7690

7791
describe('anchor scrolling', () => {
78-
it('should work (scrollPositionRestoration is disabled)', () => {
92+
it('should work (scrollPositionRestoration is disabled)', async () => {
7993
const {events, viewportScroller} =
8094
createRouterScroller({scrollPositionRestoration: 'disabled', anchorScrolling: 'enabled'});
8195
events.next(new NavigationStart(1, '/a#anchor'));
8296
events.next(new NavigationEnd(1, '/a#anchor', '/a#anchor'));
97+
await nextScrollEvent(events);
8398
expect(viewportScroller.scrollToAnchor).toHaveBeenCalledWith('anchor');
8499

85100
events.next(new NavigationStart(2, '/a#anchor2'));
86101
events.next(new NavigationEnd(2, '/a#anchor2', '/a#anchor2'));
102+
await nextScrollEvent(events);
87103
expect(viewportScroller.scrollToAnchor).toHaveBeenCalledWith('anchor2');
88104
viewportScroller.scrollToAnchor.calls.reset();
89105

90106
// we never scroll to anchor when navigating back.
91107
events.next(new NavigationStart(3, '/a#anchor', 'popstate'));
92108
events.next(new NavigationEnd(3, '/a#anchor', '/a#anchor'));
109+
await nextScrollEvent(events);
93110
expect(viewportScroller.scrollToAnchor).not.toHaveBeenCalled();
94111
expect(viewportScroller.scrollToPosition).not.toHaveBeenCalled();
95112
});
96113

97-
it('should work (scrollPositionRestoration is enabled)', () => {
114+
it('should work (scrollPositionRestoration is enabled)', async () => {
98115
const {events, viewportScroller} =
99116
createRouterScroller({scrollPositionRestoration: 'enabled', anchorScrolling: 'enabled'});
100117
events.next(new NavigationStart(1, '/a#anchor'));
101118
events.next(new NavigationEnd(1, '/a#anchor', '/a#anchor'));
119+
await nextScrollEvent(events);
102120
expect(viewportScroller.scrollToAnchor).toHaveBeenCalledWith('anchor');
103121

104122
events.next(new NavigationStart(2, '/a#anchor2'));
105123
events.next(new NavigationEnd(2, '/a#anchor2', '/a#anchor2'));
124+
await nextScrollEvent(events);
106125
expect(viewportScroller.scrollToAnchor).toHaveBeenCalledWith('anchor2');
107126
viewportScroller.scrollToAnchor.calls.reset();
108127

109128
// we never scroll to anchor when navigating back
110129
events.next(new NavigationStart(3, '/a#anchor', 'popstate', {navigationId: 1}));
111130
events.next(new NavigationEnd(3, '/a#anchor', '/a#anchor'));
131+
await nextScrollEvent(events);
112132
expect(viewportScroller.scrollToAnchor).not.toHaveBeenCalled();
113133
expect(viewportScroller.scrollToPosition).toHaveBeenCalledWith([0, 0]);
114134
});
@@ -135,14 +155,17 @@ describe('RouterScroller', () => {
135155

136156
events.next(new NavigationStart(1, '/a'));
137157
events.next(new NavigationEnd(1, '/a', '/a'));
158+
tick();
138159
setScroll(viewportScroller, 10, 100);
139160

140161
events.next(new NavigationStart(2, '/b'));
141162
events.next(new NavigationEnd(2, '/b', '/b'));
163+
tick();
142164
setScroll(viewportScroller, 20, 200);
143165

144166
events.next(new NavigationStart(3, '/c'));
145167
events.next(new NavigationEnd(3, '/c', '/c'));
168+
tick();
146169
setScroll(viewportScroller, 30, 300);
147170

148171
events.next(new NavigationStart(4, '/a', 'popstate', {navigationId: 1}));
@@ -164,7 +187,7 @@ describe('RouterScroller', () => {
164187
scrollPositionRestoration: 'disabled'|'enabled'|'top',
165188
anchorScrolling: 'disabled'|'enabled'
166189
}) {
167-
const events = new Subject<RouterEvent>();
190+
const events = new Subject<Event>();
168191
const router = <any>{
169192
events,
170193
parseUrl: (url: any) => new DefaultUrlSerializer().parse(url),
@@ -176,8 +199,8 @@ describe('RouterScroller', () => {
176199
['getScrollPosition', 'scrollToPosition', 'scrollToAnchor', 'setHistoryScrollRestoration']);
177200
setScroll(viewportScroller, 0, 0);
178201

179-
const scroller =
180-
new RouterScroller(router, viewportScroller, {scrollPositionRestoration, anchorScrolling});
202+
const scroller = new RouterScroller(
203+
router, viewportScroller, fakeZone as any, {scrollPositionRestoration, anchorScrolling});
181204
scroller.init();
182205

183206
return {events, viewportScroller, router};

0 commit comments

Comments
 (0)