Refactor: Consolidate account management to fix race conditions and state sync bugs #45

Closed
opened 2026-04-26 16:46:03 +00:00 by icub3d · 0 comments
Owner

Migrated from GitHub issue icub3d/decentcom#61
Original Author: @icub3d
Original Date: 2026-04-16T19:53:58Z


Problem

Account management logic is scattered across multiple files and stores, causing race conditions during account switches. Users experience:

  1. Servers appearing on wrong accounts — auto-connect fires during switch, adding a server before the new account's state has loaded.
  2. State not visible until restart — async rehydrate() is fire-and-forget; downstream code reads stale data from the previous account.

Root Cause

Account switching is a multi-step, non-atomic operation spread across 4+ files with no coordination:

  • App.tsxhandleSwitchAccount, handleFirstRunComplete, auto-connect effect
  • AccountSwitcher.tsxhandleSetupComplete (duplicates App.tsx logic)
  • appStore.tsswitchAppStoreAccount, initAppStoreForAccount (localStorage persistence)
  • identityStore.tssetActiveAccount (Tauri backend IPC)
  • serverStore.tsconnect/disconnect (gateway + auth)
  • useIdentity.ts — local React state for hasIdentity/publicKey

Specific Bugs

1. Auto-connect races with account switch (App.tsx:71-82)

When disconnect() sets status to "disconnected", the auto-connect effect fires immediately. But switchAppStoreAccount may not have finished rehydrating, so addServer writes the server to the wrong account's localStorage key.

2. rehydrate() not awaited (appStore.ts:121)

void useAppStore.persist.rehydrate(); // fire-and-forget!

initAppStoreForAccount calls rehydrate() without awaiting it. Any code that reads the store before rehydration completes sees stale (previous account) data.

3. Stale closures in handleFirstRunComplete (App.tsx:113-118)

switchAppStoreAccount(activeAccount, newActive); // activeAccount is from outer closure, not getState()

Uses the activeAccount captured at render time, not the current value. After refresh() completes, the closure value may be stale.

4. Duplicate setup-complete logic (AccountSwitcher.tsx:53-61)

AccountSwitcher.handleSetupComplete duplicates App.handleFirstRunComplete with the same stale-closure bug. Two code paths manage the same transition, making bugs harder to track.

5. Disconnect-before-backend-switch ordering (App.tsx:101-111)

switchAppStoreAccount(oldPubkey, pubkey);  // localStorage updated
disconnect();                               // triggers auto-connect effect
await setActiveAccount(pubkey);            // backend updated (too late)

The localStorage key is repointed before the backend knows about the switch. If the auto-connect effect fires, it authenticates with the old account's key.

6. No verification of backend state (identityStore.ts:49-62)

setActiveAccount calls the backend then re-fetches the account list, but never verifies the returned activeAccount matches the requested pubkey. A silent failure leaves the UI out of sync.

Proposed Solution

Create a centralized useAccountManager hook

Consolidate all account switch/create/import logic into a single hook that:

  1. Serializes operations — uses a mutex/flag (isSwitching) to prevent concurrent switches and block auto-connect.
  2. Atomic switch sequence:
    • Set isSwitching = true
    • Disconnect from current server (gateway close, no auto-reconnect)
    • Call backend setActiveAccount
    • Verify backend returned matching pubkey
    • Switch appStore localStorage key
    • Await rehydrate() (not fire-and-forget)
    • Set isSwitching = false
    • Allow auto-connect if a server is selected
  3. Single source of truth — App.tsx, AccountSwitcher.tsx, and useIdentity.ts all delegate to this hook instead of each implementing their own switch logic.
  4. Always use getState() — never capture activeAccount in closures; always read from store at call time.

Tasks

  • Create client/src/hooks/useAccountManager.ts with centralized switch/create/import logic
  • Add isSwitching flag to gate auto-connect effect in App.tsx
  • Make initAppStoreForAccount await rehydrate() instead of fire-and-forget
  • Replace stale activeAccount closures with useIdentityStore.getState().activeAccount calls
  • Remove duplicate handleSetupComplete from AccountSwitcher.tsx; delegate to useAccountManager
  • Remove duplicate handleFirstRunComplete / handleSwitchAccount from App.tsx; delegate to useAccountManager
  • Fix disconnect-before-backend-switch ordering: backend switch first, then localStorage
  • Add verification that setActiveAccount result matches requested pubkey
  • Update serverStore.connect to check isSwitching before proceeding
  • Write tests for the new useAccountManager hook (switch, create, concurrent switch rejection)
  • Write tests for auto-connect gating during switch
  • Manual QA: add server on Account A, switch to Account B, verify server stays on A
  • Manual QA: create new account, verify no servers leak from previous account

Test Plan

  • Unit: useAccountManager.switchAccount sets isSwitching, calls backend, awaits rehydrate, clears flag
  • Unit: useAccountManager.switchAccount rejects concurrent calls while isSwitching is true
  • Unit: auto-connect effect does NOT fire when isSwitching is true
  • Unit: initAppStoreForAccount awaits rehydrate before resolving
  • Unit: setActiveAccount throws if backend returns different pubkey than requested
  • Integration: switching accounts preserves each account's server list independently

Files Affected

  • client/src/hooks/useAccountManager.ts (new)
  • client/src/App.tsx
  • client/src/stores/appStore.ts
  • client/src/stores/identityStore.ts
  • client/src/stores/serverStore.ts
  • client/src/hooks/useIdentity.ts
  • client/src/components/accounts/AccountSwitcher.tsx
