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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -16,6 +16,7 @@ import type { Calendar, Id } from "@/jmap/types";
|
||||
import { CalendarDialog } from "./CalendarDialog";
|
||||
import { ShareDialog } from "../settings/ShareDialog";
|
||||
import { plural, t } from "@/lib/i18n";
|
||||
import { downloadFile } from "@/lib/download";
|
||||
|
||||
export function CalendarSidebar() {
|
||||
const [location, navigate] = useLocation();
|
||||
@@ -42,19 +43,14 @@ export function CalendarSidebar() {
|
||||
const importInto = useRef<Id | null>(null);
|
||||
|
||||
/*
|
||||
* Handing the file over, which the browser only does from a click. The
|
||||
* revoke below is what keeps a calendar's worth of text from sitting in
|
||||
* memory after the download has started.
|
||||
* Handing the file over, which the browser only does from a click.
|
||||
* `downloadFile` releases it once started, so a calendar's worth of text
|
||||
* does not sit in memory afterwards.
|
||||
*/
|
||||
const exportFile = async (c: Calendar) => {
|
||||
try {
|
||||
const { text, count } = await cal.exportIcs(c.id);
|
||||
const url = URL.createObjectURL(new Blob([text], { type: "text/calendar" }));
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${c.name.replace(/[^\w.-]+/g, "_") || "calendar"}.ics`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
downloadFile(text, "text/calendar", `${c.name.replace(/[^\w.-]+/g, "_") || "calendar"}.ics`);
|
||||
toast.success(plural(count, { one: "Exported {n} event", other: "Exported {n} events" }));
|
||||
} catch (err) {
|
||||
toast.error(t("Could not export this calendar: {error}", { error: (err as Error).message }));
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useSettings } from "@/store/settings";
|
||||
import { ContactEditor } from "./ContactEditor";
|
||||
import { avatarColor } from "@/lib/address";
|
||||
import { plural, t as translate } from "@/lib/i18n";
|
||||
import { downloadFile } from "@/lib/download";
|
||||
|
||||
export function ContactsView({ id }: { id?: string }) {
|
||||
const [, navigate] = useLocation();
|
||||
@@ -149,10 +150,7 @@ export function ContactsView({ id }: { id?: string }) {
|
||||
toast.error(translate("There is nothing in it to export"));
|
||||
return;
|
||||
}
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(new Blob([cards.map(toVCard).join("")], { type: "text/vcard" }));
|
||||
a.download = "contacts.vcf";
|
||||
a.click();
|
||||
downloadFile(cards.map(toVCard).join(""), "text/vcard", "contacts.vcf");
|
||||
};
|
||||
|
||||
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>}
|
||||
<span className="spacer" />
|
||||
<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>
|
||||
</div>
|
||||
<div className="contact-hero">
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
type DateFormat,
|
||||
} from "@/lib/datetime";
|
||||
import { isEnforced } from "@/lib/settingsPolicy";
|
||||
import { downloadFile } from "@/lib/download";
|
||||
|
||||
/** Illustrative instant used for the format previews: 22 Nov 2025, 18:23. */
|
||||
const SAMPLE = new Date(2025, 10, 22, 18, 23);
|
||||
@@ -236,7 +237,7 @@ export function GeneralSettings() {
|
||||
|
||||
<h2>{t("Backup")}</h2>
|
||||
<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">
|
||||
{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 = ""; }} />
|
||||
|
||||
Reference in New Issue
Block a user