From 759214eabdea8746d33f8ee475bddffcde0cbf85 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Wed, 13 May 2026 09:26:01 +0100 Subject: [PATCH] fix(webapp): Evict legacy resizable-panel localStorage on client boot (#3564) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Users on production are hitting `QuotaExceededError: Failed to execute 'setItem' on 'Storage'` when navigating runs, because their localStorage is full of orphaned `panel-group-react-aria-::` entries. - Each entry is a session-unique key written by the resizable panel library; they accumulated to thousands per user over the last two months and now block legitimate `setItem` calls (the run-view inspector can no longer persist its layout, and the page crashes mid-render). - This PR evicts the legacy entries once on client boot. The leak itself is already plugged by the v1.1.3 upgrade in #XXXX — this is the cleanup that recovers the wasted quota on existing users' machines. ## Root cause (already fixed, for context) In v0.4.1 of the underlying library, `PanelGroupImpl` defaulted `autosaveStrategy` to `"localStorage"` unconditionally — so *every* `PanelGroup` wrote to localStorage on every autosave trigger, including the four in `QueryEditor`, the one in `ReplayRunDialog`, the storybook routes, etc. Without an `autosaveId`, the key fell back to `panel-group-${useId()}`, and React Aria's `useId()` produces a new session-unique prefix each visit. Result: entries accumulated without bound across sessions. The condition was introduced when [#3282](https://github.com/triggerdotdev/trigger.dev/pull/3282) removed the wrapper's explicit `autosaveStrategy="cookie"` override (to fix HTTP 431 cookie-size errors). That worked, but the library default that took over silently caused this leak. The v1.1.3 upgrade in the resizable-panel PR changed the default to `autosaveStrategy = autosaveId ? "localStorage" : undefined`, so no new entries are being written. Existing residue still needs to be removed from users' browsers. ## Changes - New file [`apps/webapp/app/clientBeforeFirstRender.ts`](apps/webapp/app/clientBeforeFirstRender.ts) — exports a `clientBeforeFirstRender()` function that runs synchronously, before React hydrates. Encapsulates a small cleanup helper that scans `localStorage` and removes: - Every key starting with `panel-group-react-aria` (the legacy auto-generated keys). - The orphan `panel-run-parent-v2` key from before the autosaveId v2→v3 bump. - [`apps/webapp/app/entry.client.tsx`](apps/webapp/app/entry.client.tsx) — imports and invokes `clientBeforeFirstRender()` once, before `hydrateRoot()`. This guarantees the cleanup completes before any `ResizablePanelGroup` mounts and tries to write. The cleanup is wrapped in `try/catch` so private-browsing / disabled-storage scenarios fail silently. Idempotent: subsequent loads find no matching keys and exit immediately. ## Test plan - [x] Locally seed ~50 fake `panel-group-react-aria…` entries plus a `panel-run-parent-v2` entry via DevTools console, hard reload → legacy entries gone, real entries (`panel-run-parent-v3`, `panel-run-tree`) preserved. - [x] Idempotency: reload a second time, no errors, no state changes. - [x] Add a control entry (`panel-run-parent-v3-but-different-suffix`) — confirmed not over-matched. - [x] Simulate broken `Storage.setItem` throwing — page still renders, cleanup swallows the error. - [x] Typecheck clean. ## Notes - Customer report: `QuotaExceededError: Failed to execute 'setItem' on 'Storage': Setting the value of 'panel-run-parent-v3' exceeded the quota.` - The cleanup runs once per page load. Once a user has loaded the app after this deploys, their localStorage is clean and the function becomes a no-op forever. --- apps/webapp/app/clientBeforeFirstRender.ts | 38 ++++++++++++++++++++++ apps/webapp/app/entry.client.tsx | 3 ++ 2 files changed, 41 insertions(+) create mode 100644 apps/webapp/app/clientBeforeFirstRender.ts diff --git a/apps/webapp/app/clientBeforeFirstRender.ts b/apps/webapp/app/clientBeforeFirstRender.ts new file mode 100644 index 000000000..3275c5442 --- /dev/null +++ b/apps/webapp/app/clientBeforeFirstRender.ts @@ -0,0 +1,38 @@ +/** + * Runs once on the client, synchronously, before React hydrates the app. + * Reserved for housekeeping that must happen before any component mounts. + */ +export function clientBeforeFirstRender() { + cleanupLegacyResizablePanelStorage(); +} + +/** + * Earlier versions of the resizable panel library wrote a per-session + * localStorage entry for every PanelGroup, including ones without an + * `autosaveId`. The keys look like `panel-group-react-aria-::` + * and accumulate without bound across sessions until they exhaust the + * ~5 MB origin quota and break subsequent `setItem` calls. + * + * The library no longer behaves this way, but existing users still carry + * the residue. Evict it (plus the orphaned `panel-run-parent-v2` key from + * the v2→v3 autosaveId bump) once on load. + */ +function cleanupLegacyResizablePanelStorage() { + try { + const toRemove: string[] = []; + for (let i = 0; i < window.localStorage.length; i++) { + const key = window.localStorage.key(i); + if ( + key && + (key.startsWith("panel-group-react-aria") || key === "panel-run-parent-v2") + ) { + toRemove.push(key); + } + } + for (const key of toRemove) { + window.localStorage.removeItem(key); + } + } catch { + // localStorage may be disabled (private browsing, security policy) + } +} diff --git a/apps/webapp/app/entry.client.tsx b/apps/webapp/app/entry.client.tsx index 46c2919b8..e0d0f0ac0 100644 --- a/apps/webapp/app/entry.client.tsx +++ b/apps/webapp/app/entry.client.tsx @@ -1,8 +1,11 @@ import { RemixBrowser } from "@remix-run/react"; import { hydrateRoot } from "react-dom/client"; +import { clientBeforeFirstRender } from "./clientBeforeFirstRender"; import { LocaleContextProvider } from "./components/primitives/LocaleProvider"; import { OperatingSystemContextProvider } from "./components/primitives/OperatingSystemProvider"; +clientBeforeFirstRender(); + hydrateRoot( document,