The thread query is an infinite one and the frontend logic is based on the structuralSharing concept of react-query to optimiscally update the react query cache on thread mutation in order to improve ux. This part is a tricky one and it's easy to introduce regression, that's why refactor it by moving the corresponding logic into a mailbox-cache module, use a better naming (pin instead of optimistic) and battle test it.
7.7 KiB
Threads list cache — design & mutation contract
This document explains how the frontend threads list cache is managed, why it pins certain threads, and what every mutation that touches threads must do to keep the UI consistent.
It is required reading before adding or refactoring any hook that mutates threads (read/unread, starred, archived, trashed, spam, draft delete/send, labels, etc.).
The relevant code lives in:
src/frontend/src/features/providers/mailbox.tsx—MailboxProvidersrc/frontend/src/features/providers/mailbox-cache.ts— pure cache helperssrc/frontend/src/features/message/use-*.tsx— mutation hooks consuming the API
1. Server state vs. client state
There is no global client state for threads. The list comes from a
React Query infinite query (useThreadsListInfinite) keyed per mailbox and
per filter variant. The cache is the source of truth on the client; we
do not duplicate it into a Zustand/Redux store.
Two implications:
- Every mutation reconciles by either patching the cache directly or invalidating it (which triggers a refetch).
- The cache is keyed by URL search params (filter, search, label, etc.). The same thread can live in multiple cache variants simultaneously.
The query key is built by getMailboxThreadsListQueryKey(mailboxId, searchParams):
['threads', <mailboxId>, 'list' | 'search', <normalized-other-params>]
'list' vs 'search' lets us target the whole search subtree by prefix
without enumerating filter combinations.
2. Why pinning exists
Mutations like mark-as-read or toggle-starred flip a property that the server uses to filter the list:
- Mark a thread as read while viewing the "unread" filter — the server will drop it on the next refetch.
- Unstar a thread while viewing the "starred" filter — same problem.
Without protection, the thread would disappear from under the user's cursor
the moment a refetch lands (polling, invalidateQueries, window focus).
That is jarring: the user wants to see the action they just performed.
Pinning is the protection mechanism:
- The mutation calls
pinThreads(ids, patcher). This:- patches the thread(s) in every cached list variant via
patchThreadsInCache(so the UI reflects the new state immediately); - records the thread ids in
pinnedThreadIdsRef(auseRef<Set<string>>).
- patches the thread(s) in every cached list variant via
- The infinite query's
structuralSharingcallback runsmergePinnedThreadson every refetch. Threads that are pinned but missing from the new server data are re-injected at the index they previously occupied within their original page. - When the server returns a pinned thread on its own, the pin is inert:
mergePinnedThreadsonly re-injects threads missing from the response, so the server's data wins by default. The pin entry stays in the set but has no effect until the thread disappears again. - Pinned ids are cleared in bulk when the user changes mailbox or filter
(a
useEffectwatchesselectedMailbox.idandsearchParams).
Pinning rules
- Pin only when the mutation flips a property the server filters on AND the user should still see the thread under the current view.
- Always pair the pin with a cache patch — pinning a stale thread is worse than dropping it, because it shows wrong data.
- Re-insertion preserves per-page semantics. Pinned threads never move to page 0 if they originally lived on page 1; otherwise flattening the pages would yield duplicates.
3. Why unpinning matters
A pin survives until unpinThreads is called or the user changes filter /
mailbox. When a mutation moves the thread out of the active view
(archive, spam, trash, draft delete, draft send), the server will never
return it under that filter again — so the pin would persist and
mergePinnedThreads would re-inject the thread on every subsequent
refetch. The user sees a "ghost" thread that ignores their action.
Every mutation that intentionally removes a thread from the current view
must call unpinThreads(ids) before invalidating the list.
const { unpinThreads, invalidateMailbox } = useMailboxContext();
onSuccess: (data) => {
unpinThreads(data.thread_ids ?? []);
invalidateMailbox();
}
Order matters less than presence: as long as the pin is gone before the
refetch settles, structuralSharing will let the thread disappear.
4. Mutation playbook
Every thread mutation falls into one of three categories. Pick the right playbook and stick to it.
A. "Stay visible" mutations (read/unread, starred/unstarred)
These flip a server-filterable property but the thread should remain visible in the current view until the user navigates away.
onSuccess: (data) => {
pinThreads(data.thread_ids ?? [], (thread) => ({
...thread,
// recompute server-derived flags so the cached row matches reality
has_unread: deriveThreadHasUnread(thread.messaged_at, data.read_at ?? null),
accesses: thread.accesses.map((access) => ...),
}));
invalidateThreadsStats();
}
The patcher encodes the domain semantics (recomputing has_unread,
flipping has_starred, mutating accesses, etc.) — mailbox-cache.ts
does not know about them.
B. "Leave the view" mutations (archive, spam, trash, draft delete/send)
These remove the thread from the active filter. We do not patch — we unpin and let the next refetch reconcile.
onSuccess: (data) => {
unpinThreads(data.thread_ids ?? []);
invalidateMailbox();
invalidateThreadsStats();
}
C. Per-thread message mutations (draft body, message read, etc.)
Patch the per-thread messages cache (['messages', threadId]) via
patchMessagesInCache / removeMessagesFromCache. These do not touch the
threads list. Invalidate invalidateThreadMessages() if a refetch is
warranted.
5. Invalidation helpers exposed by MailboxProvider
| Helper | When to call it |
|---|---|
pinThreads(ids, patcher) |
"Stay visible" mutations (category A) |
unpinThreads(ids) |
"Leave the view" mutations (category B), before invalidating |
patchMessages(threadId, p) |
Per-thread message mutations (category C) |
removeMessages(...) |
Drop messages from a thread's cache (e.g. draft deletion) |
invalidateThreadList() |
Force refetch of every list variant of the current mailbox |
invalidateThreadMessages() |
Force refetch of the selected thread's messages |
invalidateMailbox() |
Shorthand for both above |
invalidateThreadEvents() |
Refetch events of the selected thread |
invalidateThreadsStats() |
Refetch sidebar counters (excludes per-label stats) |
invalidateLabels() |
Refetch the labels list |
6. Decision flow when adding a new mutation
- Does the mutation flip a property the server filters on?
- No → category C (messages-only) or just invalidate.
- Yes → step 2.
- Should the thread remain in the current view after the mutation?
- Yes → category A:
pinThreads(ids, patcher)+ invalidate stats only. - No → category B:
unpinThreads(ids)+invalidateMailbox().
- Yes → category A:
- Are there per-thread side effects (messages, events)?
- Yes → also patch / invalidate the relevant per-thread caches.
When in doubt, write a unit test against mergePinnedThreads reproducing
the user-visible scenario before changing behaviour.