// @vitest-environment jsdom import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; import { recordScroll, restoreScroll } from '../../src/lib/client/scroll-restore'; const STORAGE_KEY = 'kochwas:scroll'; function setScrollY(y: number) { Object.defineProperty(window, 'scrollY', { value: y, configurable: true }); } function setDocHeight(h: number) { Object.defineProperty(document.documentElement, 'scrollHeight', { value: h, configurable: true }); } function setViewportHeight(h: number) { Object.defineProperty(window, 'innerHeight', { value: h, configurable: true }); } function url(path: string): URL { return new URL(path, 'https://example.test'); } describe('scroll-restore', () => { let scrollToSpy: ReturnType; beforeEach(() => { sessionStorage.clear(); setScrollY(0); setViewportHeight(800); setDocHeight(800); scrollToSpy = vi .spyOn(window, 'scrollTo') .mockImplementation(() => undefined as unknown as void); }); afterEach(() => { scrollToSpy.mockRestore(); vi.useRealTimers(); }); it('records scrollY keyed by from-url pathname+search', () => { setScrollY(1200); recordScroll(url('/wishlist')); const map = JSON.parse(sessionStorage.getItem(STORAGE_KEY) ?? '{}'); expect(map['/wishlist']).toBe(1200); }); it('keeps separate entries per URL', () => { setScrollY(500); recordScroll(url('/wishlist')); setScrollY(900); recordScroll(url('/?q=hi')); const map = JSON.parse(sessionStorage.getItem(STORAGE_KEY) ?? '{}'); expect(map['/wishlist']).toBe(500); expect(map['/?q=hi']).toBe(900); }); it('does not overwrite a stored URL when called with a different from-url', () => { // This is the regression: on popstate, location.pathname is already // the new URL. Recording must use nav.from.url (the page being left), // not location, or we wipe the destination's saved scrollY. setScrollY(500); recordScroll(url('/')); setScrollY(0); recordScroll(url('/recipes/1')); const map = JSON.parse(sessionStorage.getItem(STORAGE_KEY) ?? '{}'); expect(map['/']).toBe(500); expect(map['/recipes/1']).toBe(0); }); it('skips when from-url is missing', () => { setScrollY(900); recordScroll(null); expect(sessionStorage.getItem(STORAGE_KEY)).toBeNull(); }); it('skips restore for non-popstate navigation', () => { sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ '/wishlist': 1000 })); setDocHeight(5000); restoreScroll('link', url('/wishlist')); expect(scrollToSpy).not.toHaveBeenCalled(); }); it('skips restore when to-url is missing', () => { sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ '/wishlist': 1000 })); restoreScroll('popstate', null); expect(scrollToSpy).not.toHaveBeenCalled(); }); it('skips restore when no entry stored', () => { setDocHeight(5000); restoreScroll('popstate', url('/wishlist')); expect(scrollToSpy).not.toHaveBeenCalled(); }); it('skips restore for trivial scrollY (noise)', () => { sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ '/wishlist': 10 })); setDocHeight(5000); restoreScroll('popstate', url('/wishlist')); expect(scrollToSpy).not.toHaveBeenCalled(); }); it('scrolls immediately when document is already tall enough', async () => { sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ '/wishlist': 1000 })); setDocHeight(5000); restoreScroll('popstate', url('/wishlist')); await new Promise((r) => requestAnimationFrame(() => r(null))); expect(scrollToSpy).toHaveBeenCalledWith({ top: 1000, left: 0, behavior: 'instant' }); }); it('waits via rAF until document grows tall enough', async () => { sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ '/wishlist': 1500 })); setDocHeight(900); restoreScroll('popstate', url('/wishlist')); await new Promise((r) => requestAnimationFrame(() => r(null))); expect(scrollToSpy).not.toHaveBeenCalled(); setDocHeight(3000); await new Promise((r) => requestAnimationFrame(() => r(null))); expect(scrollToSpy).toHaveBeenCalledWith({ top: 1500, left: 0, behavior: 'instant' }); }); });