-
Notifications
You must be signed in to change notification settings - Fork 27.2k
Expand file tree
/
Copy pathrouter_scroller.ts
More file actions
148 lines (136 loc) Β· 5.52 KB
/
router_scroller.ts
File metadata and controls
148 lines (136 loc) Β· 5.52 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {ViewportScroller} from '@angular/common';
import {inject, Injectable, InjectionToken, NgZone, OnDestroy, untracked} from '@angular/core';
import {Unsubscribable} from 'rxjs';
import {
IMPERATIVE_NAVIGATION,
NavigationEnd,
NavigationSkipped,
NavigationSkippedCode,
NavigationStart,
NavigationTrigger,
Scroll,
} from './events';
import {NavigationTransitions} from './navigation_transition';
import {UrlSerializer} from './url_tree';
export const ROUTER_SCROLLER = new InjectionToken<RouterScroller>(
typeof ngDevMode !== 'undefined' && ngDevMode ? 'Router Scroller' : '',
);
@Injectable()
export class RouterScroller implements OnDestroy {
private routerEventsSubscription?: Unsubscribable;
private scrollEventsSubscription?: Unsubscribable;
private lastId = 0;
private lastSource: NavigationTrigger | undefined = IMPERATIVE_NAVIGATION;
private restoredId = 0;
private store: {[key: string]: [number, number]} = {};
private readonly urlSerializer = inject(UrlSerializer);
private readonly zone = inject(NgZone);
readonly viewportScroller = inject(ViewportScroller);
private readonly transitions = inject(NavigationTransitions);
/** @docs-private */
constructor(
private options: {
scrollPositionRestoration?: 'disabled' | 'enabled' | 'top';
anchorScrolling?: 'disabled' | 'enabled';
},
) {
// Default both options to 'disabled'
this.options.scrollPositionRestoration ||= 'disabled';
this.options.anchorScrolling ||= 'disabled';
}
init(): void {
// we want to disable the automatic scrolling because having two places
// responsible for scrolling results race conditions, especially given
// that browser don't implement this behavior consistently
if (this.options.scrollPositionRestoration !== 'disabled') {
this.viewportScroller.setHistoryScrollRestoration('manual');
}
this.routerEventsSubscription = this.createScrollEvents();
this.scrollEventsSubscription = this.consumeScrollEvents();
}
private createScrollEvents() {
return this.transitions.events.subscribe((e) => {
if (e instanceof NavigationStart) {
// store the scroll position of the current stable navigations.
this.store[this.lastId] = this.viewportScroller.getScrollPosition();
this.lastSource = e.navigationTrigger;
this.restoredId = e.restoredState ? e.restoredState.navigationId : 0;
} else if (e instanceof NavigationEnd) {
this.lastId = e.id;
this.scheduleScrollEvent(e, this.urlSerializer.parse(e.urlAfterRedirects).fragment);
} else if (
e instanceof NavigationSkipped &&
e.code === NavigationSkippedCode.IgnoredSameUrlNavigation
) {
this.lastSource = undefined;
this.restoredId = 0;
this.scheduleScrollEvent(e, this.urlSerializer.parse(e.url).fragment);
}
});
}
private consumeScrollEvents() {
return this.transitions.events.subscribe((e) => {
if (!(e instanceof Scroll) || e.scrollBehavior === 'manual') return;
const instantScroll: ScrollOptions = {behavior: 'instant'};
// a popstate event. The pop state event will always ignore anchor scrolling.
if (e.position) {
if (this.options.scrollPositionRestoration === 'top') {
this.viewportScroller.scrollToPosition([0, 0], instantScroll);
} else if (this.options.scrollPositionRestoration === 'enabled') {
this.viewportScroller.scrollToPosition(e.position, instantScroll);
}
// imperative navigation "forward"
} else {
if (e.anchor && this.options.anchorScrolling === 'enabled') {
this.viewportScroller.scrollToAnchor(e.anchor);
} else if (this.options.scrollPositionRestoration !== 'disabled') {
this.viewportScroller.scrollToPosition([0, 0]);
}
}
});
}
private scheduleScrollEvent(
routerEvent: NavigationEnd | NavigationSkipped,
anchor: string | null,
): void {
const scroll = untracked(this.transitions.currentNavigation)?.extras.scroll;
this.zone.runOutsideAngular(async () => {
// The scroll event needs to be delayed until after change detection. Otherwise, we may
// attempt to restore the scroll position before the router outlet has fully rendered the
// component by executing its update block of the template function.
//
// #57109 (we need to wait at least a macrotask before scrolling. AfterNextRender resolves in microtask event loop with Zones)
// We could consider _also_ waiting for a render promise though one should have already happened or been scheduled by this point
// and should definitely happen before rAF/setTimeout.
// #53985 (cannot rely solely on setTimeout because a frame may paint before the timeout)
await new Promise((resolve) => {
setTimeout(resolve);
if (typeof requestAnimationFrame !== 'undefined') {
requestAnimationFrame(resolve);
}
});
this.zone.run(() => {
this.transitions.events.next(
new Scroll(
routerEvent,
this.lastSource === 'popstate' ? this.store[this.restoredId] : null,
anchor,
scroll,
),
);
});
});
}
/** @docs-private */
ngOnDestroy(): void {
this.routerEventsSubscription?.unsubscribe();
this.scrollEventsSubscription?.unsubscribe();
}
}