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", () => {
it("fetches no bodies for messages already held in full", async () => {
const { calls } = server(1, 3);