From f1234678970450ed22db6db7b2b97367cc4ec9ec Mon Sep 17 00:00:00 2001 From: John Coffey Date: Wed, 16 Sep 2026 10:59:57 -0700 Subject: [PATCH] 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. --- web/src/lib/download.ts | 16 ++++ web/src/store/__tests__/body-eviction.test.ts | 81 +++++++++++++++++++ web/src/store/mail/index.ts | 54 +++++++++++++ web/src/views/calendar/CalendarSidebar.tsx | 14 ++-- web/src/views/contacts/ContactsView.tsx | 8 +- web/src/views/settings/GeneralSettings.tsx | 3 +- 6 files changed, 161 insertions(+), 15 deletions(-) create mode 100644 web/src/lib/download.ts create mode 100644 web/src/store/__tests__/body-eviction.test.ts diff --git a/web/src/lib/download.ts b/web/src/lib/download.ts new file mode 100644 index 0000000..a813daf --- /dev/null +++ b/web/src/lib/download.ts @@ -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); +} diff --git a/web/src/store/__tests__/body-eviction.test.ts b/web/src/store/__tests__/body-eviction.test.ts new file mode 100644 index 0000000..a9a9224 --- /dev/null +++ b/web/src/store/__tests__/body-eviction.test.ts @@ -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: "

".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][] }; + 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); + }); +}); diff --git a/web/src/store/mail/index.ts b/web/src/store/mail/index.ts index 21fadf8..cd25d4c 100644 --- a/web/src/store/mail/index.ts +++ b/web/src/store/mail/index.ts @@ -99,6 +99,7 @@ export const useMail = create((set, get) => ({ setAccount(accountId) { if (accountId === get().accountId) return; + resetBodyOrder(); set({ accountId, mailboxes: {}, @@ -263,6 +264,10 @@ export const useMail = create((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(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 { + 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) { diff --git a/web/src/views/calendar/CalendarSidebar.tsx b/web/src/views/calendar/CalendarSidebar.tsx index 9a51859..1ebb135 100644 --- a/web/src/views/calendar/CalendarSidebar.tsx +++ b/web/src/views/calendar/CalendarSidebar.tsx @@ -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(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 })); diff --git a/web/src/views/contacts/ContactsView.tsx b/web/src/views/contacts/ContactsView.tsx index 1b40658..92a74a9 100644 --- a/web/src/views/contacts/ContactsView.tsx +++ b/web/src/views/contacts/ContactsView.tsx @@ -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 && } - +

diff --git a/web/src/views/settings/GeneralSettings.tsx b/web/src/views/settings/GeneralSettings.tsx index c7950c8..67d4cfb 100644 --- a/web/src/views/settings/GeneralSettings.tsx +++ b/web/src/views/settings/GeneralSettings.tsx @@ -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() {

{t("Backup")}

- +