Skip to content

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

Merged
Kitenite merged 1 commit into
mainfrom
debug-sidebar-cache-loss
Aug 1, 2026
Merged

Kitenite merged 1 commit into
mainfrom
debug-sidebar-cache-loss

Conversation

@Kitenite

@Kitenite Kitenite commented Aug 1, 2026 •

Copy link
Copy Markdown
Member

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.

Written for commit 24af1b1. Summary will update on new commits.

Review in cubic

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.

All six localStorage-backed collections now hydrate at construction and
never GC (startSync + gcTime: 0), making the idle/cleaned-up states
unrepresentable: an insert into a not-yet-hydrated collection rewrote
the whole storage key from empty memory, erasing every persisted row.
Reads are per-row tolerant so one malformed entry no longer blanks the
entire store via the library's hydration catch-all. The quota guard,
read heal, and lifecycle options fold into one hardenLocalCollection
wrapper.

Also removes the manufactured signed-out window in AuthProvider token
rotation: the token now swaps atomically instead of null -> signOut ->
set, which let concurrent get-session reads observe "no session" and
unmount the entire authenticated tree.
@coderabbitai

coderabbitai Bot commented Aug 1, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The change updates authenticated token replacement and hardens localStorage collection handling. Local collection hydration now removes invalid rows and isolates healing failures. Collection lifecycle tests cover synchronization behavior and corrupt stored entries.

Changes

Authentication token refresh

Layer / File(s) Summary
Atomic token replacement
apps/desktop/src/renderer/providers/AuthProvider/AuthProvider.tsx
Authenticated token changes now set the new token, clear the JWT, and refetch the session without signing out.

Local collection hardening

Layer / File(s) Summary
Per-entry read healing
apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/withReadHeal.ts, apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/withReadHeal.test.ts
Stored entries are validated individually. Invalid entries and entries with failed healing are dropped. Healing defaults to identity when omitted.
Hardened collection wiring and lifecycle validation
apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts, apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/localCollectionLifecycle.test.ts
hardenLocalCollection combines synchronization, retention, read healing, quota reclamation, and failure notifications. Local collections use the wrapper, and lifecycle tests cover synced and unsynced insertion behavior.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant localStorage
  participant hardenLocalCollection
  participant withReadHeal
  participant LocalCollection
  participant QuotaNotification
  localStorage->>hardenLocalCollection: Load persisted rows
  hardenLocalCollection->>withReadHeal: Validate and heal rows
  withReadHeal-->>hardenLocalCollection: Return valid rows
  hardenLocalCollection->>LocalCollection: Start synchronization
  LocalCollection->>localStorage: Persist updates
  hardenLocalCollection->>QuotaNotification: Report quota failure
Loading

Possibly related PRs

Suggested reviewers: ethan0905

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes both primary fixes: sidebar localStorage state loss prevention and atomic token swaps that avoid remounts.
Description check ✅ Passed The description explains the changes, rationale, testing, design decisions, limitations, and risks; the repository checklist is not included but the content is otherwise complete.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch debug-sidebar-cache-loss

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 5 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="apps/desktop/src/renderer/providers/AuthProvider/AuthProvider.tsx">

<violation number="1" location="apps/desktop/src/renderer/providers/AuthProvider/AuthProvider.tsx:82">
P2: The atomic swap now sets a fresh authToken while immediately nulling the JWT (`setJwt(null)`) and then awaiting `refetchSession()` to re-mint a new JWT via the 401-refresh path. This new null-the-JWT-then-refetch sequence has two unguarded edges: (1) if two `onTokenChanged` events arrive in quick succession, the overlapping async `refetchSession()` calls are not serialized/versioned, so a stale refresh result can apply to the newer swap — the same stale-refresh-overwrites-newer-state race that a generation guard is meant to prevent; and (2) if `refetchSession()` rejects, the state is left authenticated (`setAuthToken(data.token)` succeeded) but with `jwt === null` and no restore path, i.e. a session credential was permanently cleared. Consider versioning the swap (a generation counter captured around the swap, and only applying the refetch result if the generation is still current), and ensure the failure path restores a usable JWT or treats the null-JWT state as signed-out rather than leaving an inconsistent authenticated-without-credential state.</violation>
</file>
Architecture diagram
sequenceDiagram
    participant Sidebar as Sidebar UI
    participant Coll as OrgCollections
    participant Harden as hardenLocalCollection
    participant WithRead as withReadHeal
    participant Quota as withQuotaGuard
    participant LS as localStorage
    participant Auth as AuthProvider
    participant Token as electronTrpc.auth.onTokenChanged
    participant Session as refetchSession

    Note over Sidebar,LS: Collection Initialization (constructor time)
    Coll->>Harden: createOrgCollections() for orgId
    Harden->>WithRead: wrap with startSync:true, gcTime:0
    WithRead->>Quota: wrap with reclaim & notify
    Quota->>LS: sync start → hydrate from storageKey
    LS-->>Quota: raw JSON string
    Quota-->>WithRead: parse via healingParser
    WithRead->>WithRead: iterate entries per-row
    alt Entry is envelope with versionKey & data
        WithRead->>WithRead: apply heal(entry.data)
        alt heal throws
            WithRead->>WithRead: drop entry, console.warn
        else heal succeeds
            WithRead->>WithRead: keep entry with healed data
        end
    else Entry is non-envelope (malformed)
        WithRead->>WithRead: drop entry, console.warn
    end
    WithRead-->>LS: hydrated collection in memory
    Note over Coll,LS: Collection now ready – idle state unreachable

    Note over Sidebar,Session: Runtime Sidebar Mutation (e.g. insert-on-enter)
    Sidebar->>Coll: insert({...}) / update(...)
    Coll->>Coll: read .state (non-reactive, guaranteed hydrated)
    Coll->>LS: persist entire store
    alt quota exceeded
        LS-->>Coll: QuotaExceededError
        Coll->>Quota: reclaimTerminalStateForQuota()
        Quota-->>LS: retry write
        alt retry fails
            Quota->>Quota: notifyQuotaExhausted(storageKey)
            Quota-->>Coll: drop write silently
        end
    else write succeeds
        LS-->>Coll: OK
    end
    Coll-->>Sidebar: mutation result

    Note over Auth,Session: Token Swap (no sign-out gap)
    Token->>Auth: onTokenChanged({ token, expiresAt })
    Auth->>Auth: setAuthToken(data.token)
    Auth->>Auth: setJwt(null)  (stale token cleared)
    Auth->>Session: refetchSession()
    alt session fetch succeeds
        Session-->>Auth: new session
        Auth-->>Token: ready
    else session fetch fails
        Auth->>Auth: stale JWT re-mints on next 401
        Auth-->>Token: fallback state
    end
    Note over Auth,Session: No signed-out window → sidebar stays mounted
