Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 151 additions & 0 deletions web/src/lib/scrollStorageGuard.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -32,12 +42,14 @@ describe('installScrollRestorationGuard', () => {
let uninstall: () => void

beforeEach(() => {
mockScrollCacheSet.mockClear()
storage = makeMockStorage()
uninstall = installScrollRestorationGuard(storage)
})

afterEach(() => {
uninstall()
vi.unstubAllGlobals()
vi.restoreAllMocks()
})

Expand All @@ -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 }))
Expand Down Expand Up @@ -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', () => {
Expand All @@ -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)
Expand Down
74 changes: 60 additions & 14 deletions web/src/lib/scrollStorageGuard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
*
Expand All @@ -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)
Comment thread
heavygee marked this conversation as resolved.
Comment thread
heavygee marked this conversation as resolved.
}
}
storage.setItem = wrappedSetItem
Expand All @@ -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')
)
}
Loading