Skip to content

fix(web): #707 scroll guard still throws uncaught QuotaExceededError (setItem unwrap race) #716

@heavygee

Description

@heavygee

Summary

PR #707 (Fixes #708) is merged but insufficient. Uncaught QuotaExceededError on tsr-scroll-restoration-v1_3 still occurs in production on the current web bundle, tripping React's error boundary during normal session chat use.

This is a follow-up to the scroll-restoration quota chain:

Item Link What it did
Original report #611 sessionStorage quota exhaustion on TanStack scroll key crashes SPA
Pathname key collapse #632 getScrollRestorationKey → pathname to slow cache growth
In-memory cache gap #708 Prune-only guard left TanStack RAM cache oversized
Merged fix #707 Hard-reset + sync in-memory cache via scrollRestorationCache.set

The #707 fix introduced a temporary unwrap of sessionStorage.setItem that creates a race: TanStack can call scrollRestorationCache.set (which calls native setItem) while the guard is bypassed.


Evidence (production, not stale cache)

Observed on https://hapi.tail9944ee.ts.net after driver rebuild serving bundle index-BoTBCCy9.js (bundle contains __hapiScrollRestorationGuard — guard is installed).

Console log excerpt (session chat, 2026-05-27):

Uncaught QuotaExceededError: Failed to execute 'setItem' on 'Storage':
Setting the value of 'tsr-scroll-restoration-v1_3' exceeded the quota.
    at Object.set (index-BoTBCCy9.js:10:94081)
    at s (index-BoTBCCy9.js:10:95765)
    ...
setTimeout
scroll

Second occurrence (caught by error boundary / React DevTools hook):

QuotaExceededError: ... 'tsr-scroll-restoration-v1_3' ...
    at Object.set (index-BoTBCCy9.js:10:94081)
    at Object.fn (index-BoTBCCy9.js:10:96489)
    at Set.forEach
    at lR.emit
    at ref

Interpretation of stacks:

  1. Object.set = TanStack scrollRestorationCache.set in @tanstack/router-core/src/scroll-restoration.ts (line 71: safeSessionStorage.setItem(storageKey, JSON.stringify(state))).
  2. setTimeoutscroll = throttled document scroll listener in setupScrollRestoration (100ms throttle).
  3. emitref = router onRendered subscriber also calling scrollRestorationCache.set to mark location seen.

These paths call scrollRestorationCache.set directly, not only through the wrapped sessionStorage.setItem entry point.

HAR capture confirms the same JS bundle (index-BoTBCCy9.js) was loaded for the failing session URL.


Root cause

web/src/lib/scrollStorageGuard.tswriteScrollRestorationCache():

function writeScrollRestorationCache(storage, originalSetItem, updater) {
    const guardedSetItem = storage.setItem
    storage.setItem = originalSetItem   // <-- temporarily removes guard
    try {
        scrollRestorationCache?.set(updater)
    } finally {
        storage.setItem = guardedSetItem
    }
}

Called from:

  • Successful prune path (wrappedSetItem, after trimmed write succeeds)
  • Hard-reset path (hardResetScrollRestorationPersistedState)

Problem: While storage.setItem === originalSetItem, any concurrent TanStack scroll write (scrollRestorationCache.set from throttled scroll handler or onRendered) hits unwrapped native setItem. On quota exhaustion that throw is uncaught — it never enters wrappedSetItem's recovery logic.

The existing unit test hard reset calls scrollRestorationCache through unwrapped setItem documents that unwrap is intentional for in-memory sync, but there is no protection against concurrent TanStack writes during the unwrap window.

Additional unit test added locally (not yet upstream):

exposes unwrap window where concurrent TanStack scroll writes can throw uncaught — simulates TanStack calling setItem while writeScrollRestorationCache has unwrapped; demonstrates uncaught throw path.


Why #707 manual test plan item was never satisfied

PR #707 test plan included:

Manual: long session chat, scroll, navigate until storage is stressed; confirm no uncaught setItem / no white-screen error boundary

That step was unchecked at merge. Playwright stress tests against local + production hub with artificial sessionStorage fill often pass (guard handles wrapped-path recovery), but the race is intermittent and matches real operator profiles with accumulated storage + active scrolling during recovery.


Suggested fix directions (for follow-up PR)

Do not temporarily assign storage.setItem = originalSetItem. Options (pick one, verify with tests):

  1. Patch scrollRestorationCache.set directly — update in-memory state without persisting, then persist once through the wrapped setItem (or call originalSetItem only from inside wrappedSetItem where recovery already handles failures).

  2. Re-entrant lock / depth counter — if guard recovery is in progress, route all scrollRestorationCache.set persistence through wrappedSetItem even when syncing RAM (never expose native setItem on the shared sessionStorage object).

  3. Upstream belt-and-suspendersUpgrade @tanstack/react-router — upstream already has built-in scroll-restoration quota fix #683 tracks upgrading @tanstack/react-router; upstream may wrap setItem in try/catch, but HAPI should not rely on that alone given the unwrap race.

  4. Reduce concurrent writers — optional: disable or debounce scroll restoration persist while hard-reset runs (harder to reason about; prefer fixing unwrap).


Reproduction / verification checklist for fix PR

# Unit tests (extend scrollStorageGuard.test.ts)
cd web && bun run test src/lib/scrollStorageGuard.test.ts

# Playwright stress (repo root; uses system Chrome on Linux)
HAPI_URL=https://your-hub HAPI_ACCESS_TOKEN=... \
  node scripts/dev/scroll-quota-repro-playwright.mjs \
  --session <session-id> --fill-mb 4.5

# Must add: concurrent-write race test (see test name above)
# Must pass: no pageerror/console QuotaExceededError on tsr-scroll-restoration-v1_3

Manual (required): Long-lived browser profile with stressed sessionStorage, session chat open, scroll + navigate while storage near quota — confirm zero uncaught QuotaExceededError and no React error boundary.


Related

Files

  • web/src/lib/scrollStorageGuard.ts — bug location (writeScrollRestorationCache)
  • web/src/main.tsxinstallScrollRestorationGuard() at bootstrap (guard IS installed; not a boot-order bug)
  • node_modules/@tanstack/router-core/src/scroll-restoration.ts — TanStack persist + scroll listeners
  • web/src/lib/scrollStorageGuard.test.ts — existing + proposed race test

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions