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
23 changes: 11 additions & 12 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@
"@shikijs/themes": "^3.20.0",
"@tanstack/react-query": "^5.90.12",
"@tanstack/react-query-devtools": "^5.91.1",
"@tanstack/react-router": "^1.143.6",
"@tanstack/react-router": "^1.170.8",
"@tanstack/router-core": "^1.171.6",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] @tanstack/router-core@1.171.6 no longer exports scrollRestorationCache, but web/src/lib/scrollStorageGuard.ts:7 imports it and uses .set. With this dependency bump, bun typecheck:web/vite build will fail on the missing export before the app ships.

Suggested fix:

"@tanstack/react-router": "1.145.6",
"@tanstack/router-core": "1.145.6"

Or keep 1.170.8 and update scrollStorageGuard to use exported API only:

import { storageKey } from '@tanstack/router-core'

const STORAGE_KEY = storageKey
// remove ScrollCacheUpdater/writeScrollRestorationCache and the scrollRestorationCache.set(...) sync path

"@xterm/addon-canvas": "^0.7.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
Expand Down
98 changes: 1 addition & 97 deletions web/src/lib/scrollStorageGuard.test.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,8 @@
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 { storageKey as STORAGE_KEY } from '@tanstack/router-core'

import { installScrollRestorationGuard } from './scrollStorageGuard'

const STORAGE_KEY = 'tsr-scroll-restoration-v1_3'
const RETAIN_COUNT = 50

class QuotaExceededError extends Error {
Expand Down Expand Up @@ -42,15 +33,12 @@ describe('installScrollRestorationGuard', () => {
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', () => {
Expand Down Expand Up @@ -144,63 +132,6 @@ 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 @@ -221,33 +152,6 @@ 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
77 changes: 13 additions & 64 deletions web/src/lib/scrollStorageGuard.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,6 @@
/**
* 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'
import { storageKey } from '@tanstack/router-core'

type ScrollCacheUpdater = NonNullable<Parameters<NonNullable<typeof scrollRestorationCache>['set']>[0]>

const STORAGE_KEY = 'tsr-scroll-restoration-v1_3'
const STORAGE_KEY = storageKey

const TARGET_ENTRIES_AFTER_PRUNE = 50

Expand All @@ -18,60 +10,22 @@ 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 {
function hardResetScrollRestorationPersistedState(storage: Storage): 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.
* survive quota exhaustion. The default throws synchronously during a React
* commit, blocking the UI (see tiann/hapi#611). We prune oldest entries and
* retry once; if still failing, we drop the key 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.
* Upstream >=1.145.6 also wraps setItem with try-catch, so this guard is an
* additional safety net that proactively keeps the cache small.
*/
export function installScrollRestorationGuard(
storage: Storage = typeof window !== 'undefined' ? window.sessionStorage : undefined as unknown as Storage,
Expand All @@ -84,7 +38,6 @@ export function installScrollRestorationGuard(
return () => {}
}
const originalSetItem = storage.setItem
const isRealSessionStorage = typeof window !== 'undefined' && storage === window.sessionStorage

const wrappedSetItem = (key: string, value: string): void => {
try {
Expand All @@ -97,29 +50,25 @@ export function installScrollRestorationGuard(
}

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 = {}
const next: Record<string, unknown> = {}
for (const k of keepKeys) {
prunedState[k] = parsed[k]
next[k] = parsed[k]
}
trimmed = JSON.stringify(prunedState)
trimmed = JSON.stringify(next)
} catch {
hardResetScrollRestorationPersistedState(storage, originalSetItem, isRealSessionStorage)
hardResetScrollRestorationPersistedState(storage)
return
}
try {
originalSetItem.call(storage, key, trimmed)
if (isRealSessionStorage) {
writeScrollRestorationCache(storage, originalSetItem, (() => prunedState) as ScrollCacheUpdater)
}
} catch {
hardResetScrollRestorationPersistedState(storage, originalSetItem, isRealSessionStorage)
hardResetScrollRestorationPersistedState(storage)
}
}
storage.setItem = wrappedSetItem
Expand Down
Loading