Let go of old message bodies and of exported files

Every message opened kept its full copy for as long as the tab was open.
The store now holds bodies for the 40 messages most recently wanted; older
ones go back to the list properties and are fetched in full again if
opened. The open conversation is never released.

Contact, settings and calendar exports go through downloadFile, which
releases the object URL once the download has started; three of them
never released it.
This commit is contained in:
2026-09-16 10:59:57 -07:00
parent da87925b9c
commit f123467897
6 changed files with 161 additions and 15 deletions
@@ -0,0 +1,81 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CAP, client } from "@/jmap/client";
import type { JmapSession } from "@/jmap/types";
import { BODIES_KEPT, resetBodyOrder, useMail } from "@/store/mail";
/**
* A long session used to keep the full copy of every message it opened --
* bodies, headers, attachment lists -- until the tab closed.
*/
const full = (id: string, threadId = `t-${id}`) => ({
id,
threadId,
mailboxIds: { in: true },
keywords: {},
subject: `Subject ${id}`,
receivedAt: "2026-09-16T00:00:00Z",
preview: "p",
htmlBody: [{ partId: "1", type: "text/html" }],
bodyValues: { "1": { value: "<p>".padEnd(10_000, "x") } },
attachments: [],
});
beforeEach(() => {
resetBodyOrder();
client.session = { capabilities: { [CAP.core]: { maxObjectsInGet: 500 }, [CAP.mail]: {} }, accounts: {}, primaryAccounts: {}, state: "s" } as unknown as JmapSession;
useMail.setState({ accountId: "a1", emails: {}, fullIds: {}, threads: {}, openThreadId: null, emailState: "1" });
vi.stubGlobal("fetch", vi.fn(async (_url: string, init: RequestInit) => {
const { methodCalls } = JSON.parse(init.body as string) as { methodCalls: [string, Record<string, unknown>, string][] };
const methodResponses = methodCalls.map(([name, args, id]) => [name, { state: "1", list: (args.ids as string[]).map((x) => full(x)), notFound: [] }, id]);
return { ok: true, status: 200, json: async () => ({ methodResponses, sessionState: "s" }) } as Response;
}));
});
afterEach(() => {
vi.unstubAllGlobals();
});
const open = async (id: string) => {
await useMail.getState().getEmails([id], true);
};
describe("message bodies", () => {
it("are let go past the limit, oldest first, back to what the list shows", async () => {
for (let i = 0; i < BODIES_KEPT + 5; i++) await open(`m${i}`);
const { emails, fullIds } = useMail.getState();
expect(Object.keys(fullIds)).toHaveLength(BODIES_KEPT);
for (let i = 0; i < 5; i++) {
expect(fullIds[`m${i}`]).toBeUndefined();
expect(emails[`m${i}`]).toMatchObject({ id: `m${i}`, subject: `Subject m${i}`, preview: "p" });
expect(emails[`m${i}`]).not.toHaveProperty("bodyValues");
expect(emails[`m${i}`]).not.toHaveProperty("htmlBody");
}
expect(emails[`m${BODIES_KEPT + 4}`]).toHaveProperty("bodyValues");
});
it("count a message opened again as recent", async () => {
for (let i = 0; i < BODIES_KEPT; i++) await open(`m${i}`);
await open("m0");
await open("extra");
const { fullIds } = useMail.getState();
expect(fullIds.m0).toBe(true);
expect(fullIds.m1).toBeUndefined();
});
it("are never taken from the conversation that is open", async () => {
await open("keep");
useMail.setState((s) => ({ openThreadId: "t-keep", threads: { ...s.threads, "t-keep": { id: "t-keep", emailIds: ["keep"] } } }));
for (let i = 0; i < BODIES_KEPT + 5; i++) await open(`m${i}`);
expect(useMail.getState().fullIds.keep).toBe(true);
expect(useMail.getState().emails.keep).toHaveProperty("bodyValues");
});
it("are fetched again when a released message is opened", async () => {
for (let i = 0; i < BODIES_KEPT + 1; i++) await open(`m${i}`);
expect(useMail.getState().fullIds.m0).toBeUndefined();
const [again] = await useMail.getState().getEmails(["m0"], true);
expect(again).toHaveProperty("bodyValues");
expect(useMail.getState().fullIds.m0).toBe(true);
});
});
+54
View File
@@ -99,6 +99,7 @@ export const useMail = create<MailState>((set, get) => ({
setAccount(accountId) {
if (accountId === get().accountId) return;
resetBodyOrder();
set({
accountId,
mailboxes: {},
@@ -263,6 +264,10 @@ export const useMail = create<MailState>((set, get) => ({
return { emails: next, fullIds: nextFull, emailState: s.emailState ?? state };
});
}
if (full) {
touchBodies(ids);
set((s) => releaseBodies(s));
}
const now = get().emails;
return ids.map((id) => now[id]).filter((e): e is Email => Boolean(e));
},
@@ -1059,6 +1064,55 @@ function mergeEmail(prev: Email | undefined, next: Email): Email {
return prev;
}
/*
* How many messages are held with their bodies.
*
* Every message opened kept its full copy -- bodies of up to 2 MB each, parsed
* headers, the attachment list -- for as long as the tab was open, so a long
* session's memory grew with every message read. Past this many, the ones read
* longest ago go back to what the list needs, and are fetched in full again if
* they are opened again. The open conversation is never touched.
*/
export const BODIES_KEPT = 40;
/** Messages held in full, least recently wanted first. */
const bodyOrder: Id[] = [];
const LIST_KEYS = new Set<string>(LIST_PROPS);
function touchBodies(ids: Id[]): void {
for (const id of ids) {
const at = bodyOrder.indexOf(id);
if (at >= 0) bodyOrder.splice(at, 1);
bodyOrder.push(id);
}
}
/** The state with bodies past `BODIES_KEPT` let go; the same state when there is nothing to do. */
export function releaseBodies(s: MailState): MailState | Partial<MailState> {
if (bodyOrder.length <= BODIES_KEPT) return s;
const open = new Set(s.openThreadId ? (s.threads[s.openThreadId]?.emailIds ?? []) : []);
const emails = { ...s.emails };
const fullIds = { ...s.fullIds };
let over = bodyOrder.length - BODIES_KEPT;
for (let i = 0; i < bodyOrder.length && over > 0; ) {
const id = bodyOrder[i]!;
if (open.has(id) || s.emails[id]?.threadId === s.openThreadId) {
i++;
continue;
}
bodyOrder.splice(i, 1);
over--;
delete fullIds[id];
const e = emails[id];
if (e) emails[id] = Object.fromEntries(Object.entries(e).filter(([k]) => LIST_KEYS.has(k))) as unknown as Email;
}
return { emails, fullIds };
}
/** Forget what is held; for tests, and for an account switch. */
export function resetBodyOrder(): void {
bodyOrder.length = 0;
}
let sortRefused = false;
async function runQuery(accountId: Id, q: ListQuery, position: number, limit: number) {