fix(desktop): prevent sidebar localStorage state loss and token-swap remounts

Merged
KI
3mo ago
+266-57

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):

  1. Insert-on-idle wipes the store. Collections hydrate lazily; .state/.get() on a never-synced collection silently return empty, and insert() doesn't auto-start sync for idle collections. Every mutation re-serializes the entire store from memory, so an insert before hydration rewrote v2-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.
  2. 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 with startSync: true + gcTime: 0, applied uniformly to all six localStorage collections. Collections hydrate synchronously at construction and are never GC'd, making the idle/cleaned-up states unrepresentable — the non-reactive .state reads in the sidebar mutation helpers become unconditionally correct with no per-call-site readiness gates.
  • withReadHeal now 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. heal defaults to identity so every collection gets the tolerance, not just the two with custom heals.
  • onTokenChanged swaps 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/db build in-page (createCollection + localStorageCollectionOptions over 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.ts against 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 fresh startSync: true collection preserves existing storage rows; companion test pins the library's wipe behavior without it (if that ever changes, the hardening can be revisited)
    • new withReadHeal cases: corrupt entry dropped per-row while sibling rows hydrate; heal throw drops only its own row
  • 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 in v2-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 = null when 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 its storageArea identity check) plus leaks its storage listener across GC cycles — upstream issues, latent while the app has a single window.

Risks / Rollout

  • Risk: startSync: true moves hydration into createOrgCollections (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 localStorage collections now hydrate on construct and never GC (startSync: true, gcTime: 0). Per-row tolerant parsing via withReadHeal drops bad entries instead of blanking stores; applied via a hardenLocalCollection wrapper that also keeps the quota guard. This prevents insert-on-idle wipes and corrupt-entry blanking in @tanstack/react-db.
    • Token rotation: AuthProvider now swaps tokens atomically and clears the stale JWT before refetch, removing the brief signed-out window that redirected to /sign-in and unmounted the sidebar.

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.