Files
arturovt 8ec0d1eee8 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
2026-05-19 13:01:53 -07:00

377 lines
14 KiB
TypeScript

/**
* @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 {TestBed} from '@angular/core/testing';
import {
Event,
NavigationEnd,
NavigationStart,
provideRouter,
RedirectCommand,
Router,
withInMemoryScrolling,
} from '../index';
import {firstValueFrom, Subject} from 'rxjs';
import {filter, switchMap, take} from 'rxjs/operators';
import {PrivateRouterEvents, Scroll} from '../src/events';
import {ROUTER_SCROLLER, RouterScroller} from '../src/router_scroller';
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';
describe('RouterScroller', () => {
it('defaults to disabled', () => {
const viewportScroller = TestBed.inject(ViewportScroller);
spyOn(viewportScroller, 'getScrollPosition');
spyOn(viewportScroller, 'scrollToPosition');
spyOn(viewportScroller, 'scrollToAnchor');
spyOn(viewportScroller, 'setHistoryScrollRestoration');
setScroll(viewportScroller, 0, 0);
const scroller = TestBed.runInInjectionContext(() => new RouterScroller({}));
expect(scroller['options'].scrollPositionRestoration).toBe('disabled');
expect(scroller['options'].anchorScrolling).toBe('disabled');
});
function nextScrollEvent(events: Subject<Event | PrivateRouterEvents>): Promise<Scroll> {
return events
.pipe(
filter((e): e is Scroll => e instanceof Scroll),
take(1),
)
.toPromise() as Promise<Scroll>;
}
describe('scroll to top', () => {
it('should scroll to the top', async () => {
const {events, viewportScroller} = createRouterScroller({
scrollPositionRestoration: 'top',
anchorScrolling: 'disabled',
});
events.next(new NavigationStart(1, '/a'));
events.next(new NavigationEnd(1, '/a', '/a'));
await nextScrollEvent(events);
expect(viewportScroller.scrollToPosition).toHaveBeenCalledWith([0, 0]);
events.next(new NavigationStart(2, '/a'));
events.next(new NavigationEnd(2, '/b', '/b'));
await nextScrollEvent(events);
expect(viewportScroller.scrollToPosition).toHaveBeenCalledWith([0, 0]);
events.next(new NavigationStart(3, '/a', 'popstate'));
events.next(new NavigationEnd(3, '/a', '/a'));
await nextScrollEvent(events);
expect(viewportScroller.scrollToPosition).toHaveBeenCalledWith([0, 0]);
});
});
describe('scroll to the stored position', () => {
it('should scroll to the stored position on popstate', async () => {
const {events, viewportScroller} = createRouterScroller({
scrollPositionRestoration: 'enabled',
anchorScrolling: 'disabled',
});
events.next(new NavigationStart(1, '/a'));
events.next(new NavigationEnd(1, '/a', '/a'));
await nextScrollEvent(events);
setScroll(viewportScroller, 10, 100);
expect(viewportScroller.scrollToPosition).toHaveBeenCalledWith([0, 0]);
events.next(new NavigationStart(2, '/b'));
events.next(new NavigationEnd(2, '/b', '/b'));
await nextScrollEvent(events);
setScroll(viewportScroller, 20, 200);
expect(viewportScroller.scrollToPosition).toHaveBeenCalledWith([0, 0]);
events.next(new NavigationStart(3, '/a', 'popstate', {navigationId: 1}));
events.next(new NavigationEnd(3, '/a', '/a'));
await nextScrollEvent(events);
expect(viewportScroller.scrollToPosition).toHaveBeenCalledWith([10, 100], {
behavior: 'instant',
});
});
});
describe('anchor scrolling', () => {
it('should work (scrollPositionRestoration is disabled)', async () => {
const {events, viewportScroller} = createRouterScroller({
scrollPositionRestoration: 'disabled',
anchorScrolling: 'enabled',
});
events.next(new NavigationStart(1, '/a#anchor'));
events.next(new NavigationEnd(1, '/a#anchor', '/a#anchor'));
await nextScrollEvent(events);
expect(viewportScroller.scrollToAnchor).toHaveBeenCalledWith('anchor');
events.next(new NavigationStart(2, '/a#anchor2'));
events.next(new NavigationEnd(2, '/a#anchor2', '/a#anchor2'));
await nextScrollEvent(events);
expect(viewportScroller.scrollToAnchor).toHaveBeenCalledWith('anchor2');
(viewportScroller.scrollToAnchor as jasmine.Spy).calls.reset();
// we never scroll to anchor when navigating back.
events.next(new NavigationStart(3, '/a#anchor', 'popstate'));
events.next(new NavigationEnd(3, '/a#anchor', '/a#anchor'));
await nextScrollEvent(events);
expect(viewportScroller.scrollToAnchor).not.toHaveBeenCalled();
expect(viewportScroller.scrollToPosition).not.toHaveBeenCalled();
});
it('should work (scrollPositionRestoration is enabled)', async () => {
const {events, viewportScroller} = createRouterScroller({
scrollPositionRestoration: 'enabled',
anchorScrolling: 'enabled',
});
events.next(new NavigationStart(1, '/a#anchor'));
events.next(new NavigationEnd(1, '/a#anchor', '/a#anchor'));
await nextScrollEvent(events);
expect(viewportScroller.scrollToAnchor).toHaveBeenCalledWith('anchor');
events.next(new NavigationStart(2, '/a#anchor2'));
events.next(new NavigationEnd(2, '/a#anchor2', '/a#anchor2'));
await nextScrollEvent(events);
expect(viewportScroller.scrollToAnchor).toHaveBeenCalledWith('anchor2');
(viewportScroller.scrollToAnchor as jasmine.Spy).calls.reset();
// we never scroll to anchor when navigating back
events.next(new NavigationStart(3, '/a#anchor', 'popstate', {navigationId: 1}));
events.next(new NavigationEnd(3, '/a#anchor', '/a#anchor'));
await nextScrollEvent(events);
expect(viewportScroller.scrollToAnchor).not.toHaveBeenCalled();
expect(viewportScroller.scrollToPosition).toHaveBeenCalledWith([0, 0], {behavior: 'instant'});
});
});
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({
scrollPositionRestoration: 'disabled',
anchorScrolling: 'disabled',
});
events
.pipe(
filter((e): e is Scroll => e instanceof Scroll && !!e.position),
switchMap((p) => {
// can be any delay (e.g., we can wait for NgRx store to emit an event)
const r = new Subject<Scroll>();
setTimeout(() => {
r.next(p);
r.complete();
}, 10);
return r;
}),
)
.subscribe((e: Scroll) => {
viewportScroller.scrollToPosition(e.position!);
});
events.next(new NavigationStart(1, '/a'));
events.next(new NavigationEnd(1, '/a', '/a'));
await timeout();
setScroll(viewportScroller, 10, 100);
events.next(new NavigationStart(2, '/b'));
events.next(new NavigationEnd(2, '/b', '/b'));
await timeout();
setScroll(viewportScroller, 20, 200);
events.next(new NavigationStart(3, '/c'));
events.next(new NavigationEnd(3, '/c', '/c'));
await timeout();
setScroll(viewportScroller, 30, 300);
events.next(new NavigationStart(4, '/a', 'popstate', {navigationId: 1}));
events.next(new NavigationEnd(4, '/a', '/a'));
await timeout(5);
expect(viewportScroller.scrollToPosition).not.toHaveBeenCalled();
events.next(new NavigationStart(5, '/a', 'popstate', {navigationId: 1}));
events.next(new NavigationEnd(5, '/a', '/a'));
await timeout(50);
expect(viewportScroller.scrollToPosition).toHaveBeenCalledWith([10, 100]);
});
});
describe('scroll in NavigationBehaviorOptions', () => {
let router: Router;
let scrollToSpy: jasmine.Spy;
let viewportScroller: ViewportScroller;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
provideRouter(
[{path: '**', children: []}],
withInMemoryScrolling({scrollPositionRestoration: 'enabled'}),
),
],
});
router = TestBed.inject(Router);
viewportScroller = TestBed.inject(ViewportScroller);
scrollToSpy = spyOn(viewportScroller, 'scrollToPosition');
TestBed.inject(ROUTER_SCROLLER, null, {optional: true})?.init();
});
it('skips scrolling', async () => {
TestBed.inject(Router).navigateByUrl('/initial', {scroll: 'manual'});
await firstValueFrom(router.events.pipe(filter((e) => e instanceof Scroll)));
expect(scrollToSpy).not.toHaveBeenCalled();
});
it('retains scroll skipping with redirect', async () => {
router.resetConfig([
{
path: 'initial',
children: [],
canActivate: [() => new RedirectCommand(router.parseUrl('/final'))],
},
{path: '**', children: []},
]);
TestBed.inject(Router).navigateByUrl('/initial', {scroll: 'manual'});
await firstValueFrom(router.events.pipe(filter((e) => e instanceof Scroll)));
expect(scrollToSpy).not.toHaveBeenCalled();
});
it('can override scroll skipping with redirect', async () => {
router.resetConfig([
{
path: 'initial',
children: [],
canActivate: [
() => new RedirectCommand(router.parseUrl('/final'), {scroll: 'after-transition'}),
],
},
{path: '**', children: []},
]);
TestBed.inject(Router).navigateByUrl('/initial', {scroll: 'after-transition'});
await firstValueFrom(router.events.pipe(filter((e) => e instanceof Scroll)));
expect(scrollToSpy).toHaveBeenCalledTimes(1);
});
});
});
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;
const viewportScroller = TestBed.inject(ViewportScroller);
spyOn(viewportScroller, 'getScrollPosition');
spyOn(viewportScroller, 'scrollToPosition');
spyOn(viewportScroller, 'scrollToAnchor');
spyOn(viewportScroller, 'setHistoryScrollRestoration');
setScroll(viewportScroller, 0, 0);
const scroller = TestBed.runInInjectionContext(
() => new RouterScroller({scrollPositionRestoration, anchorScrolling}),
);
scroller.init();
return {events, viewportScroller};
}
function setScroll(viewportScroller: any, x: number, y: number) {
viewportScroller.getScrollPosition.and.returnValue([x, y]);
}