Skip to content

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

Closed
wpfleger96 wants to merge 6 commits into
mainfrom
hayt/thread-fetch-quick-wins
Closed

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

Conversation

@wpfleger96

@wpfleger96 wpfleger96 commented Aug 21, 2026

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.
  • Live replies update only an already-observed thread key. appendMessage writes each live threaded reply through setQueriesData with an exact: true filter (the same create-safe primitive as the aux fan-out just below it), not a raw setQueryData. A raw write builds the key when absent, which under the 30s staleTime would mint a "complete/fresh" cache holding just that one reply for a never-opened thread — the next open would skip the history fetch and render only the newest reply. Routing through findAll means a warm or in-flight thread still appends live, while an absent thread stays absent and its next mount fetches the full relay subtree.

Safety

The async aux 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. The live-reply producer never creates a thread cache, so a warm reopen is only ever trusted for a thread that was actually loaded.

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; the page-limit/staleTime invariants are pinned; and the production exact-500 cursor contract pages correctly at 499/500/501 replies. threadReplyFreshness.test.mjs drives the real producer and consumer against a live QueryClient to prove a live reply to a never-opened thread does not skip the mount history fetch and unions the reply exactly once. useChannelSubscriptionProducers.test.mjs pins the producer key scoping (a reply stays in its own root's cache; an aux overlay fans out to existing thread caches).

…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
wpfleger96 and others added 2 commits August 21, 2026 09:26
The 30s staleTime added for the reopen-storm win made an inactive channel's
thread-replies cache authoritative on reopen. Switching channels disposes that
channel's live subscription, so replies emitted while it is inactive never
reach the cache; reopening a thread within the window rendered stale topology
and unread state (unread divider, subtree badges, read-clear all computed
against pre-switch data). Deterministically broke five thread-unread smoke
cases.

Invalidate the channel's thread-replies queries when its live subscription
(re)establishes, mirroring the channel window's existing resubscribe refresh.
This keeps the warm same-channel reopen win (that cache stays subscribed and
never re-establishes) while a return-after-switch refetches the missed events.
Cached rows still paint immediately via stale-while-revalidate.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Carl, an automated reviewer, commenting via Wes’s GitHub account.

Reviewed base 2edacde4d4c01490834725774aa878dbc373c41d through head beb5b5348b16eda96284c7f06bda2303e8019729.

No blocking correctness finding. I traced the cold thread read, structural/reaction aux hydration, live content and aux producers, query-key ownership, warm reopen, channel switch/reconnect invalidation, unread consumers, and the 500-row bridge/Tauri pagination boundary.

Two non-blocking follow-ups:

  1. Please add a seam test for the load-bearing warm-cache producer contract: a live threaded content event updates only its (channel, root) key, and a live edit/reaction updates the channel's existing thread keys. The new 30-second staleTime is safe only while those paths keep subscribed caches current; the new tests cover invalidation and cold hydration but not these producers.
  2. The THREAD_PAGE_LIMIT comment overstates the exact boundary. A page with exactly 500 replies receives a non-null cursor because Tauri treats every full page as potentially incomplete, so the loader makes a second empty content request. The one-round-trip claim is true for <500, not ≤500. I reproduced replyCount: 500, contentFetchCalls: 2 at the reviewed head.

Focused verification: the two changed desktop test files passed 11/11 at the reviewed head. GitHub CI is green for all applicable checks. I also tested the suspected "aux finishes before React Query commits content" race against a real QueryObserver; React Query's notification batching commits content before the aux cache write, and the final cache retained both content and aux, so I did not treat that as a defect.

The 30s staleTime is safe only while the active channel's live subscription
keeps subscribed thread-replies caches current via appendMessage. Two producer
paths carry that contract with load-bearing key scoping: a threaded content
event writes only its own (channel, root) key, while an aux edit/reaction fans
out across every thread key in the channel. Prior tests covered invalidation
and cold hydration but not these producers.

Also correct the THREAD_PAGE_LIMIT comment: a full 500-reply page returns a
non-null cursor and costs a second empty request, so the single-round-trip
claim holds for <500 replies, not <=500.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Carl, an automated reviewer, commenting via Wes’s GitHub account.

Reviewed base 2edacde4d4c01490834725774aa878dbc373c41d through exact head 4224bba043f700947cfb060dc361ab8304ffd7ba.

High — do not create a fresh thread cache entry from a live reply.

appendMessage now unconditionally calls setQueryData(threadRepliesKey(channelId, rootId), ...) for every live threaded content event. When that thread has never been opened, this creates a query whose only row is the new live reply and whose dataUpdatedAt is now. useThreadReplies then mounts that key with a 30-second staleTime, treats it as complete/fresh, and skips loadThreadReplies. Opening the thread during that window therefore shows only the newest live reply and silently omits its older subtree.

The subscription-time invalidation cannot cover this case because the key does not exist when subscription starts. The producer test also misses it because mountSubscription pre-seeds both thread keys before emitting events.

Please avoid making an absent/unobserved thread key fresh (for example, append only to an existing/fetching key, or immediately invalidate a newly created key), and add a regression for no key -> live reply -> mount -> history fetch runs and unions the live reply.

Non-blocking test gap: add 499/500/501 page-loop coverage. The E2E bridge returns a cursor only when rows remain, while production returns a cursor for every full 500-row page, so exact-500 behavior is not currently modeled.

Validation: I traced the query producers/consumers and independently reproduced the cache freshness contract with a direct QueryClient probe: creating the absent key made it fresh and mounting it did not fetch. GitHub CI is green at this head; I did not duplicate the CI-equivalent suite locally.

`appendMessage` wrote every live threaded reply into its `(channel, root)`
thread-replies key with `setQueryData`, which builds the key when absent. Under
the PR's 30s `staleTime` that minted a "complete/fresh" cache whose only row was
the live reply for a never-opened thread, so opening it within the window
skipped `loadThreadReplies` and silently dropped the entire older subtree. Under
the prior `staleTime: 0` the mount always refetched, so this is the PR's own
regression.

Write through `setQueriesData` with an exact-key filter instead: like the aux
fan-out just below, it routes through `findAll` and updates only an
already-observed key, so a warm or in-flight thread still appends live while an
absent thread stays absent and its next mount fetches the full relay subtree
(which includes the persisted reply).

Regressions: the freshness contract at the real QueryClient (no key -> live
reply -> open runs the history fetch and unions the reply exactly once) and the
production exact-500 page-loop contract (a full page returns a cursor, so 500
and 501 replies page twice), both mutation-verified.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Carl, an automated reviewer, commenting via Wes’s GitHub account.

Reviewed exact base 2edacde4d4c01490834725774aa878dbc373c41d through head a8b701c96deab98c90ba285d45da18e1a6152f2e.

High — preserve resubscribe invalidation across live thread-cache writes.

A previously opened thread can miss reply B while its channel is inactive. Returning to the channel correctly invalidates that existing cache at desktop/src/features/messages/hooks.ts:307-312, but before the thread reopens, any live threaded content or aux event updates it through setQueriesData at hooks.ts:345-357. TanStack delegates that to setQueryData; its manual success transition clears isInvalidated and stamps dataUpdatedAt to now. The cache therefore becomes fresh A+C while still missing B, and opening it inside the new 30-second staleTime skips loadThreadReplies and renders incomplete history.

The new absent-key fix is correct, but it only prevents minting a never-observed key; it does not preserve stale state on an existing invalidated key. The added tests exercise invalidation and producer writes separately, so they miss this composition.

Please preserve invalidation, or otherwise force a history refetch, across live content and aux merges into an invalidated thread cache. Add regressions for invalidate -> live content -> mount and invalidate -> live aux -> mount, proving the mount still fetches the complete relay subtree.

Source-only review per channel policy; I did not run builds or tests. All applicable GitHub checks are green at the reviewed head. Separately, the branch currently conflicts with main in three changed files; that is mergeability state, not the reason for this changes-requested review.

@wpfleger96 wpfleger96 closed this Aug 24, 2026
@wpfleger96

Copy link
Copy Markdown
Member Author

🤖 Closing as superseded by #6572, which shipped a different answer to the same thread-open latency problem: aux now returns inline from the relay (include_aux), so the aux-backfill deferral here refactors functions that no longer exist on main, and #6572's CI proved the 30s staleTime unsafe once a channel's live subscription stops feeding the thread cache. The create-safe setQueriesData also landed on main independently. The only surviving piece is the THREAD_PAGE_LIMIT 200→500 bump, which may be recut separately.

@wpfleger96
wpfleger96 deleted the hayt/thread-fetch-quick-wins branch August 24, 2026 19:14
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.

2 participants