**Migrated from GitHub issue icub3d/decentcom#61** **Original Author:** @icub3d **Original Date:** 2026-04-16T19:53:58Z --- ## Problem Account management logic is scattered across multiple files and stores, causing race conditions during account switches. Users experience: 1. **Servers appearing on wrong accounts** — auto-connect fires during switch, adding a server before the new account's state has loaded. 2. **State not visible until restart** — async `rehydrate()` is fire-and-forget; downstream code reads stale data from the previous account. ### Root Cause Account switching is a multi-step, non-atomic operation spread across 4+ files with no coordination: - `App.tsx` — `handleSwitchAccount`, `handleFirstRunComplete`, auto-connect effect - `AccountSwitcher.tsx` — `handleSetupComplete` (duplicates App.tsx logic) - `appStore.ts` — `switchAppStoreAccount`, `initAppStoreForAccount` (localStorage persistence) - `identityStore.ts` — `setActiveAccount` (Tauri backend IPC) - `serverStore.ts` — `connect`/`disconnect` (gateway + auth) - `useIdentity.ts` — local React state for `hasIdentity`/`publicKey` ## Specific Bugs ### 1. Auto-connect races with account switch (`App.tsx:71-82`) When `disconnect()` sets status to `"disconnected"`, the auto-connect effect fires immediately. But `switchAppStoreAccount` may not have finished rehydrating, so `addServer` writes the server to the wrong account's localStorage key. ### 2. `rehydrate()` not awaited (`appStore.ts:121`) ```ts void useAppStore.persist.rehydrate(); // fire-and-forget! ``` `initAppStoreForAccount` calls `rehydrate()` without awaiting it. Any code that reads the store before rehydration completes sees stale (previous account) data. ### 3. Stale closures in `handleFirstRunComplete` (`App.tsx:113-118`) ```ts switchAppStoreAccount(activeAccount, newActive); // activeAccount is from outer closure, not getState() ``` Uses the `activeAccount` captured at render time, not the current value. After `refresh()` completes, the closure value may be stale. ### 4. Duplicate setup-complete logic (`AccountSwitcher.tsx:53-61`) `AccountSwitcher.handleSetupComplete` duplicates `App.handleFirstRunComplete` with the same stale-closure bug. Two code paths manage the same transition, making bugs harder to track. ### 5. Disconnect-before-backend-switch ordering (`App.tsx:101-111`) ```ts switchAppStoreAccount(oldPubkey, pubkey); // localStorage updated disconnect(); // triggers auto-connect effect await setActiveAccount(pubkey); // backend updated (too late) ``` The localStorage key is repointed before the backend knows about the switch. If the auto-connect effect fires, it authenticates with the old account's key. ### 6. No verification of backend state (`identityStore.ts:49-62`) `setActiveAccount` calls the backend then re-fetches the account list, but never verifies the returned `activeAccount` matches the requested `pubkey`. A silent failure leaves the UI out of sync. ## Proposed Solution ### Create a centralized `useAccountManager` hook Consolidate all account switch/create/import logic into a single hook that: 1. **Serializes operations** — uses a mutex/flag (`isSwitching`) to prevent concurrent switches and block auto-connect. 2. **Atomic switch sequence:** - Set `isSwitching = true` - Disconnect from current server (gateway close, no auto-reconnect) - Call backend `setActiveAccount` - Verify backend returned matching pubkey - Switch appStore localStorage key - **Await** `rehydrate()` (not fire-and-forget) - Set `isSwitching = false` - Allow auto-connect if a server is selected 3. **Single source of truth** — App.tsx, AccountSwitcher.tsx, and useIdentity.ts all delegate to this hook instead of each implementing their own switch logic. 4. **Always use `getState()`** — never capture `activeAccount` in closures; always read from store at call time. ## Tasks - [x] Create `client/src/hooks/useAccountManager.ts` with centralized switch/create/import logic - [x] Add `isSwitching` flag to gate auto-connect effect in `App.tsx` - [x] Make `initAppStoreForAccount` await `rehydrate()` instead of fire-and-forget - [x] Replace stale `activeAccount` closures with `useIdentityStore.getState().activeAccount` calls - [x] Remove duplicate `handleSetupComplete` from `AccountSwitcher.tsx`; delegate to `useAccountManager` - [x] Remove duplicate `handleFirstRunComplete` / `handleSwitchAccount` from `App.tsx`; delegate to `useAccountManager` - [x] Fix disconnect-before-backend-switch ordering: backend switch first, then localStorage - [x] Add verification that `setActiveAccount` result matches requested pubkey - [x] Update `serverStore.connect` to check `isSwitching` before proceeding - [x] Write tests for the new `useAccountManager` hook (switch, create, concurrent switch rejection) - [x] Write tests for auto-connect gating during switch - [ ] Manual QA: add server on Account A, switch to Account B, verify server stays on A - [ ] Manual QA: create new account, verify no servers leak from previous account ## Test Plan - [x] Unit: `useAccountManager.switchAccount` sets `isSwitching`, calls backend, awaits rehydrate, clears flag - [x] Unit: `useAccountManager.switchAccount` rejects concurrent calls while `isSwitching` is true - [x] Unit: auto-connect effect does NOT fire when `isSwitching` is true - [x] Unit: `initAppStoreForAccount` awaits rehydrate before resolving - [x] Unit: `setActiveAccount` throws if backend returns different pubkey than requested - [ ] Integration: switching accounts preserves each account's server list independently ## Files Affected - `client/src/hooks/useAccountManager.ts` (new) - `client/src/App.tsx` - `client/src/stores/appStore.ts` - `client/src/stores/identityStore.ts` - `client/src/stores/serverStore.ts` - `client/src/hooks/useIdentity.ts` - `client/src/components/accounts/AccountSwitcher.tsx`
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
icub3d/decentcom#45
No description provided.