From 82dc877fe19b9ae5023dc3be74259fa36b5a2473 Mon Sep 17 00:00:00 2001 From: jcoffey <51408202+jcoffey-dev@users.noreply.github.com> Date: Wed, 16 Sep 2026 13:47:59 -0700 Subject: [PATCH] Start at once on a device marked as your own (#395) * Start at once on a device marked as your own On a distant link, opening the app waited on four round trips before the inbox showed: the app page, the session, the folder list, then the folder. A trusted device now starts from what it kept: - the service worker answers an app route from its kept page and fetches a fresh one behind it; the app checks the server's version at start, and a reload for a new build puts the new page in place first, so it is not answered with the old one. Assets of the page just replaced are kept one build longer for a tab still running it. - the session's public details, so requests for mail go out before the server has confirmed the session; the answer replaces it, and a session that has ended lands on the sign-in form as before. - the folder list and the first page of up to four recently read folders, list properties only, so the folders and the inbox paint before any reply and the folder query does not wait on the folder list. The "folder no longer exists" check still waits for the server's list. All of it goes through the storage gate: nothing is written or read on a device not marked as the reader's own, and signing out clears it. * Show nothing kept before the session is confirmed Starting from a kept session put the kept inbox on screen before the server had said the session was still good; a session that had ended showed mail and then the sign-in form. The spinner stays until the server answers, as before. The kept session is gone -- it existed only to start early. The kept folder list and rows are still applied, from setAccount, which runs once the session is confirmed: the inbox paints the moment that answer arrives, and the folder query goes out then without waiting on the folder list. An unreachable server lands on the sign-in form as before. --- FEATURES.md | 23 ++- web/public/sw.js | 43 +++- web/src/lib/sw/staleBuild.ts | 25 +++ .../store/__tests__/startup-snapshot.test.ts | 193 ++++++++++++++++++ web/src/store/mail/index.ts | 99 ++++++++- web/src/store/mail/types.ts | 2 + web/src/views/mail/MailView.tsx | 7 +- web/src/views/mail/MailboxTree.tsx | 2 +- 8 files changed, 378 insertions(+), 16 deletions(-) create mode 100644 web/src/store/__tests__/startup-snapshot.test.ts diff --git a/FEATURES.md b/FEATURES.md index 0d550f7..bf67894 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -1414,8 +1414,13 @@ server settings is deliberately out of scope. # Platform - **Installable PWA** with a service worker: the app shell is cached for - installability and fast loads, API requests never are, and navigations are - network-first with the shell as fallback. + installability and fast loads, API requests never are. An app route is + answered from the kept shell at once while a fresh copy is fetched behind it; + a shell a build behind is caught by the version check at start and reloaded. + After a new version is seen, the rest of its code (composer, settings, + viewers) is fetched in the background, so opening them later does not wait on + the server; language catalogs are cached when first used, and nothing is + fetched ahead when the browser is set to save data. - **Manifest shortcuts** for Compose, Calendar and Contacts. - **One window, not one per launch.** A `mailto:` link, a shortcut or a notification opened while ihasmail is already running arrives in the copy @@ -1545,15 +1550,23 @@ costs something to get wrong is the one that assumes the machine is yours. | --- | --- | --- | | Stays signed in | until the browser closes | up to 30 days (`SESSION_REMEMBER_TTL`) | | Idle sign-out | after 5 minutes | none | -| Kept on the computer | nothing | settings cache, recent addresses, username | +| Kept on the computer | nothing | settings cache, recent addresses, username, and the folder list with the first page of recently read folders (list rows only: sender, subject, preview, flags — no message bodies) | | Background notifications | refused | available | | Administration | unavailable | available, if the role allows it | Local storage is gated on that answer for **reads** as well as writes — a machine trusted once still has residue, and honoring it would let a previous session's data surface in a later untrusted one. Signing out clears the settings -cache and recent addresses and tears down the push subscription, whichever -answer was given. +cache, recent addresses and kept folder list, and tears down the push +subscription, whichever answer was given. + +What a ticked device keeps is what makes it **start quickly on a distant +link**: once the server has confirmed the session, the folders and the inbox +paint from the kept copy straight away, and the request for the open folder +goes out without waiting on the folder list first. The server's answers +replace the copy a round trip later. **Nothing kept is shown before the session +is confirmed** — until then the app shows a spinner, so a session that has +ended goes from the spinner to the sign-in form and never past a mailbox. The idle timer exists because the alternative does not work: `beforeunload` text was removed from browsers years ago, and **no event fires at all** for walking diff --git a/web/public/sw.js b/web/public/sw.js index 5d2f8cd..8b31854 100644 --- a/web/public/sw.js +++ b/web/public/sw.js @@ -67,12 +67,17 @@ function assetsNamedIn(html) { return out; } -/** Drop failed responses, and assets the cached app page does not name. */ -async function tidy() { +/** + * Drop failed responses, and assets the cached app page does not name. `also` + * is a page whose assets are kept as well: the one just replaced, which a tab + * opened from the kept copy may still be running. + */ +async function tidy(also = "") { const cache = await caches.open(VERSION); const shell = await cache.match(SHELL_KEY); // Without a page to go by, which assets are current is unknown; keep them. const keep = shell ? assetsNamedIn(await shell.text()) : null; + if (keep) for (const path of assetsNamedIn(also)) keep.add(path); for (const req of await cache.keys()) { const path = new URL(req.url).pathname; if (path.startsWith(ASSETS)) { @@ -91,9 +96,10 @@ async function refreshShell(res) { const html = await res.text(); const cache = await caches.open(VERSION); const prev = await cache.match(SHELL_KEY); - if (!prev || (await prev.text()) !== html) { + const prevHtml = prev ? await prev.text() : ""; + if (prevHtml !== html) { await cache.put(SHELL_KEY, new Response(html, { headers: { "content-type": "text/html; charset=utf-8" } })); - await tidy(); + await tidy(prevHtml); } await precache(html); } @@ -228,15 +234,38 @@ self.addEventListener("fetch", (event) => { return; } - // Navigations & everything else: network-first, fall back to cached shell. + /* + * Navigations: the kept app page at once, and the network's behind it. + * + * Every route in the app is the same page, and waiting on the server for it + * cost a full round trip before anything could start -- the longest single + * wait on a distant link. So a route is answered from the kept copy when + * there is one, and the fresh page is fetched alongside to replace it for + * next time. A page that is a build behind is caught the way it always was: + * the version check reloads it (lib/sw/staleBuild.ts), and the assets it + * names are kept for one more build so it can run until then. + * + * Only app routes. An address ending in a file name -- an image or the + * manifest opened in a tab of its own -- is not the app page, and goes to + * the network as before. So does the first visit, which has no copy yet. + */ if (req.mode === "navigate") { - event.respondWith(fetch(req).then((res) => { + const network = fetch(req).then((res) => { // Every route is the same app page; a fresh one replaces the offline copy. if (res.ok && (res.headers.get("content-type") || "").startsWith("text/html")) { event.waitUntil(refreshShell(res.clone()).catch(() => {})); } return res; - }).catch(() => caches.match(SHELL_KEY))); + }); + const appRoute = !/\.[a-z0-9]+$/i.test(url.pathname); + event.respondWith((async () => { + const kept = appRoute ? await caches.match(SHELL_KEY) : undefined; + if (kept) { + event.waitUntil(network.catch(() => {})); + return kept; + } + return network.catch(() => caches.match(SHELL_KEY)); + })()); return; } event.respondWith(fetch(req).catch(() => caches.match(req))); diff --git a/web/src/lib/sw/staleBuild.ts b/web/src/lib/sw/staleBuild.ts index 34c929c..7d307d0 100644 --- a/web/src/lib/sw/staleBuild.ts +++ b/web/src/lib/sw/staleBuild.ts @@ -1,5 +1,6 @@ import { APP_VERSION } from "../version"; import { withBase } from "../basePath"; +import { SW_CACHE_NAME } from "./swCache"; import { push, type PushState } from "@/jmap/push"; /** @@ -93,10 +94,30 @@ async function check(): Promise { // deploy -- this stops the two of them reloading each other in a loop. if (tried() === serverVersion) return false; remember(serverVersion); + await primeShell(); window.location.reload(); return true; } +/* + * The service worker answers a navigation from its kept copy of the app page + * and refreshes that copy behind it. A reload for a new build must not get the + * old copy back, so the new page is put in place first. Best effort: if this + * fails, the loop guard above still stops a second reload. + */ +async function primeShell(): Promise { + if (!("caches" in window) || !navigator.serviceWorker?.controller) return; + try { + const res = await fetch(withBase("/"), { credentials: "same-origin", cache: "no-store" }); + if (!res.ok || !(res.headers.get("content-type") ?? "").startsWith("text/html")) return; + const html = await res.text(); + const cache = await caches.open(SW_CACHE_NAME); + await cache.put(withBase("/"), new Response(html, { headers: { "content-type": "text/html; charset=utf-8" } })); + } catch { + /* reload anyway */ + } +} + /** * Watch for a deploy without waiting to be asked. * @@ -139,6 +160,10 @@ export function makeConnectionWatcher(): (state: PushState) => void { export function startBuildWatch(): void { push.onConnection(makeConnectionWatcher()); + // A page the service worker answered from its kept copy may be a build + // behind; ask now rather than a minute from now. + if (navigator.serviceWorker?.controller) void reloadIfServerRebuilt(); + window.setInterval(() => { // A hidden tab is not being read, and will be checked when it surfaces. if (document.visibilityState === "visible") void reloadIfServerRebuilt(); diff --git a/web/src/store/__tests__/startup-snapshot.test.ts b/web/src/store/__tests__/startup-snapshot.test.ts new file mode 100644 index 0000000..fdbd3b6 --- /dev/null +++ b/web/src/store/__tests__/startup-snapshot.test.ts @@ -0,0 +1,193 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { CAP } from "@/jmap/client"; +import type { JmapSession } from "@/jmap/types"; +import { buildSnapshot, useMail } from "@/store/mail"; +import { useSession } from "@/store/session"; +import { clearSignedInData, setDeviceTrusted } from "@/lib/storage"; + +/** + * Starting from what a trusted device kept: the folder list and the first page + * of recent folders, applied once the server has confirmed the session. On a + * distant link each of those was a round trip before the inbox could show. + */ + +const INBOX = "mbInbox"; +const session = (remember: boolean) => + ({ + capabilities: { [CAP.core]: { maxObjectsInGet: 500 }, [CAP.mail]: {} }, + accounts: { a1: { name: "me", isPersonal: true, isReadOnly: false, accountCapabilities: { [CAP.mail]: {} } } }, + primaryAccounts: { [CAP.mail]: "a1" }, + state: "s", + username: "me", + apiUrl: "", + downloadUrl: "", + uploadUrl: "", + eventSourceUrl: "", + ihasmail: { remember, sessionId: "public-id" }, + }) as unknown as JmapSession; + +const email = (id: string) => ({ + id, + threadId: `t${id}`, + mailboxIds: { [INBOX]: true }, + keywords: {}, + receivedAt: "2026-09-16T00:00:00Z", + subject: `Subject ${id}`, + preview: "p", + bodyValues: { "1": { value: "secret body" } }, +}); + +const inboxQuery = { key: "", filter: { inMailbox: INBOX }, sort: [], collapseThreads: false, mailboxId: INBOX }; + +/** The key the store gives the inbox list, learned by asking for it. */ +function inboxKey(): string { + useMail.getState().setAccount("probe"); + void useMail.getState().query(inboxQuery); + const key = useMail.getState().list!.key; + useMail.getState().setAccount(null); + return key; +} + +function signedInWithList() { + const key = inboxKey(); + useMail.getState().setAccount("a1"); + useMail.setState({ + mailboxes: { [INBOX]: { id: INBOX, role: "inbox", name: "Inbox" } } as never, + mailboxesLoaded: true, + emails: { e1: email("e1"), e2: email("e2") } as never, + list: { key, filter: { inMailbox: INBOX }, sort: [], collapseThreads: false, mailboxId: INBOX, ids: ["e1", "e2"], total: 7, queryState: "q", loading: false, loadingMore: false, error: null, exhausted: false }, + }); +} + +let pending: ((v: Response) => void) | null; + +beforeEach(() => { + localStorage.clear(); + vi.useFakeTimers(); + pending = null; + // The session request stays unanswered unless a test answers it. + vi.stubGlobal("fetch", vi.fn(() => new Promise((resolve) => { pending = resolve; }))); + useMail.getState().setAccount(null); + useSession.setState({ status: "loading", session: null, accountId: null }); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + setDeviceTrusted(false); + localStorage.clear(); +}); + +describe("the kept mail snapshot", () => { + it("holds folders and list rows without bodies", () => { + signedInWithList(); + const snap = buildSnapshot(useMail.getState())!; + expect(snap.mailboxes.map((m) => m.id)).toEqual([INBOX]); + expect(snap.lists).toEqual([{ key: inboxKey(), ids: ["e1", "e2"], total: 7 }]); + expect(snap.emails.map((e) => e.id)).toEqual(["e1", "e2"]); + expect(JSON.stringify(snap)).not.toContain("secret body"); + }); + + it("is not made before the server's folder list has arrived", () => { + signedInWithList(); + useMail.setState({ mailboxesLoaded: false }); + expect(buildSnapshot(useMail.getState())).toBeNull(); + }); + + it("paints the folders and the inbox on the next start of a trusted device", () => { + setDeviceTrusted(true); + useSession.setState({ status: "authenticated", accountId: "a1" }); + signedInWithList(); + // A change to the list schedules the save. + useMail.setState((s) => ({ list: { ...s.list!, total: 8 } })); + vi.advanceTimersByTime(3500); + expect(localStorage.getItem("ihasmail:mail-snapshot")).toContain('"e1"'); + + // Next start. + useMail.getState().setAccount(null); + useMail.getState().setAccount("a1"); + const s = useMail.getState(); + expect(s.mailboxesCached).toBe(true); + expect(s.mailboxesLoaded).toBe(false); + expect(s.mailboxes[INBOX]?.name).toBe("Inbox"); + void s.query(inboxQuery); + expect(useMail.getState().list).toMatchObject({ ids: ["e1", "e2"], total: 8, loading: true }); + }); + + it("is neither written nor read on a device not marked as the reader's own", () => { + setDeviceTrusted(true); + useSession.setState({ status: "authenticated", accountId: "a1" }); + signedInWithList(); + useMail.setState((s) => ({ list: { ...s.list!, total: 8 } })); + vi.advanceTimersByTime(3500); + setDeviceTrusted(false); + useMail.getState().setAccount(null); + useMail.getState().setAccount("a1"); + expect(useMail.getState().mailboxesCached).toBe(false); + expect(useMail.getState().mailboxes).toEqual({}); + + localStorage.clear(); + useSession.setState({ status: "authenticated", accountId: "a1" }); + signedInWithList(); + useMail.setState((s) => ({ list: { ...s.list!, total: 9 } })); + vi.advanceTimersByTime(3500); + expect(localStorage.getItem("ihasmail:mail-snapshot")).toBeNull(); + }); + + it("is gone after signing out, and a save scheduled before it does not bring it back", () => { + setDeviceTrusted(true); + useSession.setState({ status: "authenticated", accountId: "a1" }); + signedInWithList(); + useMail.setState((s) => ({ list: { ...s.list!, total: 8 } })); + vi.advanceTimersByTime(3500); + useMail.setState((s) => ({ list: { ...s.list!, total: 9 } })); + clearSignedInData(); + useSession.setState({ status: "anonymous", accountId: null }); + vi.advanceTimersByTime(3500); + expect(localStorage.getItem("ihasmail:mail-snapshot")).toBeNull(); + }); + + it("belongs to one account", () => { + setDeviceTrusted(true); + useSession.setState({ status: "authenticated", accountId: "a1" }); + signedInWithList(); + useMail.setState((s) => ({ list: { ...s.list!, total: 8 } })); + vi.advanceTimersByTime(3500); + useMail.getState().setAccount("a2"); + expect(useMail.getState().mailboxesCached).toBe(false); + expect(useMail.getState().emails).toEqual({}); + }); +}); + +describe("before the server has confirmed the session", () => { + it("shows nothing kept: the spinner stays until the answer, and the kept folders arrive with it", async () => { + setDeviceTrusted(true); + useSession.setState({ status: "authenticated", accountId: "a1" }); + signedInWithList(); + useMail.setState((s) => ({ list: { ...s.list!, total: 8 } })); + vi.advanceTimersByTime(3500); + // Next start. + useMail.getState().setAccount(null); + useSession.setState({ status: "loading", session: null, accountId: null }); + + const boot = useSession.getState().bootstrap(); + expect(useSession.getState().status).toBe("loading"); + expect(useMail.getState().accountId).toBeNull(); + expect(useMail.getState().mailboxes).toEqual({}); + expect(useMail.getState().emails).toEqual({}); + + pending!({ ok: true, status: 200, json: async () => session(true) } as Response); + await boot; + expect(useSession.getState().status).toBe("authenticated"); + expect(useMail.getState().mailboxesCached).toBe(true); + expect(useMail.getState().mailboxes[INBOX]?.name).toBe("Inbox"); + }); + + it("keeps no session of its own", async () => { + setDeviceTrusted(true); + const boot = useSession.getState().bootstrap(); + pending!({ ok: true, status: 200, json: async () => session(true) } as Response); + await boot; + expect(localStorage.getItem("ihasmail:session")).toBeNull(); + }); +}); diff --git a/web/src/store/mail/index.ts b/web/src/store/mail/index.ts index 1741959..78ace03 100644 --- a/web/src/store/mail/index.ts +++ b/web/src/store/mail/index.ts @@ -27,6 +27,7 @@ import { useSession } from "../session"; import { mailboxDisplayName } from "@/lib/mailbox/mailboxName"; import { plural, t } from "@/lib/i18n"; import { withBase } from "@/lib/basePath"; +import { isDeviceTrusted, loadRaw, saveJson } from "@/lib/storage"; import { MAILBOX_PROPS, LIST_PROPS, FULL_PROPS, BODY_PROPS } from "./props"; import { type ListQuery, type MailState } from "./types"; import { playNewMailSound, showNotification } from "@/lib/notify/notify"; @@ -79,6 +80,7 @@ export const useMail = create((set, get) => ({ mailboxes: {}, mailboxState: null, mailboxesLoaded: false, + mailboxesCached: false, emails: {}, fullIds: {}, emailState: null, @@ -108,6 +110,7 @@ export const useMail = create((set, get) => ({ mailboxes: {}, mailboxState: null, mailboxesLoaded: false, + mailboxesCached: false, emails: {}, fullIds: {}, emailState: null, @@ -121,6 +124,7 @@ export const useMail = create((set, get) => ({ anchorId: null, lastSeenInboxEmailIds: null, }); + if (accountId) restoreSnapshot(accountId); }, async loadMailboxes() { @@ -129,7 +133,7 @@ export const useMail = create((set, get) => ({ const res = await client.call>("Mailbox/get", { accountId, ids: null, properties: MAILBOX_PROPS }); const mailboxes: Record = {}; for (const m of res.list) mailboxes[m.id] = m; - set({ mailboxes, mailboxState: res.state, mailboxesLoaded: true }); + set({ mailboxes, mailboxState: res.state, mailboxesLoaded: true, mailboxesCached: false }); // Label counts move for the same reasons folder counts do -- something was // read, moved or deleted -- so they are refreshed on the same beat rather // than on a timer of their own. Not awaited: the folder tree should not @@ -1247,6 +1251,83 @@ function snapshotFor(key: string, filter: EmailFilter, emails: Record return { ids, total: snap.total - (snap.ids.length - ids.length) }; } +/* + * What a device marked as the reader's own keeps between visits. + * + * Opening the app used to wait on the folder list before it could ask for a + * folder, and on that before anything showed: on a distant link, a second or + * so of skeleton on every start. A trusted device now keeps the folder list + * and the first page of the last few folders, list properties only -- no + * bodies -- and starts from them: the folders and the inbox paint as soon as + * the server has confirmed the session, and the query for the open folder goes + * out then without waiting for the folder list, which corrects both a round + * trip later. + * + * Never sooner. This is applied from `setAccount`, which runs only once the + * session is confirmed, so a session that has ended shows the spinner and then + * the sign-in form, and none of this in between. + * + * It is written through the same gated storage as the settings cache: nothing + * is kept on a device not marked as the reader's own, nothing is read there + * either, and signing out clears it with everything else. + */ +const SNAPSHOT_KEY = "mail-snapshot"; +const SNAPSHOT_LISTS = 4; +const SNAPSHOT_ROWS = 50; +const SNAPSHOT_MAX_CHARS = 400_000; + +export interface MailSnapshot { + v: 1; + accountId: Id; + mailboxes: Mailbox[]; + lists: { key: string; ids: Id[]; total: number }[]; + emails: Email[]; +} + +export function buildSnapshot(s: MailState): MailSnapshot | null { + if (!s.accountId || !s.mailboxesLoaded) return null; + const lists: MailSnapshot["lists"] = []; + const cur = s.list; + if (cur && !cur.loading && !cur.error && folderOf(cur.filter)) lists.push({ key: cur.key, ids: cur.ids, total: cur.total }); + for (const [key, snap] of [...snapshots].reverse()) { + if (lists.length >= SNAPSHOT_LISTS) break; + if (!lists.some((l) => l.key === key)) lists.push({ key, ...snap }); + } + const ids = new Set(); + const kept = lists.map((l) => { + const rows = l.ids.filter((id) => s.emails[id]).slice(0, SNAPSHOT_ROWS); + rows.forEach((id) => ids.add(id)); + return { key: l.key, ids: rows, total: l.total }; + }); + const emails = [...ids].map((id) => Object.fromEntries(Object.entries(s.emails[id]!).filter(([k]) => LIST_KEYS.has(k))) as unknown as Email); + return { v: 1, accountId: s.accountId, mailboxes: Object.values(s.mailboxes), lists: kept, emails }; +} + +let snapshotTimer: ReturnType | null = null; + +function saveSnapshot(): void { + if (snapshotTimer) clearTimeout(snapshotTimer); + snapshotTimer = null; + if (!isDeviceTrusted() || useSession.getState().status !== "authenticated") return; + const s = useMail.getState(); + if (s.accountId !== useSession.getState().accountId) return; + const snap = buildSnapshot(s); + if (!snap) return; + if (JSON.stringify(snap).length > SNAPSHOT_MAX_CHARS) return; + saveJson(SNAPSHOT_KEY, snap); +} + +function restoreSnapshot(accountId: Id): void { + const snap = loadRaw(SNAPSHOT_KEY, null); + if (!snap || snap.v !== 1 || snap.accountId !== accountId || !Array.isArray(snap.mailboxes) || !snap.mailboxes.length) return; + const mailboxes: Record = {}; + for (const m of snap.mailboxes) mailboxes[m.id] = m; + const emails: Record = {}; + for (const e of snap.emails ?? []) emails[e.id] = e; + for (const l of snap.lists ?? []) snapshots.set(l.key, { ids: l.ids, total: l.total }); + useMail.setState({ mailboxes, mailboxesCached: true, emails }); +} + let sortRefused = false; async function runQuery(accountId: Id, q: ListQuery, position: number, limit: number) { @@ -1513,3 +1594,19 @@ async function followFolders(before: FolderRef[]): Promise { toast.error(t("Folder changed, but its filter rules could not be updated: {error}", { error: (err as Error).message })); } } + +/* + * Kept a few seconds after the folders or the list last changed, and when the + * page is being put away, which is the last chance a closing tab gets. + */ +useMail.subscribe((s, prev) => { + if (s.mailboxes === prev.mailboxes && s.list === prev.list) return; + if (!isDeviceTrusted()) return; + if (snapshotTimer) clearTimeout(snapshotTimer); + snapshotTimer = setTimeout(saveSnapshot, 3000); +}); +if (typeof window !== "undefined") { + window.addEventListener("pagehide", () => { + if (snapshotTimer) saveSnapshot(); + }); +} diff --git a/web/src/store/mail/types.ts b/web/src/store/mail/types.ts index 2bbca7a..1121d57 100644 --- a/web/src/store/mail/types.ts +++ b/web/src/store/mail/types.ts @@ -36,6 +36,8 @@ export interface MailState { mailboxes: Record; mailboxState: string | null; mailboxesLoaded: boolean; + /** The folder list shown is the copy this device kept, not yet confirmed by the server. */ + mailboxesCached: boolean; emails: Record; fullIds: Record; emailState: string | null; diff --git a/web/src/views/mail/MailView.tsx b/web/src/views/mail/MailView.tsx index 8fbb21c..6d5ef47 100644 --- a/web/src/views/mail/MailView.tsx +++ b/web/src/views/mail/MailView.tsx @@ -27,6 +27,9 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string; const searchStr = useSearch(); const mailboxes = useMail((s) => s.mailboxes); const mailboxesLoaded = useMail((s) => s.mailboxesLoaded); + // Enough to ask for a folder: the kept copy of the folder list will do, and + // staying true as the server's copy replaces it keeps the query from repeating. + const mailboxesKnown = useMail((s) => s.mailboxesLoaded || s.mailboxesCached); const inboxId = useMail((s) => s.roleId("inbox")); const query = useMail((s) => s.query); const list = useMail((s) => s.list); @@ -114,8 +117,8 @@ export function MailView({ mailboxId, threadId, search }: { mailboxId?: string; }, [search, q, mailboxId, folderShape, settings.conversationMode, scheduledId]); useEffect(() => { - if (listQuery && mailboxesLoaded) void query(listQuery); - }, [listQuery, query, mailboxesLoaded]); + if (listQuery && mailboxesKnown) void query(listQuery); + }, [listQuery, query, mailboxesKnown]); // Nothing moves a message out of Scheduled when its hold expires, so settle // the folder up on the way in: sent messages to Sent, canceled ones back to diff --git a/web/src/views/mail/MailboxTree.tsx b/web/src/views/mail/MailboxTree.tsx index 222a139..4e4b1ce 100644 --- a/web/src/views/mail/MailboxTree.tsx +++ b/web/src/views/mail/MailboxTree.tsx @@ -38,7 +38,7 @@ const FOLDER_MIME = "application/x-ihasmail-folder"; export function MailboxTree() { const mailboxes = useMail((s) => s.mailboxes); - const loaded = useMail((s) => s.mailboxesLoaded); + const loaded = useMail((s) => s.mailboxesLoaded || s.mailboxesCached); const [location] = useLocation(); const currentId = location.startsWith("/mail/") ? location.split("/")[2] : undefined; const showHidden = useSettings((s) => s.settings.showHiddenFolders);