diff --git a/packages/router-core/src/scroll-restoration.ts b/packages/router-core/src/scroll-restoration.ts index 6287a6fce0..6a466abb78 100644 --- a/packages/router-core/src/scroll-restoration.ts +++ b/packages/router-core/src/scroll-restoration.ts @@ -319,6 +319,7 @@ export function setupScrollRestoration(router: AnyRouter, force?: boolean) { const hashScrollIntoViewOptions = event.toLocation.state.__hashScrollIntoViewOptions ?? true let windowRestored = false + const restoredElements = new Set() if (shouldResetScroll) { const action = locationHistoryActions.get(event.toLocation) @@ -351,6 +352,7 @@ export function setupScrollRestoration(router: AnyRouter, force?: boolean) { if (element) { element.scrollLeft = scrollX element.scrollTop = scrollY + restoredElements.add(element) } } } @@ -367,6 +369,13 @@ export function setupScrollRestoration(router: AnyRouter, force?: boolean) { if (scrollToTopSelectors) { scrollToTopElements ??= getScrollToTopElements(scrollToTopSelectors) for (const element of scrollToTopElements) { + // A successful element scroll restoration must suppress the + // scroll-to-top fallback for that element, mirroring how + // `windowRestored` suppresses it for the window. Otherwise the + // just-restored scroll position is immediately reset to 0. + if (restoredElements.has(element)) { + continue + } element.scrollTo(scrollOptions) } } diff --git a/packages/router-core/tests/scroll-restoration.test.ts b/packages/router-core/tests/scroll-restoration.test.ts index 188d13bb68..02f08e3c39 100644 --- a/packages/router-core/tests/scroll-restoration.test.ts +++ b/packages/router-core/tests/scroll-restoration.test.ts @@ -45,6 +45,103 @@ describe('setupScrollRestoration', () => { window.history.scrollRestoration = previousScrollRestoration }) +}) + +describe('element scroll restoration', () => { + const SELECTOR = '[data-scroll-restoration-id="container"]' + + function createScrollableElement() { + const el = document.createElement('div') + el.setAttribute('data-scroll-restoration-id', 'container') + + // jsdom has no layout, so back scrollTop/scrollLeft with real storage and + // make `scrollTo` actually apply, so we can assert the user-visible result. + let top = 0 + let left = 0 + Object.defineProperty(el, 'scrollTop', { + configurable: true, + get: () => top, + set: (v: number) => { + top = v + }, + }) + Object.defineProperty(el, 'scrollLeft', { + configurable: true, + get: () => left, + set: (v: number) => { + left = v + }, + }) + const scrollTo = vi.fn((opts: { top?: number; left?: number }) => { + if (opts.top !== undefined) top = opts.top + if (opts.left !== undefined) left = opts.left + }) + ;(el as any).scrollTo = scrollTo + + document.body.appendChild(el) + return { el, scrollTo } + } + + test('does not reset a restored element that is also in scrollToTopSelectors', async () => { + vi.spyOn(window, 'scrollTo').mockImplementation(() => {}) + + const rootRoute = new BaseRootRoute({}) + const indexRoute = new BaseRoute({ getParentRoute: () => rootRoute, path: '/' }) + const pageRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/page', + }) + + const router = createTestRouter({ + routeTree: rootRoute.addChildren([indexRoute, pageRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + scrollRestoration: true, + // Key by pathname so revisiting `/page` restores its cached scroll. + getScrollRestorationKey: (location) => location.pathname, + scrollToTopSelectors: [SELECTOR], + }) + + await router.load() + + const { el, scrollTo } = createScrollableElement() + + const homeLocation = router.buildLocation({ to: '/' }) + const pageLocation = router.buildLocation({ to: '/page' }) + + // Simulate the user scrolling the element on `/page`. + el.scrollTop = 100 + el.dispatchEvent(new Event('scroll')) + + // Navigating away snapshots the element's scroll position under `/page`. + router.emit({ + type: 'onBeforeLoad', + fromLocation: pageLocation, + toLocation: homeLocation, + pathChanged: true, + hrefChanged: true, + hashChanged: false, + }) + + // Re-render `/page` with a scroll-resetting navigation (PUSH). + router._scroll.next = true + router.emit({ + type: 'onRendered', + fromLocation: homeLocation, + toLocation: pageLocation, + pathChanged: true, + hrefChanged: true, + hashChanged: false, + }) + + // The element's restored scroll position must survive: the scroll-to-top + // fallback should not reset an element that was just restored. + expect(el.scrollTop).toBe(100) + expect(scrollTo).not.toHaveBeenCalledWith( + expect.objectContaining({ top: 0 }), + ) + + el.remove() + }) test.each([ ['omitted', undefined],