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>
134 lines
4.5 KiB
TypeScript
134 lines
4.5 KiB
TypeScript
/**
|
|
* Key TanStack Router uses for its scroll restoration cache in sessionStorage.
|
|
* Defined in `@tanstack/router-core/src/scroll-restoration.ts` (not part of
|
|
* 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
|
|
|
|
const GUARD_MARKER = '__hapiScrollRestorationGuard'
|
|
|
|
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 and reset TanStack's in-memory cache
|
|
* so navigation can continue.
|
|
*
|
|
* Idempotent — calling more than once on the same storage is a no-op.
|
|
*
|
|
* Returns an `uninstall` thunk that restores the original `setItem`. Intended
|
|
* for tests; production code calls this once at boot and never uninstalls.
|
|
*/
|
|
export function installScrollRestorationGuard(
|
|
storage: Storage = typeof window !== 'undefined' ? window.sessionStorage : undefined as unknown as Storage,
|
|
): () => void {
|
|
if (!storage) {
|
|
return () => {}
|
|
}
|
|
const guarded = storage as GuardedStorage
|
|
if (guarded[GUARD_MARKER]) {
|
|
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) {
|
|
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
|
|
prunedState = {}
|
|
for (const k of keepKeys) {
|
|
prunedState[k] = parsed[k]
|
|
}
|
|
trimmed = JSON.stringify(prunedState)
|
|
} catch {
|
|
hardResetScrollRestorationPersistedState(storage, originalSetItem, isRealSessionStorage)
|
|
return
|
|
}
|
|
try {
|
|
originalSetItem.call(storage, key, trimmed)
|
|
if (isRealSessionStorage) {
|
|
writeScrollRestorationCache(storage, originalSetItem, (() => prunedState) as ScrollCacheUpdater)
|
|
}
|
|
} catch {
|
|
hardResetScrollRestorationPersistedState(storage, originalSetItem, isRealSessionStorage)
|
|
}
|
|
}
|
|
storage.setItem = wrappedSetItem
|
|
guarded[GUARD_MARKER] = true
|
|
return () => {
|
|
if (storage.setItem === wrappedSetItem) {
|
|
storage.setItem = originalSetItem
|
|
delete guarded[GUARD_MARKER]
|
|
}
|
|
}
|
|
}
|