Merge pull request #387 from Coffey-Labs/perf/client-memory

Let go of old message bodies and of exported files
This commit is contained in:
jcoffey
2026-09-16 11:02:42 -07:00
committed by GitHub
6 changed files with 161 additions and 15 deletions
+16
View File
@@ -0,0 +1,16 @@
/**
* Hand the browser a file the app made, to save.
*
* The object URL is released as soon as the download has been started: a
* click on the link starts it synchronously, and an unreleased URL keeps the
* whole file in memory for as long as the tab is open -- an address book's
* worth of vCards, per export.
*/
export function downloadFile(content: BlobPart, type: string, filename: string): void {
const url = URL.createObjectURL(new Blob([content], { type }));
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
@@ -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) { setAccount(accountId) {
if (accountId === get().accountId) return; if (accountId === get().accountId) return;
resetBodyOrder();
set({ set({
accountId, accountId,
mailboxes: {}, mailboxes: {},
@@ -263,6 +264,10 @@ export const useMail = create<MailState>((set, get) => ({
return { emails: next, fullIds: nextFull, emailState: s.emailState ?? state }; return { emails: next, fullIds: nextFull, emailState: s.emailState ?? state };
}); });
} }
if (full) {
touchBodies(ids);
set((s) => releaseBodies(s));
}
const now = get().emails; const now = get().emails;
return ids.map((id) => now[id]).filter((e): e is Email => Boolean(e)); 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; 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; 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) {
+5 -9
View File
@@ -16,6 +16,7 @@ import type { Calendar, Id } from "@/jmap/types";
import { CalendarDialog } from "./CalendarDialog"; import { CalendarDialog } from "./CalendarDialog";
import { ShareDialog } from "../settings/ShareDialog"; import { ShareDialog } from "../settings/ShareDialog";
import { plural, t } from "@/lib/i18n"; import { plural, t } from "@/lib/i18n";
import { downloadFile } from "@/lib/download";
export function CalendarSidebar() { export function CalendarSidebar() {
const [location, navigate] = useLocation(); const [location, navigate] = useLocation();
@@ -42,19 +43,14 @@ export function CalendarSidebar() {
const importInto = useRef<Id | null>(null); const importInto = useRef<Id | null>(null);
/* /*
* Handing the file over, which the browser only does from a click. The * Handing the file over, which the browser only does from a click.
* revoke below is what keeps a calendar's worth of text from sitting in * `downloadFile` releases it once started, so a calendar's worth of text
* memory after the download has started. * does not sit in memory afterwards.
*/ */
const exportFile = async (c: Calendar) => { const exportFile = async (c: Calendar) => {
try { try {
const { text, count } = await cal.exportIcs(c.id); const { text, count } = await cal.exportIcs(c.id);
const url = URL.createObjectURL(new Blob([text], { type: "text/calendar" })); downloadFile(text, "text/calendar", `${c.name.replace(/[^\w.-]+/g, "_") || "calendar"}.ics`);
const a = document.createElement("a");
a.href = url;
a.download = `${c.name.replace(/[^\w.-]+/g, "_") || "calendar"}.ics`;
a.click();
URL.revokeObjectURL(url);
toast.success(plural(count, { one: "Exported {n} event", other: "Exported {n} events" })); toast.success(plural(count, { one: "Exported {n} event", other: "Exported {n} events" }));
} catch (err) { } catch (err) {
toast.error(t("Could not export this calendar: {error}", { error: (err as Error).message })); toast.error(t("Could not export this calendar: {error}", { error: (err as Error).message }));
+3 -5
View File
@@ -15,6 +15,7 @@ import { useSettings } from "@/store/settings";
import { ContactEditor } from "./ContactEditor"; import { ContactEditor } from "./ContactEditor";
import { avatarColor } from "@/lib/address"; import { avatarColor } from "@/lib/address";
import { plural, t as translate } from "@/lib/i18n"; import { plural, t as translate } from "@/lib/i18n";
import { downloadFile } from "@/lib/download";
export function ContactsView({ id }: { id?: string }) { export function ContactsView({ id }: { id?: string }) {
const [, navigate] = useLocation(); const [, navigate] = useLocation();
@@ -149,10 +150,7 @@ export function ContactsView({ id }: { id?: string }) {
toast.error(translate("There is nothing in it to export")); toast.error(translate("There is nothing in it to export"));
return; return;
} }
const a = document.createElement("a"); downloadFile(cards.map(toVCard).join(""), "text/vcard", "contacts.vcf");
a.href = URL.createObjectURL(new Blob([cards.map(toVCard).join("")], { type: "text/vcard" }));
a.download = "contacts.vcf";
a.click();
}; };
const importFile = async (f: File, intoBookId?: string) => { const importFile = async (f: File, intoBookId?: string) => {
@@ -359,7 +357,7 @@ function ContactDetail({ card: c, onBack, onEdit, narrow, onEmail }: { card: Con
{narrow && <button className="icon-btn" onClick={onBack} aria-label={translate("Back")}><ArrowLeft size={20} /></button>} {narrow && <button className="icon-btn" onClick={onBack} aria-label={translate("Back")}><ArrowLeft size={20} /></button>}
<span className="spacer" /> <span className="spacer" />
<button className="btn btn-sm" onClick={onEdit}><Pencil size={14} /> {translate("Edit")}</button> <button className="btn btn-sm" onClick={onEdit}><Pencil size={14} /> {translate("Edit")}</button>
<button className="btn btn-sm" onClick={() => { const a = document.createElement("a"); a.href = URL.createObjectURL(new Blob([toVCard(c)], { type: "text/vcard" })); a.download = `${name.replace(/[^\w.-]+/g, "_")}.vcf`; a.click(); }}><Download size={14} /> {translate("vCard")}</button> <button className="btn btn-sm" onClick={() => downloadFile(toVCard(c), "text/vcard", `${name.replace(/[^\w.-]+/g, "_")}.vcf`)}><Download size={14} /> {translate("vCard")}</button>
<button className="btn btn-sm btn-ghost" style={{ color: "var(--danger)" }} onClick={async () => { if (await confirmDialog({ title: translate("Delete {name}?", { name }), confirmLabel: translate("Delete"), danger: true })) { try { const { destroyed, refused } = await contacts.destroyCards([c.id]); if (!destroyed) { toast.error(refused ? setErrorMessage(refused) : translate("It was not deleted")); return; } toast.success(translate("Contact deleted")); navigate("/contacts"); } catch (err) { toast.error((err as Error).message); } } }}><Trash2 size={14} /></button> <button className="btn btn-sm btn-ghost" style={{ color: "var(--danger)" }} onClick={async () => { if (await confirmDialog({ title: translate("Delete {name}?", { name }), confirmLabel: translate("Delete"), danger: true })) { try { const { destroyed, refused } = await contacts.destroyCards([c.id]); if (!destroyed) { toast.error(refused ? setErrorMessage(refused) : translate("It was not deleted")); return; } toast.success(translate("Contact deleted")); navigate("/contacts"); } catch (err) { toast.error((err as Error).message); } } }}><Trash2 size={14} /></button>
</div> </div>
<div className="contact-hero"> <div className="contact-hero">
+2 -1
View File
@@ -25,6 +25,7 @@ import {
type DateFormat, type DateFormat,
} from "@/lib/datetime"; } from "@/lib/datetime";
import { isEnforced } from "@/lib/settingsPolicy"; import { isEnforced } from "@/lib/settingsPolicy";
import { downloadFile } from "@/lib/download";
/** Illustrative instant used for the format previews: 22 Nov 2025, 18:23. */ /** Illustrative instant used for the format previews: 22 Nov 2025, 18:23. */
const SAMPLE = new Date(2025, 10, 22, 18, 23); const SAMPLE = new Date(2025, 10, 22, 18, 23);
@@ -236,7 +237,7 @@ export function GeneralSettings() {
<h2>{t("Backup")}</h2> <h2>{t("Backup")}</h2>
<div className="row wrap"> <div className="row wrap">
<button className="btn" onClick={() => { const blob = new Blob([exportJson()], { type: "application/json" }); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = "ihasmail-settings.json"; a.click(); }}>{t("Export settings")}</button> <button className="btn" onClick={() => downloadFile(exportJson(), "application/json", "ihasmail-settings.json")}>{t("Export settings")}</button>
<label className="btn"> <label className="btn">
{t("Import settings")} {t("Import settings")}
<input type="file" accept="application/json" hidden onChange={async (e) => { const f = e.target.files?.[0]; if (!f) return; const ok = importJson(await f.text()); toast[ok ? "success" : "error"](ok ? t("Settings imported") : t("Invalid settings file")); e.target.value = ""; }} /> <input type="file" accept="application/json" hidden onChange={async (e) => { const f = e.target.files?.[0]; if (!f) return; const ok = importJson(await f.text()); toast[ok ? "success" : "error"](ok ? t("Settings imported") : t("Invalid settings file")); e.target.value = ""; }} />