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.
This commit is contained in:
jcoffey
2026-09-16 13:47:59 -07:00
committed by GitHub
parent 4c67460450
commit 82dc877fe1
8 changed files with 378 additions and 16 deletions
@@ -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<Response>((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();
});
});
+98 -1
View File
@@ -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<MailState>((set, get) => ({
mailboxes: {},
mailboxState: null,
mailboxesLoaded: false,
mailboxesCached: false,
emails: {},
fullIds: {},
emailState: null,
@@ -108,6 +110,7 @@ export const useMail = create<MailState>((set, get) => ({
mailboxes: {},
mailboxState: null,
mailboxesLoaded: false,
mailboxesCached: false,
emails: {},
fullIds: {},
emailState: null,
@@ -121,6 +124,7 @@ export const useMail = create<MailState>((set, get) => ({
anchorId: null,
lastSeenInboxEmailIds: null,
});
if (accountId) restoreSnapshot(accountId);
},
async loadMailboxes() {
@@ -129,7 +133,7 @@ export const useMail = create<MailState>((set, get) => ({
const res = await client.call<GetResponse<Mailbox>>("Mailbox/get", { accountId, ids: null, properties: MAILBOX_PROPS });
const mailboxes: Record<Id, Mailbox> = {};
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<Id, Email>
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<Id>();
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<typeof setTimeout> | 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<MailSnapshot | null>(SNAPSHOT_KEY, null);
if (!snap || snap.v !== 1 || snap.accountId !== accountId || !Array.isArray(snap.mailboxes) || !snap.mailboxes.length) return;
const mailboxes: Record<Id, Mailbox> = {};
for (const m of snap.mailboxes) mailboxes[m.id] = m;
const emails: Record<Id, Email> = {};
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<void> {
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();
});
}
+2
View File
@@ -36,6 +36,8 @@ export interface MailState {
mailboxes: Record<Id, Mailbox>;
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<Id, Email>;
fullIds: Record<Id, true>;
emailState: string | null;