mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
* fix(web): reset scroll restoration cache when sessionStorage is full TanStack keeps scroll state in RAM; pruning only the JSON blob did not stop repeat quota throws. On persist failure for the scroll key, clear the library cache when guarding real sessionStorage. Treat any write error on that key (not only QuotaExceededError-shaped) as recoverable. Co-authored-by: Cursor <cursoragent@cursor.com> * test(web): cover scroll cache hard reset and non-quota recovery Exercise TanStack scrollRestorationCache reset on real sessionStorage, mock-storage isolation, and generic storage write failures for #708. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): sync pruned scroll cache and avoid hard-reset recursion After a successful storage prune, align TanStack's in-memory scrollRestorationCache with the trimmed payload. Temporarily unwrap sessionStorage.setItem when writing through the library cache so hard reset cannot recurse through the guard (Codex PR review #707). Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
268 lines
10 KiB
TypeScript
268 lines
10 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
const mockScrollCacheSet = vi.hoisted(() => vi.fn())
|
|
|
|
vi.mock('@tanstack/router-core', () => ({
|
|
scrollRestorationCache: {
|
|
state: {},
|
|
set: mockScrollCacheSet,
|
|
},
|
|
}))
|
|
|
|
import { installScrollRestorationGuard } from './scrollStorageGuard'
|
|
|
|
const STORAGE_KEY = 'tsr-scroll-restoration-v1_3'
|
|
const RETAIN_COUNT = 50
|
|
|
|
class QuotaExceededError extends Error {
|
|
constructor() {
|
|
super('quota')
|
|
this.name = 'QuotaExceededError'
|
|
}
|
|
}
|
|
|
|
function makeMockStorage(): Storage & { _store: Record<string, string>; _setItem: ReturnType<typeof vi.fn> } {
|
|
const store: Record<string, string> = {}
|
|
const setItem = vi.fn((key: string, value: string) => { store[key] = value })
|
|
const storage = {
|
|
setItem,
|
|
getItem: (key: string) => store[key] ?? null,
|
|
removeItem: vi.fn((key: string) => { delete store[key] }),
|
|
clear: vi.fn(() => { for (const k of Object.keys(store)) delete store[k] }),
|
|
key: () => null,
|
|
length: 0,
|
|
} as unknown as Storage & { _store: Record<string, string>; _setItem: ReturnType<typeof vi.fn> }
|
|
storage._store = store
|
|
storage._setItem = setItem
|
|
return storage
|
|
}
|
|
|
|
describe('installScrollRestorationGuard', () => {
|
|
let storage: ReturnType<typeof makeMockStorage>
|
|
let uninstall: () => void
|
|
|
|
beforeEach(() => {
|
|
mockScrollCacheSet.mockClear()
|
|
storage = makeMockStorage()
|
|
uninstall = installScrollRestorationGuard(storage)
|
|
})
|
|
|
|
afterEach(() => {
|
|
uninstall()
|
|
vi.unstubAllGlobals()
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
it('passes through writes to keys other than the scroll restoration key unchanged on quota error', () => {
|
|
storage._setItem.mockImplementationOnce(() => { throw new QuotaExceededError() })
|
|
expect(() => storage.setItem('other-key', 'value')).toThrow(QuotaExceededError)
|
|
})
|
|
|
|
it('recovers from any write failure on the scroll key, not only quota errors', () => {
|
|
class GenericStorageError extends Error {
|
|
constructor() {
|
|
super('storage write failed')
|
|
this.name = 'SecurityError'
|
|
}
|
|
}
|
|
const fullState: Record<string, unknown> = {}
|
|
for (let i = 0; i < 100; i++) {
|
|
fullState[`/route/${i}`] = { window: { scrollX: 0, scrollY: i } }
|
|
}
|
|
const fullValue = JSON.stringify(fullState)
|
|
|
|
let call = 0
|
|
storage._setItem.mockImplementation((key: string, value: string) => {
|
|
call += 1
|
|
if (call === 1) {
|
|
throw new GenericStorageError()
|
|
}
|
|
storage._store[key] = value
|
|
})
|
|
|
|
storage.setItem(STORAGE_KEY, fullValue)
|
|
|
|
expect(storage._setItem).toHaveBeenCalledTimes(2)
|
|
expect(Object.keys(JSON.parse(storage._store[STORAGE_KEY]) as object).length).toBe(RETAIN_COUNT)
|
|
})
|
|
|
|
it('handles quota errors that are not instanceof Error (DOMException-shaped)', () => {
|
|
const domExceptionLike = {
|
|
name: 'QuotaExceededError',
|
|
message: "Failed to execute 'setItem' on 'Storage': Setting the value of 'tsr-scroll-restoration-v1_3' exceeded the quota."
|
|
}
|
|
const fullState: Record<string, unknown> = {}
|
|
for (let i = 0; i < 100; i++) {
|
|
fullState[`/route/${i}`] = { window: { scrollX: 0, scrollY: i } }
|
|
}
|
|
const fullValue = JSON.stringify(fullState)
|
|
|
|
let call = 0
|
|
storage._setItem.mockImplementation((key: string, value: string) => {
|
|
call += 1
|
|
if (call === 1) {
|
|
throw domExceptionLike
|
|
}
|
|
storage._store[key] = value
|
|
})
|
|
|
|
expect(domExceptionLike instanceof Error).toBe(false)
|
|
storage.setItem(STORAGE_KEY, fullValue)
|
|
|
|
expect(storage._setItem).toHaveBeenCalledTimes(2)
|
|
expect(Object.keys(JSON.parse(storage._store[STORAGE_KEY]) as object).length).toBe(RETAIN_COUNT)
|
|
})
|
|
|
|
it('passes through scroll restoration writes that succeed', () => {
|
|
storage.setItem(STORAGE_KEY, JSON.stringify({ a: 1 }))
|
|
expect(storage._store[STORAGE_KEY]).toBe(JSON.stringify({ a: 1 }))
|
|
})
|
|
|
|
it('prunes oldest entries to exactly the retain count and retries on quota error', () => {
|
|
const fullState: Record<string, unknown> = {}
|
|
for (let i = 0; i < 100; i++) {
|
|
fullState[`/route/${i}`] = { window: { scrollX: 0, scrollY: i } }
|
|
}
|
|
const fullValue = JSON.stringify(fullState)
|
|
|
|
let call = 0
|
|
storage._setItem.mockImplementation((key: string, value: string) => {
|
|
call += 1
|
|
if (call === 1) {
|
|
throw new QuotaExceededError()
|
|
}
|
|
storage._store[key] = value
|
|
})
|
|
|
|
storage.setItem(STORAGE_KEY, fullValue)
|
|
|
|
expect(storage._setItem).toHaveBeenCalledTimes(2)
|
|
const stored = JSON.parse(storage._store[STORAGE_KEY]) as Record<string, unknown>
|
|
const storedKeys = Object.keys(stored)
|
|
expect(storedKeys.length).toBe(RETAIN_COUNT)
|
|
expect(storedKeys).toContain('/route/99') // newest kept
|
|
expect(storedKeys).toContain('/route/50') // boundary kept
|
|
expect(storedKeys).not.toContain('/route/49') // boundary dropped
|
|
expect(storedKeys).not.toContain('/route/0') // oldest dropped
|
|
expect(mockScrollCacheSet).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('syncs TanStack in-memory scroll cache after a successful prune on real sessionStorage', () => {
|
|
const realSessionStorage = makeMockStorage()
|
|
vi.stubGlobal('window', { sessionStorage: realSessionStorage })
|
|
|
|
const off = installScrollRestorationGuard(realSessionStorage)
|
|
|
|
const fullState: Record<string, unknown> = {}
|
|
for (let i = 0; i < 100; i++) {
|
|
fullState[`/route/${i}`] = { window: { scrollX: 0, scrollY: i } }
|
|
}
|
|
const fullValue = JSON.stringify(fullState)
|
|
|
|
let call = 0
|
|
realSessionStorage._setItem.mockImplementation((key: string, value: string) => {
|
|
call += 1
|
|
if (call === 1) {
|
|
throw new QuotaExceededError()
|
|
}
|
|
realSessionStorage._store[key] = value
|
|
})
|
|
|
|
realSessionStorage.setItem(STORAGE_KEY, fullValue)
|
|
|
|
expect(mockScrollCacheSet).toHaveBeenCalledTimes(1)
|
|
const updater = mockScrollCacheSet.mock.calls[0]![0] as (
|
|
state: Record<string, unknown>
|
|
) => Record<string, unknown>
|
|
const synced = updater(fullState)
|
|
expect(Object.keys(synced).length).toBe(RETAIN_COUNT)
|
|
expect(Object.keys(synced)).toContain('/route/99')
|
|
expect(Object.keys(synced)).not.toContain('/route/0')
|
|
|
|
off()
|
|
})
|
|
|
|
it('hard reset calls scrollRestorationCache through unwrapped setItem', () => {
|
|
const realSessionStorage = makeMockStorage()
|
|
vi.stubGlobal('window', { sessionStorage: realSessionStorage })
|
|
|
|
const off = installScrollRestorationGuard(realSessionStorage)
|
|
const wrappedSetItem = realSessionStorage.setItem
|
|
|
|
let cacheWriteSetItem: Storage['setItem'] | undefined
|
|
mockScrollCacheSet.mockImplementationOnce(() => {
|
|
cacheWriteSetItem = realSessionStorage.setItem
|
|
})
|
|
|
|
realSessionStorage._setItem.mockImplementationOnce(() => { throw new QuotaExceededError() })
|
|
realSessionStorage.setItem(STORAGE_KEY, 'not json {')
|
|
|
|
expect(cacheWriteSetItem).toBeDefined()
|
|
expect(cacheWriteSetItem).not.toBe(wrappedSetItem)
|
|
|
|
off()
|
|
})
|
|
|
|
it('removes the key entirely if the value is not valid JSON', () => {
|
|
storage._setItem.mockImplementationOnce(() => { throw new QuotaExceededError() })
|
|
storage.setItem(STORAGE_KEY, 'not json {')
|
|
expect(storage.removeItem).toHaveBeenCalledWith(STORAGE_KEY)
|
|
})
|
|
|
|
it('removes the key entirely if the retried write also throws', () => {
|
|
const fullState: Record<string, unknown> = {}
|
|
for (let i = 0; i < 100; i++) {
|
|
fullState[`/route/${i}`] = { window: { scrollX: 0, scrollY: i } }
|
|
}
|
|
storage._setItem.mockImplementation(() => { throw new QuotaExceededError() })
|
|
|
|
storage.setItem(STORAGE_KEY, JSON.stringify(fullState))
|
|
|
|
expect(storage.removeItem).toHaveBeenCalledWith(STORAGE_KEY)
|
|
})
|
|
|
|
it('does not reset TanStack scroll cache when guarding mock storage', () => {
|
|
storage._setItem.mockImplementationOnce(() => { throw new QuotaExceededError() })
|
|
storage.setItem(STORAGE_KEY, 'not json {')
|
|
|
|
expect(storage.removeItem).toHaveBeenCalledWith(STORAGE_KEY)
|
|
expect(mockScrollCacheSet).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('resets TanStack in-memory scroll cache when hard reset uses real sessionStorage', () => {
|
|
const realSessionStorage = makeMockStorage()
|
|
vi.stubGlobal('window', { sessionStorage: realSessionStorage })
|
|
|
|
const off = installScrollRestorationGuard(realSessionStorage)
|
|
realSessionStorage._setItem.mockImplementation(() => { throw new QuotaExceededError() })
|
|
|
|
realSessionStorage.setItem(STORAGE_KEY, JSON.stringify({ stale: { window: { scrollX: 0, scrollY: 1 } } }))
|
|
|
|
expect(realSessionStorage.removeItem).toHaveBeenCalledWith(STORAGE_KEY)
|
|
expect(mockScrollCacheSet).toHaveBeenCalledTimes(1)
|
|
const updater = mockScrollCacheSet.mock.calls[0]![0] as (
|
|
state: Record<string, unknown>
|
|
) => Record<string, unknown>
|
|
expect(updater({ stale: { window: { scrollX: 0, scrollY: 1 } } })).toEqual({})
|
|
|
|
off()
|
|
})
|
|
|
|
it('is idempotent — installing twice does not double-wrap', () => {
|
|
const wrapped1 = storage.setItem
|
|
const noop = installScrollRestorationGuard(storage)
|
|
const wrapped2 = storage.setItem
|
|
expect(wrapped2).toBe(wrapped1)
|
|
noop()
|
|
})
|
|
|
|
it('uninstall restores the original setItem', () => {
|
|
const fresh = makeMockStorage()
|
|
const original = fresh.setItem
|
|
const off = installScrollRestorationGuard(fresh)
|
|
expect(fresh.setItem).not.toBe(original)
|
|
off()
|
|
expect(fresh.setItem).toBe(original)
|
|
})
|
|
})
|