import { renderHook, act } from '@testing-library/react-hooks' import { render } from '@testing-library/react' import { useLayoutEffect } from 'react' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' const mockLocation = { pathname: '/album', search: '' } const mockHistory = { action: 'PUSH' } vi.mock('react-router-dom', () => ({ useLocation: () => mockLocation, useHistory: () => mockHistory, })) import { useScrollRestoration } from './useScrollRestoration' describe('useScrollRestoration', () => { beforeEach(() => { window.scrollTo = vi.fn() window.scrollY = 0 mockLocation.pathname = '/album' mockLocation.search = '' mockHistory.action = 'PUSH' }) afterEach(() => { vi.useRealTimers() vi.clearAllMocks() }) const scrollTo = (y) => { window.scrollY = y act(() => { window.dispatchEvent(new Event('scroll')) }) } // Returns to a page whose rows have not arrived, so scrollTo still clamps. The returned setter // grows the page to the height it reaches once they do. const returnToUnfilledPage = (saved) => { vi.useFakeTimers() let maxScroll = saved window.scrollTo = vi.fn(({ top }) => { window.scrollY = Math.min(top, maxScroll) }) const first = renderHook(() => useScrollRestoration()) scrollTo(saved) first.unmount() maxScroll = 0 window.scrollY = 0 mockHistory.action = 'POP' renderHook(() => useScrollRestoration()) return (height) => (maxScroll = height) } const advanceFrames = () => act(() => vi.advanceTimersByTime(100)) it('starts a pushed route at the top', () => { renderHook(() => useScrollRestoration()) expect(window.scrollTo).toHaveBeenCalledWith({ top: 0 }) }) it('restores the saved offset when returning to an entry', () => { const first = renderHook(() => useScrollRestoration()) scrollTo(640) first.unmount() mockLocation.pathname = '/album/al-1/show' const second = renderHook(() => useScrollRestoration()) expect(window.scrollTo).toHaveBeenLastCalledWith({ top: 0 }) second.unmount() mockLocation.pathname = '/album' mockHistory.action = 'POP' renderHook(() => useScrollRestoration()) expect(window.scrollTo).toHaveBeenLastCalledWith({ top: 640 }) }) it('tops a pushed route even if that key was seen before', () => { const first = renderHook(() => useScrollRestoration()) scrollTo(500) first.unmount() mockHistory.action = 'PUSH' renderHook(() => useScrollRestoration()) expect(window.scrollTo).toHaveBeenLastCalledWith({ top: 0 }) }) // Restoring before the rows exist leaves the document too short: the browser clamps to the // top and the offset is lost with no error. it('waits for readiness before touching the scroll position', () => { const { rerender } = renderHook( ({ ready }) => useScrollRestoration(ready), { initialProps: { ready: false }, }, ) expect(window.scrollTo).not.toHaveBeenCalled() rerender({ ready: true }) expect(window.scrollTo).toHaveBeenCalledWith({ top: 0 }) }) // Hash history assigns no location.key, so an implementation keyed on it would collapse every // route into one slot and the detail page would erase the list's offset before we returned. it('keeps a separate offset per route', () => { const list = renderHook(() => useScrollRestoration()) scrollTo(900) list.unmount() mockLocation.pathname = '/album/al-1/show' const detail = renderHook(() => useScrollRestoration()) scrollTo(0) detail.unmount() mockLocation.pathname = '/album' mockHistory.action = 'POP' renderHook(() => useScrollRestoration()) expect(window.scrollTo).toHaveBeenLastCalledWith({ top: 900 }) }) // Without the per-instance guard this fires again when readiness flickers, fighting a user who // has already started scrolling. Nothing else in the suite pins it. it('does not scroll again when readiness flickers on the same route', () => { const { rerender } = renderHook( ({ ready }) => useScrollRestoration(ready), { initialProps: { ready: true }, }, ) expect(window.scrollTo).toHaveBeenCalledTimes(1) rerender({ ready: false }) rerender({ ready: true }) expect(window.scrollTo).toHaveBeenCalledTimes(1) }) it('scrolls once per entry, not on every re-render', () => { const { rerender } = renderHook(() => useScrollRestoration()) rerender() rerender() expect(window.scrollTo).toHaveBeenCalledTimes(1) }) // The artist page reports ready as soon as its header record is cached, while the albums below // are still loading. A single scrollTo lands on a viewport-tall document and is clamped to 0. it('keeps trying until the page is tall enough to hold the offset', () => { const fillPageTo = returnToUnfilledPage(800) expect(window.scrollY).toBe(0) fillPageTo(1200) advanceFrames() expect(window.scrollY).toBe(800) }) it('stops retrying once the user scrolls somewhere else', () => { const fillPageTo = returnToUnfilledPage(800) fillPageTo(1200) window.scrollY = 300 advanceFrames() expect(window.scrollY).toBe(300) }) // A trackpad back-swipe keeps firing wheel momentum through the restore, so treating input as // the yield signal cancels the gesture's own restore. it('keeps restoring while wheel momentum fires, as a back-swipe does', () => { const fillPageTo = returnToUnfilledPage(800) act(() => { for (let i = 0; i < 10; i++) { window.dispatchEvent( new WheelEvent('wheel', { deltaX: -30, deltaY: 0 }), ) } }) fillPageTo(1200) advanceFrames() expect(window.scrollY).toBe(800) }) // The incoming page tops itself in its own layout effect, while this page's scroll listener is // still attached. Banking the offset any later than the teardown records that 0 instead. it('records the offset before the next page scrolls to the top', () => { const Outgoing = () => { useScrollRestoration() return null } const Incoming = () => { useLayoutEffect(() => { window.scrollY = 0 window.dispatchEvent(new Event('scroll')) }, []) return null } const { rerender } = render() scrollTo(1400) mockLocation.pathname = '/album/al-1/show' rerender() mockLocation.pathname = '/album' mockHistory.action = 'POP' renderHook(() => useScrollRestoration()) expect(window.scrollTo).toHaveBeenLastCalledWith({ top: 1400 }) }) it('keeps saving after the restore, so leaving again remembers the new offset', () => { const first = renderHook(() => useScrollRestoration()) scrollTo(200) first.unmount() mockHistory.action = 'POP' const second = renderHook(() => useScrollRestoration()) expect(window.scrollTo).toHaveBeenLastCalledWith({ top: 200 }) scrollTo(900) second.unmount() renderHook(() => useScrollRestoration()) expect(window.scrollTo).toHaveBeenLastCalledWith({ top: 900 }) }) })