mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
8ec0d1eee8
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
377 lines
14 KiB
TypeScript
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]);
|
|
}
|