diff --git a/web/src/lib/scrollStorageGuard.test.ts b/web/src/lib/scrollStorageGuard.test.ts index fecfffd8c..14d0871b0 100644 --- a/web/src/lib/scrollStorageGuard.test.ts +++ b/web/src/lib/scrollStorageGuard.test.ts @@ -1,4 +1,14 @@ 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' @@ -32,12 +42,14 @@ describe('installScrollRestorationGuard', () => { let uninstall: () => void beforeEach(() => { + mockScrollCacheSet.mockClear() storage = makeMockStorage() uninstall = installScrollRestorationGuard(storage) }) afterEach(() => { uninstall() + vi.unstubAllGlobals() vi.restoreAllMocks() }) @@ -46,6 +58,61 @@ describe('installScrollRestorationGuard', () => { 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 = {} + 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 = {} + 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 })) @@ -77,6 +144,63 @@ describe('installScrollRestorationGuard', () => { 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 = {} + 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 + ) => Record + 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', () => { @@ -97,6 +221,33 @@ describe('installScrollRestorationGuard', () => { 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 + ) => Record + 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) diff --git a/web/src/lib/scrollStorageGuard.ts b/web/src/lib/scrollStorageGuard.ts index afe970635..80679fb86 100644 --- a/web/src/lib/scrollStorageGuard.ts +++ b/web/src/lib/scrollStorageGuard.ts @@ -4,6 +4,10 @@ * the package's public API — update this constant if the library bumps the * suffix on `tsr-scroll-restoration-v1_*`). */ +import { scrollRestorationCache } from '@tanstack/router-core' + +type ScrollCacheUpdater = NonNullable['set']>[0]> + const STORAGE_KEY = 'tsr-scroll-restoration-v1_3' const TARGET_ENTRIES_AFTER_PRUNE = 50 @@ -14,13 +18,55 @@ interface GuardedStorage extends Storage { [GUARD_MARKER]?: true } +function writeScrollRestorationCache( + storage: Storage, + originalSetItem: Storage['setItem'], + updater: ScrollCacheUpdater, +): void { + const guardedSetItem = storage.setItem + storage.setItem = originalSetItem + try { + scrollRestorationCache?.set(updater) + } finally { + storage.setItem = guardedSetItem + } +} + +function hardResetScrollRestorationPersistedState( + storage: Storage, + originalSetItem: Storage['setItem'], + isRealSessionStorage: boolean +): void { + try { + storage.removeItem(STORAGE_KEY) + } catch { + // ignore + } + if (!isRealSessionStorage) { + return + } + // TanStack keeps the full scroll map in memory even when setItem fails. + // Pruning only the JSON string leaves RAM oversized — the next scroll + // write throws again. Clear the library cache so persisted size matches. + try { + writeScrollRestorationCache(storage, originalSetItem, () => ({})) + } catch { + try { + originalSetItem.call(storage, STORAGE_KEY, '{}') + } catch { + // last resort: session may be full or private-mode broken + } + } +} + /** * Wrap `sessionStorage.setItem` so writes to the scroll restoration cache * survive quota exhaustion. The default behavior throws synchronously during * a React commit, blocking the UI (see tiann/hapi#611). We prune the oldest * entries (by JSON property insertion order — i.e. visited-first dropped, * recently-visited kept) and retry once; if the value is not valid JSON or - * the retry still fails, we drop the key entirely so navigation can continue. + * the retry still fails, we drop the key and reset TanStack's in-memory cache + * so navigation can continue. * * Idempotent — calling more than once on the same storage is a no-op. * @@ -38,35 +84,42 @@ export function installScrollRestorationGuard( return () => {} } const originalSetItem = storage.setItem + const isRealSessionStorage = typeof window !== 'undefined' && storage === window.sessionStorage + const wrappedSetItem = (key: string, value: string): void => { try { originalSetItem.call(storage, key, value) return } catch (err) { - if (key !== STORAGE_KEY || !isQuotaError(err)) { + if (key !== STORAGE_KEY) { throw err } } + let trimmed: string + let prunedState: Record try { const parsed = JSON.parse(value) as Record const keys = Object.keys(parsed) const keepKeys = keys.length > TARGET_ENTRIES_AFTER_PRUNE ? keys.slice(-TARGET_ENTRIES_AFTER_PRUNE) : keys - const next: Record = {} + prunedState = {} for (const k of keepKeys) { - next[k] = parsed[k] + prunedState[k] = parsed[k] } - trimmed = JSON.stringify(next) + trimmed = JSON.stringify(prunedState) } catch { - storage.removeItem(STORAGE_KEY) + hardResetScrollRestorationPersistedState(storage, originalSetItem, isRealSessionStorage) return } try { originalSetItem.call(storage, key, trimmed) + if (isRealSessionStorage) { + writeScrollRestorationCache(storage, originalSetItem, (() => prunedState) as ScrollCacheUpdater) + } } catch { - storage.removeItem(STORAGE_KEY) + hardResetScrollRestorationPersistedState(storage, originalSetItem, isRealSessionStorage) } } storage.setItem = wrappedSetItem @@ -78,10 +131,3 @@ export function installScrollRestorationGuard( } } } - -function isQuotaError(err: unknown): boolean { - return ( - err instanceof Error && - (err.name === 'QuotaExceededError' || err.name === 'NS_ERROR_DOM_QUOTA_REACHED') - ) -}