mirror of
https://github.com/suitenumerique/messages.git
synced 2026-08-21 07:02:22 +02:00
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.
185 lines
7.7 KiB
Markdown
185 lines
7.7 KiB
Markdown
# 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` — `MailboxProvider`
|
|
- `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)`:
|
|
|
|
```ts
|
|
['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.
|
|
|
|
```ts
|
|
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.
|
|
|
|
```ts
|
|
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.
|
|
|
|
```ts
|
|
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.
|