diff --git a/packages/router/src/router_scroller.ts b/packages/router/src/router_scroller.ts index 12bec867583..73855523630 100644 --- a/packages/router/src/router_scroller.ts +++ b/packages/router/src/router_scroller.ts @@ -7,7 +7,16 @@ */ import {ViewportScroller} from '@angular/common'; -import {inject, Injectable, InjectionToken, NgZone, OnDestroy, untracked} from '@angular/core'; +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 { @@ -36,6 +45,8 @@ export class RouterScroller implements OnDestroy { 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); @@ -51,6 +62,13 @@ export class RouterScroller implements OnDestroy { // 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 { @@ -111,6 +129,7 @@ export class RouterScroller implements OnDestroy { 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 diff --git a/packages/router/test/router_scroller.spec.ts b/packages/router/test/router_scroller.spec.ts index 7b6e52a81c1..76f8d58a8eb 100644 --- a/packages/router/test/router_scroller.spec.ts +++ b/packages/router/test/router_scroller.spec.ts @@ -21,7 +21,11 @@ import {filter, switchMap, take} from 'rxjs/operators'; import {PrivateRouterEvents, Scroll} from '../src/events'; import {ROUTER_SCROLLER, RouterScroller} from '../src/router_scroller'; -import {ɵWritable as Writable} from '@angular/core'; +import { + ApplicationRef, + ɵWritable as Writable, + ɵIS_HYDRATION_DOM_REUSE_ENABLED as IS_HYDRATION_DOM_REUSE_ENABLED, +} from '@angular/core'; import {ViewportScroller} from '@angular/common'; import {NavigationTransitions} from '../src/navigation_transition'; import {timeout} from '@angular/private/testing'; @@ -151,6 +155,79 @@ describe('RouterScroller', () => { }); }); + describe('SSR hydration', () => { + it('should not scroll to top on initial navigation when hydrating', async () => { + const {events, viewportScroller} = createRouterScroller( + {scrollPositionRestoration: 'enabled', anchorScrolling: 'disabled'}, + [{provide: IS_HYDRATION_DOM_REUSE_ENABLED, useValue: true}], + ); + + // Simulate the initial navigation that happens during SSR hydration. + // The user may have already scrolled down on the server-rendered page, + // so the scroller must not reset the position to [0, 0]. + events.next(new NavigationStart(1, '/a')); + events.next(new NavigationEnd(1, '/a', '/a')); + await TestBed.inject(ApplicationRef).whenStable(); + + expect(viewportScroller.scrollToPosition).not.toHaveBeenCalled(); + }); + + it('should not scroll to top on initial navigation when hydrating (top mode)', async () => { + const {events, viewportScroller} = createRouterScroller( + {scrollPositionRestoration: 'top', anchorScrolling: 'disabled'}, + [{provide: IS_HYDRATION_DOM_REUSE_ENABLED, useValue: true}], + ); + + events.next(new NavigationStart(1, '/a')); + events.next(new NavigationEnd(1, '/a', '/a')); + await TestBed.inject(ApplicationRef).whenStable(); + + expect(viewportScroller.scrollToPosition).not.toHaveBeenCalled(); + }); + + it('should scroll to top on subsequent navigations after hydration', async () => { + const {events, viewportScroller} = createRouterScroller( + {scrollPositionRestoration: 'top', anchorScrolling: 'disabled'}, + [{provide: IS_HYDRATION_DOM_REUSE_ENABLED, useValue: true}], + ); + + // Skip the initial navigation — no scroll event is emitted during hydration. + events.next(new NavigationStart(1, '/a')); + events.next(new NavigationEnd(1, '/a', '/a')); + await TestBed.inject(ApplicationRef).whenStable(); + + // A subsequent navigation should still scroll to top as normal. + events.next(new NavigationStart(2, '/b')); + events.next(new NavigationEnd(2, '/b', '/b')); + await nextScrollEvent(events); + + expect(viewportScroller.scrollToPosition).toHaveBeenCalledWith([0, 0]); + }); + + it('should not scroll on immediate follow-up navigations triggered during hydration', async () => { + const {events, viewportScroller} = createRouterScroller( + {scrollPositionRestoration: 'top', anchorScrolling: 'disabled'}, + [{provide: IS_HYDRATION_DOM_REUSE_ENABLED, useValue: true}], + ); + + // Fire both navigations during hydration — no scroll events are emitted for either. + events.next(new NavigationStart(1, '/a')); + events.next(new NavigationEnd(1, '/a', '/a')); + events.next(new NavigationStart(2, '/a')); + events.next(new NavigationEnd(2, '/a?filter=active', '/a?filter=active')); + await TestBed.inject(ApplicationRef).whenStable(); + + expect(viewportScroller.scrollToPosition).not.toHaveBeenCalled(); + + // A navigation after hydration settles should scroll normally. + events.next(new NavigationStart(3, '/b')); + events.next(new NavigationEnd(3, '/b', '/b')); + await nextScrollEvent(events); + + expect(viewportScroller.scrollToPosition).toHaveBeenCalledWith([0, 0]); + }); + }); + describe('extending a scroll service', () => { it('work', async () => { const {events, viewportScroller} = createRouterScroller({ @@ -262,13 +339,20 @@ describe('RouterScroller', () => { }); }); -function createRouterScroller({ - scrollPositionRestoration, - anchorScrolling, -}: { - scrollPositionRestoration: 'disabled' | 'enabled' | 'top'; - anchorScrolling: 'disabled' | 'enabled'; -}) { +function createRouterScroller( + { + scrollPositionRestoration, + anchorScrolling, + }: { + scrollPositionRestoration: 'disabled' | 'enabled' | 'top'; + anchorScrolling: 'disabled' | 'enabled'; + }, + extraProviders: any[] = [], +) { + if (extraProviders.length > 0) { + TestBed.configureTestingModule({providers: extraProviders}); + } + const events = new Subject(); (TestBed.inject(NavigationTransitions) as Writable).events = events;