mirror of
https://github.com/angular/angular
synced 2026-05-24 09:28:37 +00:00
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:
parent
fd05135da9
commit
099bf577ee
2 changed files with 112 additions and 9 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue