fix(desktop): prevent sidebar localStorage state loss and token-swap remounts
Summary
- Hardens all six localStorage-backed sidebar collections so a write can never erase previously persisted rows, and one corrupt row can never blank the whole store on load.
- Removes the manufactured signed-out window during bearer-token rotation that unmounted the entire authenticated tree (sidebar included) mid-session.
Why / Context
Investigation into the dashboard sidebar "randomly losing state" found two independent failure classes in @tanstack/db 0.6.14's localStorage collections (verified against the shipped library source):
- Insert-on-idle wipes the store. Collections hydrate lazily;
.state/.get()on a never-synced collection silently return empty, andinsert()doesn't auto-start sync foridlecollections. Every mutation re-serializes the entire store from memory, so an insert before hydration rewrotev2-workspace-local-state-<orgId>(etc.) from near-empty memory — permanently erasing all placement rows, pins, sections, and pane layouts for the org. The default 5-minute GC reintroduces the same hazard after subscribers unmount. - One corrupt entry blanks hydration. The library loads the whole store inside a single try/catch that returns an empty map: one malformed entry (or a heal-function throw) discards every row, and the next mutation persists the emptiness.
Separately, AuthProvider.onTokenChanged did setAuthToken(null) → awaited signOut() → setAuthToken(new). Any get-session fired in that gap (window focus, network reconnect) read "no session", which redirected to /sign-in and unmounted the whole dashboard — dropping all ephemeral UI state until the follow-up refetch remounted it.
How It Works
hardenLocalCollection(collections.ts) folds the existing quota guard and read heal together withstartSync: true+gcTime: 0, applied uniformly to all six localStorage collections. Collections hydrate synchronously at construction and are never GC'd, making theidle/cleaned-upstates unrepresentable — the non-reactive.statereads in the sidebar mutation helpers become unconditionally correct with no per-call-site readiness gates.withReadHealnow parses per-row: non-envelope entries and heal throws drop that row (with a console warning) instead of escaping to the library's whole-store catch-all.healdefaults to identity so every collection gets the tolerance, not just the two with custom heals.onTokenChangedswaps the token atomically and refetches the session; the stale-identity JWT is cleared and re-mints via the existing 401-refresh path.
Manual QA / CDP Verification
Driven end-to-end over CDP against this worktree's dev app (renderer on port 7965, signed-in session + active org verified first):
- Corrupt-entry survival (fix present): injected a non-envelope entry into
v2-workspace-local-state-<org>(21 entries on disk), froze the dying page's writes, hard-reloaded ignoring cache. The renderer logged[withReadHeal] Dropping malformed stored entry "s:__corrupt__"at hydration, the sidebar rendered all 3 projects and every workspace row, and the on-disk store diffed byte-identical to the pre-test backup (20/20 rows: pane layouts, tabOrder, hidden flags, viewedFiles all intact). Screenshot captured. - Failure mode confirmed in the real renderer: probing the app's actual Vite-optimized
@tanstack/dbbuild in-page (createCollection+localStorageCollectionOptionsover a store with one corrupt + one valid entry) hydrates zero rows — the whole-store blanking is real in the shipped runtime, not just under bun test. - Self-heal observed live: the next collection write after a corrupted boot rewrote the store minus the dropped entry, valid rows untouched (21 → 20).
- App-level pre-fix A/B (git-checkout of the old parser into the running dev server) was inconclusive due to dev-mode HMR/transform-cache ambiguity — the pre-fix failure evidence is the in-renderer library probe above plus the pinned unit tests below.
- Not exercised manually: the insert-on-idle wipe (requires precise pre-hydration timing; pinned by
localCollectionLifecycle.test.tsagainst the real library) and the token-rotation swap (requires a live OAuth deep-link; covered by code-path analysis in the description).
Testing
bun test src/renderer/routes/_authenticated/providers/CollectionsProvider/— 48 pass, including:- new
localCollectionLifecycle.test.ts: insert on a freshstartSync: truecollection preserves existing storage rows; companion test pins the library's wipe behavior without it (if that ever changes, the hardening can be revisited) - new
withReadHealcases: corrupt entry dropped per-row while sibling rows hydrate; heal throw drops only its own row
- new
bun run lint(exit 0)bun run typecheck(desktop)
Design Decisions
- Config over guards: rather than adding readiness gates at every
.insert()call site (there are several ungated ones, e.g. the ensure-on-visit effect inv2-workspace/layout.tsx), the collection lifecycle is fixed so the dangerous states cannot occur. Net complexity goes down — one wrapper instead of three at each call site. gcTime: 0: these stores are a few KB of localStorage; keeping them live forever is cheaper than the correctness risk of re-hydration windows.
Known Limitations / Follow-ups
- Dropping the
signOut()round trip means a previous user's server-side session isn't revoked on deep-link account switch; identity is governed by the bearer token, so this is cosmetic. - Not addressed here (flagged during the same investigation): the server persists
activeOrganizationId = nullwhen a membership read transiently returns empty (packages/auth/src/lib/resolve-session-organization-state.ts) — deserves its own PR; and the library's cross-tab storage-event sync is inert for these collections (the storage wrapper breaks itsstorageAreaidentity check) plus leaks its storage listener across GC cycles — upstream issues, latent while the app has a single window.
Risks / Rollout
- Risk:
startSync: truemoves hydration intocreateOrgCollections(render path). Synchronous localStorage reads of small stores; no measurable startup cost expected. - Rollback: revert the commit — no schema or data-format changes; persisted stores are readable by both old and new code.
Summary by cubic
Prevents sidebar state loss and mid-session unmounts by hardening all localStorage-backed sidebar collections and making auth token swaps atomic. Persisted rows are now safe, and token rotations no longer trigger sign-out flashes.
- Bug Fixes
- Sidebar persistence: All six
localStoragecollections now hydrate on construct and never GC (startSync: true,gcTime: 0). Per-row tolerant parsing viawithReadHealdrops bad entries instead of blanking stores; applied via ahardenLocalCollectionwrapper that also keeps the quota guard. This prevents insert-on-idle wipes and corrupt-entry blanking in@tanstack/react-db. - Token rotation:
AuthProvidernow swaps tokens atomically and clears the stale JWT before refetch, removing the brief signed-out window that redirected to/sign-inand unmounted the sidebar.
- Sidebar persistence: All six
Summary by CodeRabbit
-
Bug Fixes
- Improved session refresh behavior when authentication tokens change, avoiding unnecessary sign-outs.
- Hardened local data storage to better handle quota limits, synchronization, and cleanup.
- Corrupted or unrepairable stored entries are now discarded without preventing valid data from loading.
- Preserved existing local data when initializing synchronized collections.
-
Tests
- Added coverage for local storage lifecycle behavior and recovery from invalid entries.