#459·sphere

Chat caches keyed on a nullable identity, and five surfaces that misreport a missing Sphere

Author: KruGoLCreated Jul 26, 2026Updated Jul 26, 2026

Found while auditing every wallet surface for its behaviour under a lock. All of these are byte-identical to main — none was introduced by the graceful-lock branch, and none is reachable by a user now that locked chat surfaces are gated. They are grouped here because they share one root cause and one blast radius.

Root: chat caches are keyed on an identity that can vanish

useChat.ts:71 and useGroupChat.ts:102 both derive addressId as directAddress ? buildAddressId(directAddress) : 'default'. directAddress comes from the useIdentity query, so any loss of Sphere — a lock, a logout, a slow first render — flips the key to the shared literal 'default'.

Two consequences:

  1. Cache aliasing. Every wallet that ever hits the fallback shares one 'default' bucket. There is a real window where sphere exists but useIdentity has no data yet, so that bucket can hold live DM data.
  2. Silent draft loss (DM only). The [addressId] effect at useChat.ts:90-96 calls setMessageInput('') unconditionally, so a half-typed message died whenever the key flipped. useGroupChat.ts:113-120 deliberately omits setMessageInput, so group drafts survived — the two halves disagreed.

The draft half is already mitigated on the graceful-lock branch: both composers moved to a module-scoped draft store (src/components/chat/draftStore.ts) so an unmount or a key flip no longer destroys typed text. The aliasing is not fixed. The real fix is to stop keying caches on a nullable identity — give useIdentity placeholderData, or hold the last known address — which touches useIdentity / useSphereEvents / useChat / useGroupChat and carries live-address-switch blast radius, so it wants its own PR.

Related, same area

  • isGroupChatConnected is never reset on any loss of Sphere. ServicesProvider.tsx:7 starts false, :10 reads sphere?.groupChat ?? null, and the effect at :13-14 early-returns, so neither :30 nor :38 runs. GroupChatModule.destroy() (sphere-sdk modules/groupchat/GroupChatModule.ts:215-241) emits no groupchat:connection {connected:false} — the only false emitter is connect()'s catch. The flag therefore misreports on any sphere loss, and the two locked states are wrong in opposite directions: a cold start sticks false (forever "Connecting..."), a warm lock sticks true ("Select a group to start" over an empty list). A naive "reset it on lock" turns every warm lock into the forever-spinner, so this needs the SDK to emit on destroy. Has an SDK sub-task.

  • DM send error path. useChat.ts:236 throws 'No conversation selected' when the real cause is !sphere, and useChat.ts:330DMChatSection.tsx:121-125 calls mutateAsync un-awaited with no .catch(), so the rejection is unhandled AND toasted AND sent to Sentry. This is the DM half of #454, which fixed exactly this shape for the group composer via requireSent.ts.

  • MiniChatBubbles vanishes entirely. MiniChatBubbles.tsx:98-100 returns null when conversations.length === 0, unmounting the whole portal — launcher, unread badge, every open window — while locked, and miniChatStore.ts keeps the window ids so they pop back on unlock. It is mounted on every dashboard route (DashboardLayout.tsx:67), so it sits outside DesktopLayout and is NOT covered by the locked-surface gate.

  • Unread badges silently read 0. useDmUnreadCount.ts:11-16 and useGroupUnreadCount.ts:12-17 fall back to data = 0, so TabBar.tsx:96-105, Sidebar.tsx:56-57 and DesktopShortcuts.tsx:155-159 assert "nothing new" when the truth is "not listening". Display-only: no message is lost, because NostrTransportProvider.ts:1223-1234 advances the DM cursor only after a successful unwrap.

  • Marketplace review affordances unmount instead of explaining. ProjectReviewsSection.tsx:159/:220/:273/:282 and ReviewReplies.tsx:27/:86/:96/:119 gate on !!sphere, so a locked wallet reads as "your account is not eligible to review". ProjectPage is behind no blocker.