/
githubmirror
/
angular
Обзор
Документация
Войти
/
githubmirror
/
angular
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
packages/router/src/router_scroller.ts
167 строк
6 KB
arturovt
fix(router): skip scroll-to-top on initial navigation when hydrating
19 май 2026, 23:01
19 май 2026, 23:01
8ec0d1e
Код
Авторство
О чём код?
/** * @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 { ApplicationRef, inject, Injectable, InjectionToken, NgZone, OnDestroy, untracked, ɵIS_HYDRATION_DOM_REUSE_ENABLED as IS_HYDRATION_DOM_REUSE_ENABLED, } 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 isHydrating = inject(IS_HYDRATION_DOM_REUSE_ENABLED, {optional: true}) ?? false; 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'; if (this.isHydrating) { inject(ApplicationRef) .whenStable() .then(() => { this.isHydrating = false; }); } } 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 { if (this.isHydrating) return; 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(); } }