Render only the message rows that changed

The rows were memoized, but nothing they were given kept its identity: the
list built each row's thread messages afresh, passed inline handlers and the
whole selection, and the click and context-menu handlers changed with the
selection and the menu. Every visible row rendered on every store write.

Rows now select their own message and conversation from the store, take a
plain selected flag, and get handlers whose identity never changes. The
conversation summary is memoized.

Refreshes also keep the object for a message whose fetched properties did
not change, so a refresh that changed one message renders one row.
This commit is contained in:
2026-09-16 09:10:41 -07:00
parent 460760ba12
commit c6dbcaef63
3 changed files with 136 additions and 44 deletions
@@ -152,6 +152,30 @@ describe("refreshList", () => {
}); });
}); });
describe("merging a refresh", () => {
it("keeps the object for a message that did not change, so its row need not render", async () => {
const { listed } = server(3);
await useMail.getState().query({ key: "", filter: { inMailbox: INBOX }, sort: [], collapseThreads: false, mailboxId: INBOX });
const before = { ...useMail.getState().emails };
await useMail.getState().refreshList();
const after = useMail.getState().emails;
for (const id of listed) expect(after[id]).toBe(before[id]);
});
it("replaces the object for a message that did change", async () => {
const { listed } = server(2);
await useMail.getState().query({ key: "", filter: { inMailbox: INBOX }, sort: [], collapseThreads: false, mailboxId: INBOX });
// Held as starred; the server says it is not.
useMail.setState((s) => ({ emails: { ...s.emails, [listed[0]!]: { ...s.emails[listed[0]!]!, keywords: { $flagged: true } } } }));
const held = useMail.getState().emails;
await useMail.getState().refreshList();
const after = useMail.getState().emails;
expect(after[listed[0]!]).not.toBe(held[listed[0]!]);
expect(after[listed[0]!]!.keywords).toEqual({});
expect(after[listed[1]!]).toBe(held[listed[1]!]);
});
});
describe("loadThread", () => { describe("loadThread", () => {
it("fetches no bodies for messages already held in full", async () => { it("fetches no bodies for messages already held in full", async () => {
const { calls } = server(1, 3); const { calls } = server(1, 3);
+24 -4
View File
@@ -255,7 +255,7 @@ export const useMail = create<MailState>((set, get) => ({
for (const r of results) { for (const r of results) {
state = r.state; state = r.state;
for (const e of r.list) { for (const e of r.list) {
next[e.id] = { ...next[e.id], ...e }; next[e.id] = mergeEmail(next[e.id], e);
if (full) nextFull[e.id] = true; if (full) nextFull[e.id] = true;
} }
} }
@@ -990,7 +990,7 @@ export const useMail = create<MailState>((set, get) => ({
); );
set((s) => { set((s) => {
const next = { ...s.emails }; const next = { ...s.emails };
for (const r of results) for (const e of r.list) next[e.id] = { ...next[e.id], ...e }; for (const r of results) for (const e of r.list) next[e.id] = mergeEmail(next[e.id], e);
return { emails: next }; return { emails: next };
}); });
} }
@@ -1038,6 +1038,26 @@ function sortIdentities(list: Identity[], accountId: Id): Identity[] {
* *
* Keyed by nothing: a refusal is about the server, and there is only one. * Keyed by nothing: a refusal is about the server, and there is only one.
*/ */
/**
* Fold freshly fetched properties into the copy already held.
*
* Returns the held object itself when nothing in `next` differs from it. A
* refresh fetches every listed message again, and a new object for each one
* -- the same data, a new identity -- made every row of the list render
* again after any change at all.
*/
function mergeEmail(prev: Email | undefined, next: Email): Email {
if (!prev) return next;
for (const key of Object.keys(next) as (keyof Email)[]) {
const a = prev[key];
const b = next[key];
if (a === b) continue;
if (a && b && typeof a === "object" && JSON.stringify(a) === JSON.stringify(b)) continue;
return { ...prev, ...next };
}
return prev;
}
let sortRefused = false; let sortRefused = false;
async function runQuery(accountId: Id, q: ListQuery, position: number, limit: number) { async function runQuery(accountId: Id, q: ListQuery, position: number, limit: number) {
@@ -1096,7 +1116,7 @@ async function runQueryOnce(accountId: Id, q: ListQuery, position: number, reque
const following = useMail.getState().emailState !== null; const following = useMail.getState().emailState !== null;
useMail.setState((s) => { useMail.setState((s) => {
const emails = { ...s.emails }; const emails = { ...s.emails };
for (const e of emailsRes.list) emails[e.id] = { ...emails[e.id], ...e }; for (const e of emailsRes.list) emails[e.id] = mergeEmail(emails[e.id], e);
const threads = { ...s.threads }; const threads = { ...s.threads };
for (const t of threadsRes?.list ?? []) threads[t.id] = t; for (const t of threadsRes?.list ?? []) threads[t.id] = t;
return { emails, threads, emailState: s.emailState ?? emailsRes.state }; return { emails, threads, emailState: s.emailState ?? emailsRes.state };
@@ -1115,7 +1135,7 @@ async function refreshEmails(accountId: Id, ids: Id[]): Promise<void> {
); );
useMail.setState((s) => { useMail.setState((s) => {
const emails = { ...s.emails }; const emails = { ...s.emails };
for (const r of results) for (const e of r.list) emails[e.id] = { ...emails[e.id], ...e }; for (const r of results) for (const e of r.list) emails[e.id] = mergeEmail(emails[e.id], e);
return { emails }; return { emails };
}); });
} }
+88 -40
View File
@@ -1,5 +1,6 @@
import { Fragment, memo, useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type MouseEvent, type ReactNode } from "react"; import { Fragment, memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type DragEvent, type MouseEvent, type ReactNode } from "react";
import { useVirtualizer } from "@tanstack/react-virtual"; import { useVirtualizer } from "@tanstack/react-virtual";
import { useShallow } from "zustand/react/shallow";
import { Archive, ArrowLeft, CalendarDays, CalendarRange, CalendarPlus, CheckSquare, FolderInput, PanelRight, PanelBottom, PanelTop, Filter, Inbox, Mail, MailOpen, MailPlus, MoreVertical, Paperclip, RefreshCw, Reply, Search, Star, Tag, Trash2, AlertOctagon, Forward, Eraser, ShieldCheck, X } from "lucide-react"; import { Archive, ArrowLeft, CalendarDays, CalendarRange, CalendarPlus, CheckSquare, FolderInput, PanelRight, PanelBottom, PanelTop, Filter, Inbox, Mail, MailOpen, MailPlus, MoreVertical, Paperclip, RefreshCw, Reply, Search, Star, Tag, Trash2, AlertOctagon, Forward, Eraser, ShieldCheck, X } from "lucide-react";
import { useLocation } from "wouter"; import { useLocation } from "wouter";
import { useMail, type ListState } from "@/store/mail"; import { useMail, type ListState } from "@/store/mail";
@@ -39,6 +40,26 @@ const SWIPE_ICON: Record<SwipeIcon, ReactNode> = {
move: <FolderInput size={22} />, move: <FolderInput size={22} />,
}; };
/**
* A callback whose identity never changes but which always runs the latest
* version of `fn`.
*
* The rows are memoized, and every handler they are given has to keep its
* identity for that to mean anything. The handlers here read the selection,
* the list and the menu, all of which change constantly -- so as ordinary
* `useCallback`s they were new on nearly every render, and every visible row
* rendered again with them.
*/
function useStableCallback<A extends unknown[], R>(fn: (...args: A) => R): (...args: A) => R {
const ref = useRef(fn);
// Updated after render rather than during it, so a render React throws away
// never leaves its version behind. Handlers only run on events, which come later.
useLayoutEffect(() => {
ref.current = fn;
});
return useCallback((...args: A) => ref.current(...args), []);
}
export interface ListActions { export interface ListActions {
archive: (rows?: Id[]) => Promise<void>; archive: (rows?: Id[]) => Promise<void>;
trash: (rows?: Id[]) => Promise<void>; trash: (rows?: Id[]) => Promise<void>;
@@ -71,7 +92,6 @@ interface Props {
export function MessageList({ title, list, openThreadId, openMessageId, focusId, setFocusId, onOpen, actions, mailboxId, isSearch }: Props) { export function MessageList({ title, list, openThreadId, openMessageId, focusId, setFocusId, onOpen, actions, mailboxId, isSearch }: Props) {
const [, navigate] = useLocation(); const [, navigate] = useLocation();
const emails = useMail((s) => s.emails); const emails = useMail((s) => s.emails);
const threads = useMail((s) => s.threads);
const selected = useMail((s) => s.selected); const selected = useMail((s) => s.selected);
const select = useMail((s) => s.select); const select = useMail((s) => s.select);
const selectAll = useMail((s) => s.selectAll); const selectAll = useMail((s) => s.selectAll);
@@ -161,7 +181,7 @@ export function MessageList({ title, list, openThreadId, openMessageId, focusId,
onPull: useCallback((y: number, armed: boolean, live: boolean) => setPull({ y, armed, live }), []), onPull: useCallback((y: number, armed: boolean, live: boolean) => setPull({ y, armed, live }), []),
}); });
const onRowClick = useCallback( const onRowClick = useStableCallback(
(e: MouseEvent, rowId: Id) => { (e: MouseEvent, rowId: Id) => {
const action = rowClick({ const action = rowClick({
rowId, ids, anchor: lastClick.current, selected, rowId, ids, anchor: lastClick.current, selected,
@@ -179,18 +199,14 @@ export function MessageList({ title, list, openThreadId, openMessageId, focusId,
// leaves the rows looking smeared blue over the selection they meant. // leaves the rows looking smeared blue over the selection they meant.
else window.getSelection()?.removeAllRanges(); else window.getSelection()?.removeAllRanges();
}, },
[ids, select, selected, isMobile, onOpen],
); );
const onContext = useCallback( const onContext = useStableCallback((e: MouseEvent, rowId: Id) => {
(e: MouseEvent, rowId: Id) => { e.preventDefault();
e.preventDefault(); setCtxRow(rowId);
setCtxRow(rowId); setFocusId(rowId);
setFocusId(rowId); ctxMenu.openAt(e.clientX, e.clientY);
ctxMenu.openAt(e.clientX, e.clientY); });
},
[ctxMenu, setFocusId],
);
/* /*
* Hold a row to select it, the way the mail app the phone came with does. * Hold a row to select it, the way the mail app the phone came with does.
@@ -201,15 +217,12 @@ export function MessageList({ title, list, openThreadId, openMessageId, focusId,
* row *is* how you select it. Once one row is selected, plain taps toggle * row *is* how you select it. Once one row is selected, plain taps toggle
* the rest (see `onRowClick`), so this only has to open the mode. * the rest (see `onRowClick`), so this only has to open the mode.
*/ */
const onLongPress = useCallback( const onLongPress = useStableCallback((rowId: Id) => {
(rowId: Id) => { haptic(15);
haptic(15); setFocusId(rowId);
setFocusId(rowId); lastClick.current = rowId;
lastClick.current = rowId; select([rowId], !useMail.getState().selected[rowId]);
select([rowId], !useMail.getState().selected[rowId]); });
},
[select, setFocusId],
);
const onSwipeState = useCallback((rowId: Id, state: { dir: -1 | 1; armed: boolean; desc: SwipeDescriptor } | null) => { const onSwipeState = useCallback((rowId: Id, state: { dir: -1 | 1; armed: boolean; desc: SwipeDescriptor } | null) => {
// A row clearing itself must not clear a gesture that has since moved on // A row clearing itself must not clear a gesture that has since moved on
@@ -217,7 +230,7 @@ export function MessageList({ title, list, openThreadId, openMessageId, focusId,
setSwiping((cur) => (state ? { id: rowId, ...state } : cur?.id === rowId ? null : cur)); setSwiping((cur) => (state ? { id: rowId, ...state } : cur?.id === rowId ? null : cur));
}, []); }, []);
const fireSwipe = useCallback( const fireSwipe = useStableCallback(
async (rowId: Id, d: SwipeDescriptor) => { async (rowId: Id, d: SwipeDescriptor) => {
switch (d.action) { switch (d.action) {
case "archive": await actions.archive([rowId]); break; case "archive": await actions.archive([rowId]); break;
@@ -229,8 +242,15 @@ export function MessageList({ title, list, openThreadId, openMessageId, focusId,
case "move": actions.move([rowId]); break; case "move": actions.move([rowId]); break;
} }
}, },
[actions],
); );
const onRowSelect = useStableCallback((rowId: Id, on: boolean) => {
select([rowId], on);
lastClick.current = rowId;
});
const onRowStar = useStableCallback((rowId: Id, on: boolean) => void actions.star(on, [rowId]));
const onRowArchive = useStableCallback((rowId: Id) => void actions.archive([rowId]));
const onRowTrash = useStableCallback((rowId: Id) => void actions.trash([rowId]));
const onRowRead = useStableCallback((rowId: Id, read: boolean) => void actions.read(read, [rowId]));
const ctxTargets = useMemo(() => (ctxRow ? (selected[ctxRow] ? Object.keys(selected) : [ctxRow]) : []), [ctxRow, selected]); const ctxTargets = useMemo(() => (ctxRow ? (selected[ctxRow] ? Object.keys(selected) : [ctxRow]) : []), [ctxRow, selected]);
@@ -463,7 +483,6 @@ export function MessageList({ title, list, openThreadId, openMessageId, focusId,
} }
const e = emails[id]; const e = emails[id];
if (!e) return <div key={id} style={{ position: "absolute", top: vi.start, height: vi.size }} />; if (!e) return <div key={id} style={{ position: "absolute", top: vi.start, height: vi.size }} />;
const thread = list?.collapseThreads ? threads[e.threadId] : undefined;
const strip = swiping?.id === id ? swiping : null; const strip = swiping?.id === id ? swiping : null;
return ( return (
<Fragment key={id}> <Fragment key={id}>
@@ -486,8 +505,8 @@ export function MessageList({ title, list, openThreadId, openMessageId, focusId,
</div> </div>
)} )}
<Row <Row
email={e} id={id}
threadEmails={thread ? thread.emailIds.map((x) => emails[x]).filter((x): x is Email => Boolean(x)) : undefined} threadId={list?.collapseThreads ? e.threadId : null}
top={vi.start} top={vi.start}
height={vi.size} height={vi.size}
selected={Boolean(selected[id])} selected={Boolean(selected[id])}
@@ -501,12 +520,11 @@ export function MessageList({ title, list, openThreadId, openMessageId, focusId,
isSent={mailbox?.role === "sent"} isSent={mailbox?.role === "sent"}
onClick={onRowClick} onClick={onRowClick}
onContext={onContext} onContext={onContext}
onSelect={(rowId, on) => { select([rowId], on); lastClick.current = rowId; }} onSelect={onRowSelect}
onStar={(rowId, on) => void actions.star(on, [rowId])} onStar={onRowStar}
onArchive={(rowId) => void actions.archive([rowId])} onArchive={onRowArchive}
onTrash={(rowId) => void actions.trash([rowId])} onTrash={onRowTrash}
onRead={(rowId, read) => void actions.read(read, [rowId])} onRead={onRowRead}
selectedIds={selected}
touch={isTouch} touch={isTouch}
role={mailbox?.role ?? null} role={mailbox?.role ?? null}
swipeLeft={settings.swipeLeft} swipeLeft={settings.swipeLeft}
@@ -548,8 +566,9 @@ export function MessageList({ title, list, openThreadId, openMessageId, focusId,
} }
interface RowProps { interface RowProps {
email: Email; id: Id;
threadEmails?: Email[]; /** The conversation to summarize, in conversation view; null for a single message. */
threadId: Id | null;
top: number; top: number;
height: number; height: number;
selected: boolean; selected: boolean;
@@ -561,7 +580,6 @@ interface RowProps {
isDrafts: boolean; isDrafts: boolean;
isSent: boolean; isSent: boolean;
mailboxId: Id | null; mailboxId: Id | null;
selectedIds: Record<Id, true>;
onClick: (e: MouseEvent, id: Id) => void; onClick: (e: MouseEvent, id: Id) => void;
onContext: (e: MouseEvent, id: Id) => void; onContext: (e: MouseEvent, id: Id) => void;
onSelect: (id: Id, on: boolean) => void; onSelect: (id: Id, on: boolean) => void;
@@ -579,12 +597,40 @@ interface RowProps {
onSwipeFire: (id: Id, desc: SwipeDescriptor) => Promise<void>; onSwipeFire: (id: Id, desc: SwipeDescriptor) => Promise<void>;
} }
const Row = memo(function Row({ email: e, threadEmails, top, height, selected, focused, open, twoLine, showAvatar, showPreview, isDrafts, isSent, mailboxId, selectedIds, onClick, onContext, onSelect, onStar, onArchive, onTrash, onRead, touch, role, swipeLeft, swipeRight, onLongPress, onSwipeState, onSwipeFire }: RowProps) { const NO_EMAILS: Email[] = [];
/*
* A row reads its own message and conversation from the store, rather than
* being handed them. The list re-renders on every store write -- each star,
* each push, each thread loaded -- and objects built for a row in that render
* were new every time, so no row was ever skipped. Selected this way, a row
* renders when something it shows has changed, and not otherwise.
*/
const Row = memo(function Row(props: RowProps) {
const email = useMail((s) => s.emails[props.id]);
const threadEmails = useMail(
useShallow((s) => {
if (!props.threadId) return NO_EMAILS;
const ids = s.threads[props.threadId]?.emailIds;
if (!ids) return NO_EMAILS;
return ids.map((x) => s.emails[x]).filter((x): x is Email => Boolean(x));
}),
);
if (!email) return null;
const { id: _id, threadId, ...rest } = props;
return <RowView {...rest} email={email} threadEmails={threadId ? threadEmails : undefined} />;
});
type RowViewProps = Omit<RowProps, "id" | "threadId"> & { email: Email; threadEmails?: Email[] };
function RowView({ email: e, threadEmails, top, height, selected, focused, open, twoLine, showAvatar, showPreview, isDrafts, isSent, mailboxId, onClick, onContext, onSelect, onStar, onArchive, onTrash, onRead, touch, role, swipeLeft, swipeRight, onLongPress, onSwipeState, onSwipeFire }: RowViewProps) {
const labels = useSettings((s) => s.settings.labels); const labels = useSettings((s) => s.settings.labels);
// Subscribed purely so the row re-renders when the date format changes. // Subscribed purely so the row re-renders when the date format changes.
useSettings((s) => dateTimeKey(s.settings)); useSettings((s) => dateTimeKey(s.settings));
const inScope = threadEmails ? threadEmails.filter((x) => (mailboxId ? x.mailboxIds[mailboxId] : true)) : [e]; const scope = useMemo(() => {
const scope = inScope.length ? inScope : [e]; const inScope = threadEmails ? threadEmails.filter((x) => (mailboxId ? x.mailboxIds[mailboxId] : true)) : [e];
return inScope.length ? inScope : [e];
}, [threadEmails, mailboxId, e]);
const unread = scope.some((x) => !x.keywords.$seen); const unread = scope.some((x) => !x.keywords.$seen);
const starred = scope.some((x) => x.keywords.$flagged); const starred = scope.some((x) => x.keywords.$flagged);
const hasAtt = scope.some((x) => x.hasAttachment); const hasAtt = scope.some((x) => x.hasAttachment);
@@ -671,6 +717,8 @@ const Row = memo(function Row({ email: e, threadEmails, top, height, selected, f
}); });
const onDragStart = (ev: DragEvent) => { const onDragStart = (ev: DragEvent) => {
// Read when the drag starts, so the row need not re-render on every change of selection.
const selectedIds = useMail.getState().selected;
const ids = selectedIds[e.id] ? Object.keys(selectedIds) : [e.id]; const ids = selectedIds[e.id] ? Object.keys(selectedIds) : [e.id];
// include thread emails in scope // include thread emails in scope
const all = new Set<Id>(); const all = new Set<Id>();
@@ -762,4 +810,4 @@ const Row = memo(function Row({ email: e, threadEmails, top, height, selected, f
)} )}
</div> </div>
); );
}); }