Files
lasuite-messages/docs/frontend-threads-cache.md
T
Jean-Baptiste PENRATHandGitHub 6be3b9e129 ♻️(frontend) refactor thread query cache management (#642)
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.
2026-05-06 19:39:07 +02:00

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.tsxMailboxProvider
  • src/frontend/src/features/providers/mailbox-cache.ts — pure cache helpers
  • src/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:

  1. Every mutation reconciles by either patching the cache directly or invalidating it (which triggers a refetch).
  2. 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:

  1. 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 (a useRef<Set<string>>).
  2. The infinite query's structuralSharing callback runs mergePinnedThreads on 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.
  3. When the server returns a pinned thread on its own, the pin is inert: mergePinnedThreads only 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.
  4. Pinned ids are cleared in bulk when the user changes mailbox or filter (a useEffect watches selectedMailbox.id and searchParams).

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

  1. Does the mutation flip a property the server filters on?
    • No → category C (messages-only) or just invalidate.
    • Yes → step 2.
  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().
  3. 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.