Skip to content

perf(desktop): de-block thread aux from first paint, page 500, cache reopens - #6446

Open
wpfleger96 wants to merge 2 commits into
mainfrom
hayt/thread-fetch-quick-wins
Open

perf(desktop): de-block thread aux from first paint, page 500, cache reopens#6446
wpfleger96 wants to merge 2 commits into
mainfrom
hayt/thread-fetch-quick-wins

Conversation

@wpfleger96

Copy link
Copy Markdown
Member

Opening a long thread (~200-300 replies) in the desktop app was slow and flaky, and reopening the same thread re-ran the entire fetch from scratch. This is PR-2 of the desktop thread-load arc — the fetch-pipeline quick wins.

What was slow

A 300-reply cold open paid four serial relay legs before anything painted:

  1. Two content pages — THREAD_PAGE_LIMIT was 200, so 300 replies took two serial get_thread_replies calls.
  2. Two aux waves — after both content pages landed, withThreadAux fetched edits/deletions and reactions over all ~301 ids and blocked the resolve on them.

staleTime: 0 meant closing and reopening the panel re-ran the whole pipeline every time.

Changes

  • THREAD_PAGE_LIMIT 200 → 500. The bridge clamps a thread page to BRIDGE_THREAD_MAX_LIMIT (500) and the Tauri command caps it with .min(500), so 500 is the largest page the server serves — a ≤500-reply thread now cold-opens its content in a single round trip.
  • De-block aux from first paint. loadThreadReplies resolves with content replies as soon as the page loop completes. Structural aux (edits/deletions) and reactions hydrate asynchronously into the same thread-replies cache via a functional setQueryData merge — the exact pattern the channel timeline already ships (backfillAuxForMessages). Accepted tradeoff: an edited reply may briefly render its original text until the merge lands.
  • staleTime 0 → 30s on useThreadReplies and useThreadRepliesForRoots, so reopening a recently-loaded thread is a cache hit. While the panel's channel is subscribed, the live WS subscription writes every new content and aux event into the thread-replies key (hooks.ts appendMessage), keeping the warm cache current. The finite bound exists precisely because that self-healing only covers the subscribed channel: edits/deletions/reactions arriving for a thread whose channel is not subscribed never reach the cache, so a 30s staleTime guarantees the next mount refetches and corrects them.

Safety

The async merge uses a functional setQueryData updater keyed on the thread's (channelId, rootId), so it can only touch that thread's cache. It folds aux over whatever content is current rather than a fetch-time snapshot, so a live reply that appends mid-flight is preserved, and aux referencing a message a later refetch dropped simply renders against nothing — content is never resurrected. Both aux fetches stay best-effort: a failure logs and degrades to bare replies.

Tests

Extends useThreadReplies.test.mjs: aux merges into the content cache; both-fetches-fail degrades to bare replies; the merge folds over a live mid-flight append rather than the stale snapshot; and the page-limit/staleTime invariants are pinned.

…reopens

A 300-reply thread cold-open paid four serial relay legs before anything painted: two content pages (limit 200) followed by two aux waves for edits/deletions/reactions over all reply ids. staleTime:0 re-ran the whole pipeline on every reopen.

Raise THREAD_PAGE_LIMIT to the server-clamped 500 so a <=500-reply thread fetches its content in one page. Resolve loadThreadReplies with content as soon as the page loop completes and hydrate aux into the same thread-replies cache via a functional setQueryData merge, off the critical path -- the exact pattern the channel timeline already ships. Set a 30s staleTime so reopening a recently-loaded thread is a cache hit; the live subscription keeps the subscribed channel's cache fresh while the bound guarantees an unsubscribed thread refetches to pick up edits/deletions it missed.

The merge folds aux over whatever content is current, so a live append that lands mid-flight is preserved and a message a later refetch dropped is never resurrected.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
@wpfleger96
wpfleger96 requested a review from a team as a code owner August 21, 2026 01:54
The suite exercised backfillThreadAux() in isolation but never ran the loader through the de-block seam, so it stayed green if the fire-and-forget dispatch regressed to an await (recreating the first-paint latency this change removes) or was dropped entirely.

Make loadThreadReplies injectable at the relay boundary and add behavioral tests proving content resolves while aux is pending, aux merges into the thread cache after content resolves, and aux rejection leaves the content query successful.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
@wpfleger96
wpfleger96 force-pushed the hayt/thread-fetch-quick-wins branch from ddfd716 to 0981d68 Compare August 21, 2026 02:08
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