Skip to content

Commit 66ffeca

Browse files
atscottAndrewKushnir
authored andcommitted
fix(router): Scroller should scroll as soon as change detection completes (#55105)
Using `setTimeout` to delay scrolling can result in scrolling in the next frame and cause noticeable flicker. This commit scrolls as soon as the next render happens (or in `setTimeout` if a render does not happen before then). fixes #53985 PR Close #55105
1 parent fd54415 commit 66ffeca

File tree

2 files changed

+52
-35
lines changed

2 files changed

+52
-35
lines changed

packages/router/src/router_scroller.ts

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

99
import {ViewportScroller} from '@angular/common';
10-
import {Injectable, InjectionToken, NgZone, OnDestroy} from '@angular/core';
10+
import {
11+
EnvironmentInjector,
12+
Injectable,
13+
InjectionToken,
14+
NgZone,
15+
OnDestroy,
16+
afterNextRender,
17+
inject,
18+
} from '@angular/core';
1119
import {Unsubscribable} from 'rxjs';
1220

1321
import {
@@ -31,6 +39,7 @@ export class RouterScroller implements OnDestroy {
3139
private lastSource: 'imperative' | 'popstate' | 'hashchange' | undefined = 'imperative';
3240
private restoredId = 0;
3341
private store: {[key: string]: [number, number]} = {};
42+
private readonly environmentInjector = inject(EnvironmentInjector);
3443

3544
/** @nodoc */
3645
constructor(
@@ -105,21 +114,32 @@ export class RouterScroller implements OnDestroy {
105114
routerEvent: NavigationEnd | NavigationSkipped,
106115
anchor: string | null,
107116
): void {
108-
this.zone.runOutsideAngular(() => {
109-
// The scroll event needs to be delayed until after change detection. Otherwise, we may
117+
this.zone.runOutsideAngular(async () => {
118+
// The scroll event needs to be delayed until after change detection. Otherwise we may
110119
// attempt to restore the scroll position before the router outlet has fully rendered the
111120
// component by executing its update block of the template function.
112-
setTimeout(() => {
113-
this.zone.run(() => {
114-
this.transitions.events.next(
115-
new Scroll(
116-
routerEvent,
117-
this.lastSource === 'popstate' ? this.store[this.restoredId] : null,
118-
anchor,
119-
),
120-
);
121+
await new Promise<void>((resolve) => {
122+
// TODO(atscott): Attempt to remove the setTimeout in a future PR.
123+
setTimeout(() => {
124+
resolve();
121125
});
122-
}, 0);
126+
afterNextRender(
127+
() => {
128+
resolve();
129+
},
130+
{injector: this.environmentInjector},
131+
);
132+
});
133+
134+
this.zone.run(() => {
135+
this.transitions.events.next(
136+
new Scroll(
137+
routerEvent,
138+
this.lastSource === 'popstate' ? this.store[this.restoredId] : null,
139+
anchor,
140+
),
141+
);
142+
});
123143
});
124144
}
125145

packages/router/test/router_scroller.spec.ts

Lines changed: 19 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -6,40 +6,34 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import {fakeAsync, tick} from '@angular/core/testing';
9+
import {TestBed, fakeAsync, tick} from '@angular/core/testing';
1010
import {DefaultUrlSerializer, Event, NavigationEnd, NavigationStart} from '@angular/router';
1111
import {Subject} from 'rxjs';
1212
import {filter, switchMap, take} from 'rxjs/operators';
1313

1414
import {Scroll} from '../src/events';
1515
import {RouterScroller} from '../src/router_scroller';
16+
import {ApplicationRef, ɵNoopNgZone as NoopNgZone} from '@angular/core';
1617

1718
// 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-
};
2219
describe('RouterScroller', () => {
2320
it('defaults to disabled', () => {
2421
const events = new Subject<Event>();
25-
const router = <any>{
26-
events,
27-
parseUrl: (url: any) => new DefaultUrlSerializer().parse(url),
28-
triggerEvent: (e: any) => events.next(e),
29-
};
30-
3122
const viewportScroller = jasmine.createSpyObj('viewportScroller', [
3223
'getScrollPosition',
3324
'scrollToPosition',
3425
'scrollToAnchor',
3526
'setHistoryScrollRestoration',
3627
]);
3728
setScroll(viewportScroller, 0, 0);
38-
const scroller = new RouterScroller(
39-
new DefaultUrlSerializer(),
40-
{events} as any,
41-
viewportScroller,
42-
fakeZone as any,
29+
const scroller = TestBed.runInInjectionContext(
30+
() =>
31+
new RouterScroller(
32+
new DefaultUrlSerializer(),
33+
{events} as any,
34+
viewportScroller,
35+
new NoopNgZone(),
36+
),
4337
);
4438

4539
expect((scroller as any).options.scrollPositionRestoration).toBe('disabled');
@@ -226,12 +220,15 @@ describe('RouterScroller', () => {
226220
]);
227221
setScroll(viewportScroller, 0, 0);
228222

229-
const scroller = new RouterScroller(
230-
new DefaultUrlSerializer(),
231-
transitions,
232-
viewportScroller,
233-
fakeZone as any,
234-
{scrollPositionRestoration, anchorScrolling},
223+
const scroller = TestBed.runInInjectionContext(
224+
() =>
225+
new RouterScroller(
226+
new DefaultUrlSerializer(),
227+
transitions,
228+
viewportScroller,
229+
new NoopNgZone(),
230+
{scrollPositionRestoration, anchorScrolling},
231+
),
235232
);
236233
scroller.init();
237234

0 commit comments

Comments
 (0)