fix(router): skip scroll-to-top on initial navigation when hydrating

When scrollPositionRestoration is enabled and the app hydrates an
SSR-rendered page, RouterScroller was unconditionally scrolling the
viewport to [0, 0] on the first imperative navigation. This discards
any scroll position the user established while the server-rendered
page was loading.

Fix by injecting IS_HYDRATION_DOM_REUSE_ENABLED into RouterScroller
and suppressing the scroll-to-top for the initial navigation only.
Subsequent navigations are unaffected.

Closes #64578

(cherry picked from commit 8ec0d1eee8)
This commit is contained in:
arturovt 2026-04-12 01:02:33 +03:00 committed by leonsenft
parent fd05135da9
commit 099bf577ee
2 changed files with 112 additions and 9 deletions

View file

@ -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

View file

@ -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<Event | PrivateRouterEvents>();
(TestBed.inject(NavigationTransitions) as Writable<NavigationTransitions>).events = events;