Loading

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

// concurrent get-session read "no session" and unmount the whole
// authenticated tree. The stale JWT re-mints on the next 401.
setAuthToken(data.token);
setJwt(null);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The atomic swap now sets a fresh authToken while immediately nulling the JWT (setJwt(null)) and then awaiting refetchSession() to re-mint a new JWT via the 401-refresh path. This new null-the-JWT-then-refetch sequence has two unguarded edges: (1) if two onTokenChanged events arrive in quick succession, the overlapping async refetchSession() calls are not serialized/versioned, so a stale refresh result can apply to the newer swap — the same stale-refresh-overwrites-newer-state race that a generation guard is meant to prevent; and (2) if refetchSession() rejects, the state is left authenticated (setAuthToken(data.token) succeeded) but with jwt === null and no restore path, i.e. a session credential was permanently cleared. Consider versioning the swap (a generation counter captured around the swap, and only applying the refetch result if the generation is still current), and ensure the failure path restores a usable JWT or treats the null-JWT state as signed-out rather than leaving an inconsistent authenticated-without-credential state.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/desktop/src/renderer/providers/AuthProvider/AuthProvider.tsx, line 82:

<comment>The atomic swap now sets a fresh authToken while immediately nulling the JWT (`setJwt(null)`) and then awaiting `refetchSession()` to re-mint a new JWT via the 401-refresh path. This new null-the-JWT-then-refetch sequence has two unguarded edges: (1) if two `onTokenChanged` events arrive in quick succession, the overlapping async `refetchSession()` calls are not serialized/versioned, so a stale refresh result can apply to the newer swap — the same stale-refresh-overwrites-newer-state race that a generation guard is meant to prevent; and (2) if `refetchSession()` rejects, the state is left authenticated (`setAuthToken(data.token)` succeeded) but with `jwt === null` and no restore path, i.e. a session credential was permanently cleared. Consider versioning the swap (a generation counter captured around the swap, and only applying the refetch result if the generation is still current), and ensure the failure path restores a usable JWT or treats the null-JWT state as signed-out rather than leaving an inconsistent authenticated-without-credential state.</comment>

<file context>
@@ -75,9 +75,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
+				// concurrent get-session read "no session" and unmount the whole
+				// authenticated tree. The stale JWT re-mints on the next 401.
 				setAuthToken(data.token);
+				setJwt(null);
 				try {
 					await refetchSession();
</file context>

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Assessed as non-blocking, leaving as-is:

(1) Overlapping onTokenChanged events: better-auth's session atom aborts an in-flight get-session when a new fetch starts, and every request reads the bearer via getAuthToken() at send time — so the last swap's refetch always resolves against the newest token. Rotation events are also rare (OAuth deep-link saves only; the subscription does not fire on boot or refresh).

(2) jwt === null after a failed refetch is the designed lazy state, not a stranded one: Electric's 401 handler (refreshJwtAfterUnauthorized) and the 50-minute interval in this same provider both re-mint it, and ensureFreshJwt fetches on demand. The bearer token — the actual session credential — is never cleared on this path. The pre-fix code left the stale JWT in place, which was strictly worse.

@Kitenite
Kitenite merged commit 3242789 into main Aug 1, 2026
12 checks passed
@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

🧹 Preview Cleanup Complete

The following preview resources have been cleaned up:

  • ✅ Neon database branch

Thank you for your contribution! 🎉

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant