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'))
})
}
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 })
})
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 })
})
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 })
})
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)
})
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)
})
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)
})
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(<Outgoing />)
scrollTo(1400)
mockLocation.pathname = '/album/al-1/show'
rerender(<Incoming />)
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 })
})
})