fix(web): bound scroll restoration cache by collapsing keys to pathname (#632)

This commit is contained in:
Junmo Kim
2026-05-18 09:09:54 +08:00
committed by GitHub
parent b2a30c2e39
commit 5512890a4c
6 changed files with 355 additions and 0 deletions
+111
View File
@@ -0,0 +1,111 @@
import { describe, expect, it } from 'vitest'
import { getScrollRestorationKey } from './scrollRestorationKey'
import type { ParsedLocation } from '@tanstack/react-router'
function makeLocation(overrides: Partial<ParsedLocation>): ParsedLocation {
return {
pathname: '/',
search: {},
searchStr: '',
hash: '',
href: '/',
state: {},
...overrides,
} as ParsedLocation
}
describe('getScrollRestorationKey', () => {
it('returns pathname for routes without per-query identity', () => {
expect(getScrollRestorationKey(makeLocation({ pathname: '/sessions' }))).toBe('/sessions')
expect(getScrollRestorationKey(makeLocation({ pathname: '/sessions/abc123' }))).toBe('/sessions/abc123')
expect(getScrollRestorationKey(makeLocation({ pathname: '/sessions/abc123/terminal' }))).toBe('/sessions/abc123/terminal')
expect(getScrollRestorationKey(makeLocation({ pathname: '/settings' }))).toBe('/settings')
expect(getScrollRestorationKey(makeLocation({ pathname: '/browse' }))).toBe('/browse')
})
it('differentiates file routes by the path search param', () => {
const fileA = makeLocation({
pathname: '/sessions/abc123/file',
search: { path: 'src/foo.ts' },
})
const fileB = makeLocation({
pathname: '/sessions/abc123/file',
search: { path: 'src/bar.ts' },
})
expect(getScrollRestorationKey(fileA)).toBe('/sessions/abc123/file?path=src/foo.ts')
expect(getScrollRestorationKey(fileB)).toBe('/sessions/abc123/file?path=src/bar.ts')
expect(getScrollRestorationKey(fileA)).not.toBe(getScrollRestorationKey(fileB))
})
it('falls back to pathname when file route has no path search param', () => {
const location = makeLocation({
pathname: '/sessions/abc123/file',
search: {},
})
expect(getScrollRestorationKey(location)).toBe('/sessions/abc123/file')
})
it('differentiates staged vs unstaged file diffs for the same path', () => {
const unstaged = makeLocation({
pathname: '/sessions/abc123/file',
search: { path: 'src/foo.ts' },
})
const staged = makeLocation({
pathname: '/sessions/abc123/file',
search: { path: 'src/foo.ts', staged: true },
})
const stagedFalse = makeLocation({
pathname: '/sessions/abc123/file',
search: { path: 'src/foo.ts', staged: false },
})
expect(getScrollRestorationKey(unstaged)).toBe('/sessions/abc123/file?path=src/foo.ts')
expect(getScrollRestorationKey(staged)).toBe('/sessions/abc123/file?path=src/foo.ts&staged=true')
// staged=false is the default and not included in the key (matches unstaged)
expect(getScrollRestorationKey(stagedFalse)).toBe('/sessions/abc123/file?path=src/foo.ts')
expect(getScrollRestorationKey(unstaged)).not.toBe(getScrollRestorationKey(staged))
})
it('differentiates browse route by machineId', () => {
const noMachine = makeLocation({ pathname: '/browse', search: {} })
const machineA = makeLocation({ pathname: '/browse', search: { machineId: 'm-aaa' } })
const machineB = makeLocation({ pathname: '/browse', search: { machineId: 'm-bbb' } })
expect(getScrollRestorationKey(noMachine)).toBe('/browse')
expect(getScrollRestorationKey(machineA)).toBe('/browse?machineId=m-aaa')
expect(getScrollRestorationKey(machineB)).toBe('/browse?machineId=m-bbb')
expect(getScrollRestorationKey(machineA)).not.toBe(getScrollRestorationKey(machineB))
})
it('differentiates files route by directories tab', () => {
const changes = makeLocation({
pathname: '/sessions/abc123/files',
search: { tab: 'changes' },
})
const directories = makeLocation({
pathname: '/sessions/abc123/files',
search: { tab: 'directories' },
})
expect(getScrollRestorationKey(changes)).toBe('/sessions/abc123/files')
expect(getScrollRestorationKey(directories)).toBe('/sessions/abc123/files?tab=directories')
expect(getScrollRestorationKey(changes)).not.toBe(getScrollRestorationKey(directories))
})
it('ignores history-entry-unique state.__TSR_key — same logical key for two history entries', () => {
const location1 = makeLocation({
pathname: '/sessions/abc123',
state: { __TSR_key: 'key_entry_1', __TSR_index: 0 },
})
const location2 = makeLocation({
pathname: '/sessions/abc123',
state: { __TSR_key: 'key_entry_2', __TSR_index: 1 },
})
expect(getScrollRestorationKey(location1)).toBe(getScrollRestorationKey(location2))
})
it('ignores hash', () => {
const location = makeLocation({
pathname: '/browse',
hash: '#section-2',
})
expect(getScrollRestorationKey(location)).toBe('/browse')
})
})
+37
View File
@@ -0,0 +1,37 @@
import type { ParsedLocation } from '@tanstack/react-router'
const FILE_ROUTE = /^\/sessions\/[^/]+\/file$/
const FILES_ROUTE = /^\/sessions\/[^/]+\/files$/
/**
* Derive the cache key TanStack Router uses to remember scroll positions.
*
* The default key (`location.state.__TSR_key`) is unique per history entry,
* so the cache grows without bound across navigations and eventually exhausts
* `sessionStorage` (~5 MB → QuotaExceededError that blocks React commit;
* see tiann/hapi#611).
*
* Returning `location.pathname` collapses navigations to the same route into
* one bucket. Routes whose visible content is identified by a search param
* (file diff path, files tab) include the relevant param so per-file/per-tab
* scroll positions are still remembered.
*/
export function getScrollRestorationKey(location: ParsedLocation): string {
const search = location.search as {
path?: unknown
staged?: unknown
tab?: unknown
machineId?: unknown
}
if (FILE_ROUTE.test(location.pathname) && typeof search.path === 'string') {
const stagedSuffix = search.staged === true ? '&staged=true' : ''
return `${location.pathname}?path=${search.path}${stagedSuffix}`
}
if (FILES_ROUTE.test(location.pathname) && search.tab === 'directories') {
return `${location.pathname}?tab=directories`
}
if (location.pathname === '/browse' && typeof search.machineId === 'string') {
return `${location.pathname}?machineId=${search.machineId}`
}
return location.pathname
}
+116
View File
@@ -0,0 +1,116 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { installScrollRestorationGuard } from './scrollStorageGuard'
const STORAGE_KEY = 'tsr-scroll-restoration-v1_3'
const RETAIN_COUNT = 50
class QuotaExceededError extends Error {
constructor() {
super('quota')
this.name = 'QuotaExceededError'
}
}
function makeMockStorage(): Storage & { _store: Record<string, string>; _setItem: ReturnType<typeof vi.fn> } {
const store: Record<string, string> = {}
const setItem = vi.fn((key: string, value: string) => { store[key] = value })
const storage = {
setItem,
getItem: (key: string) => store[key] ?? null,
removeItem: vi.fn((key: string) => { delete store[key] }),
clear: vi.fn(() => { for (const k of Object.keys(store)) delete store[k] }),
key: () => null,
length: 0,
} as unknown as Storage & { _store: Record<string, string>; _setItem: ReturnType<typeof vi.fn> }
storage._store = store
storage._setItem = setItem
return storage
}
describe('installScrollRestorationGuard', () => {
let storage: ReturnType<typeof makeMockStorage>
let uninstall: () => void
beforeEach(() => {
storage = makeMockStorage()
uninstall = installScrollRestorationGuard(storage)
})
afterEach(() => {
uninstall()
vi.restoreAllMocks()
})
it('passes through writes to keys other than the scroll restoration key unchanged on quota error', () => {
storage._setItem.mockImplementationOnce(() => { throw new QuotaExceededError() })
expect(() => storage.setItem('other-key', 'value')).toThrow(QuotaExceededError)
})
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 }))
})
it('prunes oldest entries to exactly the retain count and retries on quota error', () => {
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 QuotaExceededError()
}
storage._store[key] = value
})
storage.setItem(STORAGE_KEY, fullValue)
expect(storage._setItem).toHaveBeenCalledTimes(2)
const stored = JSON.parse(storage._store[STORAGE_KEY]) as Record<string, unknown>
const storedKeys = Object.keys(stored)
expect(storedKeys.length).toBe(RETAIN_COUNT)
expect(storedKeys).toContain('/route/99') // newest kept
expect(storedKeys).toContain('/route/50') // boundary kept
expect(storedKeys).not.toContain('/route/49') // boundary dropped
expect(storedKeys).not.toContain('/route/0') // oldest dropped
})
it('removes the key entirely if the value is not valid JSON', () => {
storage._setItem.mockImplementationOnce(() => { throw new QuotaExceededError() })
storage.setItem(STORAGE_KEY, 'not json {')
expect(storage.removeItem).toHaveBeenCalledWith(STORAGE_KEY)
})
it('removes the key entirely if the retried write also throws', () => {
const fullState: Record<string, unknown> = {}
for (let i = 0; i < 100; i++) {
fullState[`/route/${i}`] = { window: { scrollX: 0, scrollY: i } }
}
storage._setItem.mockImplementation(() => { throw new QuotaExceededError() })
storage.setItem(STORAGE_KEY, JSON.stringify(fullState))
expect(storage.removeItem).toHaveBeenCalledWith(STORAGE_KEY)
})
it('is idempotent — installing twice does not double-wrap', () => {
const wrapped1 = storage.setItem
const noop = installScrollRestorationGuard(storage)
const wrapped2 = storage.setItem
expect(wrapped2).toBe(wrapped1)
noop()
})
it('uninstall restores the original setItem', () => {
const fresh = makeMockStorage()
const original = fresh.setItem
const off = installScrollRestorationGuard(fresh)
expect(fresh.setItem).not.toBe(original)
off()
expect(fresh.setItem).toBe(original)
})
})
+87
View File
@@ -0,0 +1,87 @@
/**
* 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_*`).
*/
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
}
/**
* 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.
*
* 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 wrappedSetItem = (key: string, value: string): void => {
try {
originalSetItem.call(storage, key, value)
return
} catch (err) {
if (key !== STORAGE_KEY || !isQuotaError(err)) {
throw err
}
}
let trimmed: string
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> = {}
for (const k of keepKeys) {
next[k] = parsed[k]
}
trimmed = JSON.stringify(next)
} catch {
storage.removeItem(STORAGE_KEY)
return
}
try {
originalSetItem.call(storage, key, trimmed)
} catch {
storage.removeItem(STORAGE_KEY)
}
}
storage.setItem = wrappedSetItem
guarded[GUARD_MARKER] = true
return () => {
if (storage.setItem === wrappedSetItem) {
storage.setItem = originalSetItem
delete guarded[GUARD_MARKER]
}
}
}
function isQuotaError(err: unknown): boolean {
return (
err instanceof Error &&
(err.name === 'QuotaExceededError' || err.name === 'NS_ERROR_DOM_QUOTA_REACHED')
)
}
+2
View File
@@ -11,6 +11,7 @@ import { queryClient } from './lib/query-client'
import { createAppRouter } from './router'
import { I18nProvider } from './lib/i18n-context'
import { restoreSpaRedirect } from './lib/spaRedirect'
import { installScrollRestorationGuard } from './lib/scrollStorageGuard'
function getStartParam(): string | null {
const query = new URLSearchParams(window.location.search)
@@ -34,6 +35,7 @@ function getInitialPath(): string {
}
async function bootstrap() {
installScrollRestorationGuard()
initializeFontScale()
// Only load Telegram SDK in Telegram environment (with 3s timeout)
+2
View File
@@ -11,6 +11,7 @@ import {
useNavigate,
useParams,
} from '@tanstack/react-router'
import { getScrollRestorationKey } from '@/lib/scrollRestorationKey'
import { App } from '@/App'
import { SessionChat } from '@/components/SessionChat'
import { SessionList } from '@/components/SessionList'
@@ -666,6 +667,7 @@ export function createAppRouter(history?: RouterHistory) {
routeTree,
history,
scrollRestoration: true,
getScrollRestorationKey,
})
}