Files
buzz/desktop
Will Pfleger 6f6093243e fix(desktop): harden profile batch and thread-reply fetches against relay slowness (#7188)
Three client gaps turn transient relay failures into permanent UI
degradation. Under a slow or rate-limited relay:

1. A cold channel's profile batch exhausts its single retry and leaves
raw npubs + broken mention chips until the user manually kicks the
channel.
2. A thread opened from a notification trusts a successful-but-empty
reply read as authoritative and never retries.
3. A rate-limited `CLOSED` on a history subscription immediately rejects
the caller rather than retrying after the rate-limit window.

All three are addressed without changing global query defaults or the
happy-path behavior.

## Changes

**Fix 1 — cold profile batch resilience** (`useUsersBatchQuery`,
`desktop/src/features/profile/hooks.ts`)

Override `retry: 3` with exponential backoff and error-gated
`refetchOnWindowFocus: (query) => query.state.status === "error"`,
scoped to this query only. The global defaults (`retry: 1`,
`refetchOnWindowFocus: false`) are intentional for other queries and are
unchanged. After the retry budget exhausts, a window-focus event (e.g.
channel-switch) recovers the query automatically — but only when it is
already in an error state, preventing unnecessary refetches for
successful batches.

**Fix 2 — stale-empty thread reads** (`useThreadReplies.ts`,
`ChannelScreen.tsx`)

Add optional `expectedEventId` parameter. When a completed paged fetch
does not contain the expected event, throw
`ThreadExpectedEventMissingError` so React Query's built-in retry
machinery handles it rather than caching an authoritative empty.
`ChannelScreen` passes `threadScrollTargetId` (the notification-linked
reply ID) as `expectedEventId`.

When notification routing changes `expectedEventId` while the same
thread root is already mounted (same query key), an explicit
`invalidateQueries` in a `useEffect` triggers a fresh validation pass.
The `useEffect` is declared after `useQuery` so TanStack's internal
options-update effect installs the new `queryFn` closure first; the
refetch therefore uses the current `expectedEventId` rather than the
previous null closure. For the cold-start race (target arrives before
the first page returns), the effect detects `fetchStatus === "fetching"
&& status === "pending"` and calls
`cancelQueries().then(invalidateQueries)` so the obsolete in-flight
response cannot settle as authoritative before the new target's
validation closure is active.

The query-fn tracks consecutive fetch attempts per target. On attempt 3,
it adds the target to `exhaustedTargetsRef` before calling
`loadThreadReplies`. `loadThreadReplies` sees the target in the
exhausted set and returns the fetched replies directly rather than
throwing — the terminal attempt always resolves to success. No
re-entrant scheduling: the resolution is synchronous inside the query
function itself. Deleted/moderated targets never lock the thread in a
terminal error surface.

**Fix 3 — CLOSED recovery for history subscriptions**
(`relayClosedRecovery.ts`, `relayClientSession.ts`,
`relayClientShared.ts`, `relayGateBoundary.ts`)

On a rate-limited `CLOSED` the subscription previously rejected the
caller immediately. Store `filter` and `timeoutMs` on
`HistorySubscription`, then on rate-limited `CLOSED` re-register under a
fresh `subId` and defer `sendReq` until the rate-limit window clears —
matching the live-sub recovery design already present in
`relayClosedRecovery.ts`. Bounded to 3 attempts; exhausted retries
reject immediately so callers are never left waiting indefinitely. A new
op-timeout guards the retry REQ against a non-responding relay; when the
op-timeout fires it sends `CLOSE` for the rotated `subId` (matching the
behavior of the original timeout path) so the relay releases the slot
rather than counting it against the per-connection cap.

## Tests

- `relayClosedRecovery.test.mjs`: behavioral fake-clock tests for
history-sub retry, 3-attempt exhaustion, op-timeout CLOSE send +
late-EOSE non-regression, rejecting-`closeSubscription` swallowed
without unhandled rejection, wiring source assertion (fails if
`relayClientSession.ts` drops the `closeSubscription` callback) — 18
tests
- `useThreadReplies.test.mjs`: `loadThreadReplies` unit tests
(throw/exhaustion-guard); behavioral hook tests via real
`QueryClientProvider` + `renderHook`: exhaustion-resolves-to-data,
settled null→target change retries on missing-target page and lands
target data (fails if `invalidateQueries` is removed OR if `useQuery` is
moved after the `useEffect`), cold-fetch cancel-then-invalidate (gated
fetcher — released after rerender, stale empty discarded, replacement
fetch settles with target); ChannelScreen wiring source assertion — 9
tests
- `profileBatchResilience.test.mjs`: source assertions for `retry: 3`,
`retryDelay`, error-gated `refetchOnWindowFocus`, and unchanged global
defaults — 2 tests

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-09-02 17:42:45 -04:00
..
…

Buzz

Desktop chat shell with:

  • Tauri + React + TypeScript + Vite
  • Tailwind CSS
  • shadcn/ui-ready shared components
  • Biome (lint/format/check)
  • Feature-driven frontend structure

Scripts

  • pnpm dev - run the web frontend
  • pnpm tauri dev - run the desktop app
  • pnpm build - typecheck and build frontend
  • pnpm typecheck - TypeScript checks
  • pnpm lint - Biome lint
  • pnpm format - Biome format (write)
  • pnpm check - Biome check

Structure

  • src/shared - reusable app-wide code (ui, lib, styles)
  • src/features - feature modules (vertical slices)
  • src/app - top-level app composition