mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
fix(web): reset scroll restoration when sessionStorage quota hits (#707)
* 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>
This commit is contained in:
@@ -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<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 }))
|
||||
@@ -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<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', () => {
|
||||
@@ -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<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)
|
||||
|
||||
@@ -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<Parameters<NonNullable<typeof scrollRestorationCache>['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<string, unknown>
|
||||
try {
|
||||
const parsed = JSON.parse(value) as Record<string, unknown>
|
||||
const keys = Object.keys(parsed)
|
||||
const keepKeys = keys.length > TARGET_ENTRIES_AFTER_PRUNE
|
||||
? keys.slice(-TARGET_ENTRIES_AFTER_PRUNE)
|
||||
: keys
|
||||
const next: Record<string, unknown> = {}
|
||||
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')
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user