Merge pull request #383 from Coffey-Labs/perf/lazy-store-init

Ask shared accounts together, and about files only when Files opens
This commit is contained in:
jcoffey
2026-09-16 10:06:53 -07:00
committed by GitHub
6 changed files with 142 additions and 31 deletions
@@ -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 { useSession } from "@/store/session";
import { useFiles } from "@/store/files";
import { useContacts } from "@/store/contacts";
import { useCalendar } from "@/store/calendar";
/**
* What signing in costs for each account somebody has shared with the reader.
*
* The files, contacts and calendar stores each asked every shared account a
* question at sign-in, one account after another -- a request apiece, before
* the reader had opened any of those views. The questions now go out together,
* and Files does not ask at all until it is opened.
*/
const SHARED = ["s1", "s2", "s3"];
const session = {
capabilities: { [CAP.core]: { maxCallsInRequest: 16, maxObjectsInGet: 500 }, [CAP.filenode]: {}, [CAP.contacts]: {}, [CAP.calendars]: {} },
accounts: {
own: { name: "[email protected]", isPersonal: true, accountCapabilities: { [CAP.filenode]: {}, [CAP.contacts]: {}, [CAP.calendars]: {} } },
...Object.fromEntries(SHARED.map((id) => [id, { name: `${id}@example.com`, isPersonal: false, accountCapabilities: {} }])),
},
primaryAccounts: { [CAP.filenode]: "own", [CAP.contacts]: "own", [CAP.calendars]: "own" },
state: "s",
} as unknown as JmapSession;
type Call = [string, Record<string, unknown>, string];
let requests: Call[][];
beforeEach(() => {
requests = [];
vi.stubGlobal("fetch", vi.fn(async (_url: string, init: RequestInit) => {
const { methodCalls } = JSON.parse(init.body as string) as { methodCalls: Call[] };
requests.push(methodCalls);
const methodResponses = methodCalls.map(([name, args, id]) => {
const accountId = args.accountId as string;
if (name === "FileNode/query") return [name, { accountId, ids: accountId === "s2" ? ["f1"] : [], total: 0, position: 0, queryState: "q" }, id];
if (name === "Calendar/get") return [name, { accountId, state: "1", list: [{ id: `cal-${accountId}`, name: `Calendar of ${accountId}` }], notFound: [] }, id];
return [name, { accountId, state: "1", list: [], notFound: [] }, id];
});
return { ok: true, status: 200, json: async () => ({ methodResponses, sessionState: "s" }) } as Response;
}));
client.session = session;
useSession.setState({ status: "authenticated", session, accountId: "own" });
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("shared accounts", () => {
it("are not asked about files at sign-in", async () => {
await useFiles.getState().init();
expect(requests).toHaveLength(0);
expect(useFiles.getState().available).toBe(true);
expect(useFiles.getState().ownAccountId).toBe("own");
});
it("are asked about files together when Files wants to know", async () => {
await useFiles.getState().discoverShared();
expect(requests).toHaveLength(1);
expect(requests[0]!.map(([n, a]) => `${n} ${a.accountId}`)).toEqual(SHARED.map((id) => `FileNode/query ${id}`));
expect(useFiles.getState().sharedAccounts).toEqual([{ id: "s2", name: "[email protected]" }]);
});
it("are asked about address books in one request", async () => {
await useContacts.getState().loadShared();
expect(requests).toHaveLength(1);
expect(requests[0]!.filter(([n]) => n === "AddressBook/get")).toHaveLength(3);
});
it("are asked about calendars in one request, and listed in the session's order", async () => {
await useCalendar.getState().loadSharedCalendars();
expect(requests).toHaveLength(1);
expect(useCalendar.getState().sharedCalendars.map((c) => c.accountId)).toEqual(SHARED);
});
});
+16 -16
View File
@@ -409,14 +409,13 @@ export const useCalendar = create<CalendarState>((set, get) => ({
if (accountId !== get().accountId) set({ accountId, calendars: {}, events: {}, ranges: {} }); if (accountId !== get().accountId) set({ accountId, calendars: {}, events: {}, ranges: {} });
set({ available }); set({ available });
if (!available) return; if (!available) return;
await get().loadCalendars(); // Side by side: none of the three waits on another, and together they share a request.
const identities = client.call<GetResponse<ParticipantIdentity>>("ParticipantIdentity/get", { accountId, ids: null }).then(
(res) => set({ identities: res.list }),
() => set({ identities: [] }),
);
void get().loadSharedCalendars(); void get().loadSharedCalendars();
try { await Promise.all([get().loadCalendars(), identities]);
const res = await client.call<GetResponse<ParticipantIdentity>>("ParticipantIdentity/get", { accountId, ids: null });
set({ identities: res.list });
} catch {
set({ identities: [] });
}
}, },
/* /*
@@ -434,15 +433,16 @@ export const useCalendar = create<CalendarState>((set, get) => ({
const session = useSession.getState(); const session = useSession.getState();
const own = session.ownAccountFor(CAP.calendars); const own = session.ownAccountFor(CAP.calendars);
const accounts = Object.entries(session.session?.accounts ?? {}).filter(([id, a]) => a.isPersonal === false && id !== own); const accounts = Object.entries(session.session?.accounts ?? {}).filter(([id, a]) => a.isPersonal === false && id !== own);
const found: SharedCalendar[] = []; // Every account at once, in one request, and listed in the session's order.
for (const [accountId, account] of accounts) { const answers = await Promise.all(
try { accounts.map(([accountId, account]) =>
const res = await client.call<GetResponse<Calendar>>("Calendar/get", { accountId, ids: null, properties: CALENDAR_PROPS }); client.call<GetResponse<Calendar>>("Calendar/get", { accountId, ids: null, properties: CALENDAR_PROPS }).then(
for (const calendar of res.list) found.push({ accountId, accountName: account.name, calendar }); (res) => res.list.map((calendar): SharedCalendar => ({ accountId, accountName: account.name, calendar })),
} catch { (): SharedCalendar[] => [],
continue; ),
} ),
} );
const found = answers.flat();
set({ sharedCalendars: found }); set({ sharedCalendars: found });
// Fill in whatever windows are already on screen. // Fill in whatever windows are already on screen.
for (const key of Object.keys(get().ranges)) { for (const key of Object.keys(get().ranges)) {
+8 -4
View File
@@ -294,7 +294,9 @@ export const useContacts = create<ContactsState>((set, get) => ({
} }
const books: SharedBook[] = []; const books: SharedBook[] = [];
const cards: Record<string, ContactCard> = {}; const cards: Record<string, ContactCard> = {};
for (const [accountId, account] of accounts) { // Every account at once: calls made in one tick share a request, where a
// loop sent one after another for each account shared with the reader.
await Promise.all(accounts.map(async ([accountId, account]) => {
try { try {
const res = await client.call<GetResponse<AddressBook>>("AddressBook/get", { accountId, ids: null, properties: ADDRESS_BOOK_PROPS }); const res = await client.call<GetResponse<AddressBook>>("AddressBook/get", { accountId, ids: null, properties: ADDRESS_BOOK_PROPS });
for (const book of res.list) books.push({ accountId, accountName: account.name, book }); for (const book of res.list) books.push({ accountId, accountName: account.name, book });
@@ -310,7 +312,7 @@ export const useContacts = create<ContactsState>((set, get) => ({
*/ */
const added = new Set(useSettings.getState().settings.addedShares); const added = new Set(useSettings.getState().settings.addedShares);
const wanted = new Set(res.list.filter((b) => b.isSubscribed || added.has(sharedKey(accountId, b.id))).map((b) => b.id)); const wanted = new Set(res.list.filter((b) => b.isSubscribed || added.has(sharedKey(accountId, b.id))).map((b) => b.id));
if (!wanted.size) continue; if (!wanted.size) return;
// One page. A shared book is a colleague's contacts, not an archive, // One page. A shared book is a colleague's contacts, not an archive,
// and the alternative is holding the reader's own list hostage to it. // and the alternative is holding the reader's own list hostage to it.
const cardsRes = await client.chain([ const cardsRes = await client.chain([
@@ -325,9 +327,11 @@ export const useContacts = create<ContactsState>((set, get) => ({
} catch { } catch {
// An account that refuses is one that shared nothing here. Not an // An account that refuses is one that shared nothing here. Not an
// error to show: the reader did not ask for it and cannot act on it. // error to show: the reader did not ask for it and cannot act on it.
continue;
}
} }
}));
// Answers arrive in any order; list the books in the session's.
const order = new Map(accounts.map(([id], i) => [id, i]));
books.sort((a, b) => (order.get(a.accountId) ?? 0) - (order.get(b.accountId) ?? 0));
set({ sharedBooks: books, sharedCards: cards, sharedLoaded: true }); set({ sharedBooks: books, sharedCards: cards, sharedLoaded: true });
}, },
+30 -10
View File
@@ -50,7 +50,10 @@ interface FilesState {
*/ */
draggingIds: Id[]; draggingIds: Id[];
/** Whether Files is available and which account is the reader's. No round trip. */
init(): Promise<void>; init(): Promise<void>;
/** Ask each shared account whether it holds files; see the note on it. */
discoverShared(): Promise<void>;
/** Browse an account: the reader's own, or one shared with them. */ /** Browse an account: the reader's own, or one shared with them. */
openAccount(accountId: Id | null): void; openAccount(accountId: Id | null): void;
loadChildren(parentId: Id | null): Promise<void>; loadChildren(parentId: Id | null): Promise<void>;
@@ -138,6 +141,17 @@ export const useFiles = create<FilesState>((set, get) => ({
const session = useSession.getState(); const session = useSession.getState();
const ownAccountId = session.ownAccountFor(CAP.filenode); const ownAccountId = session.ownAccountFor(CAP.filenode);
const available = Boolean(ownAccountId && client.hasCapability(CAP.filenode)); const available = Boolean(ownAccountId && client.hasCapability(CAP.filenode));
// Stay where the reader is if the session still offers that account;
// whether it still holds files is `discoverShared`'s to say.
const browsing = get().accountId;
const offered = Object.entries(session.session?.accounts ?? {}).some(([id, a]) => id === browsing && a.isPersonal === false);
if (!(browsing && (browsing === ownAccountId || offered))) set(emptyForAccount(ownAccountId));
set({ available, ownAccountId });
},
async discoverShared() {
const session = useSession.getState();
const ownAccountId = get().ownAccountId;
/* /*
* Which accounts hold shared files cannot be worked out from capabilities: * Which accounts hold shared files cannot be worked out from capabilities:
* Stalwart advertises the whole set on a shared account -- mail, calendars, * Stalwart advertises the whole set on a shared account -- mail, calendars,
@@ -151,23 +165,29 @@ export const useFiles = create<FilesState>((set, get) => ({
* account whose calendar or contacts were the thing actually shared. An * account whose calendar or contacts were the thing actually shared. An
* account that shares no files does not belong in a list of shared files. * account that shares no files does not belong in a list of shared files.
*/ */
/*
* Not at sign-in: only the Files view and the file picker list shared
* accounts, and each opening asks afresh. The questions go out together --
* calls made in one tick share a request -- rather than one account after
* another.
*/
const s = session.session; const s = session.session;
const candidates = Object.entries(s?.accounts ?? {}).filter(([, a]) => a.isPersonal === false); const candidates = Object.entries(s?.accounts ?? {}).filter(([, a]) => a.isPersonal === false);
const sharedAccounts: SharedAccount[] = []; const answers = await Promise.all(
for (const [id, a] of candidates) { candidates.map(([id, a]) =>
try { client.call<QueryResponse>("FileNode/query", { accountId: id, limit: 1 }).then(
const res = await client.call<QueryResponse>("FileNode/query", { accountId: id, limit: 1 }); (res): SharedAccount | null => (res.ids.length ? { id, name: a.name } : null),
if (res.ids.length) sharedAccounts.push({ id, name: a.name });
} catch {
// Refused means nothing here is ours to see, which is the same answer. // Refused means nothing here is ours to see, which is the same answer.
continue; () => null,
} ),
} ),
);
const sharedAccounts = answers.filter((a): a is SharedAccount => a !== null);
// Stay where the reader is if they are reading a share that still exists. // Stay where the reader is if they are reading a share that still exists.
const browsing = get().accountId; const browsing = get().accountId;
const keep = browsing && (browsing === ownAccountId || sharedAccounts.some((a) => a.id === browsing)); const keep = browsing && (browsing === ownAccountId || sharedAccounts.some((a) => a.id === browsing));
if (!keep) set(emptyForAccount(ownAccountId)); if (!keep) set(emptyForAccount(ownAccountId));
set({ available, ownAccountId, sharedAccounts }); set({ sharedAccounts });
}, },
openAccount(accountId) { openAccount(accountId) {
+5
View File
@@ -27,6 +27,11 @@ export function FilePicker({ onPick, onClose }: { onPick: (files: AttachableFile
const [picked, setPicked] = useState<Record<string, FileNode>>({}); const [picked, setPicked] = useState<Record<string, FileNode>>({});
const [returnTo] = useState(() => files.accountId); const [returnTo] = useState(() => files.accountId);
// Shared accounts are not looked for at sign-in; the picker lists them, so it asks.
useEffect(() => {
void useFiles.getState().discoverShared();
}, []);
useEffect(() => { useEffect(() => {
void files.loadChildren(cur); void files.loadChildren(cur);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
+1
View File
@@ -32,6 +32,7 @@ async function refreshShares(force = false): Promise<void> {
return; return;
} }
await useFiles.getState().init(); await useFiles.getState().init();
await useFiles.getState().discoverShared();
} }