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: {} });
set({ available });
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();
try {
const res = await client.call<GetResponse<ParticipantIdentity>>("ParticipantIdentity/get", { accountId, ids: null });
set({ identities: res.list });
} catch {
set({ identities: [] });
}
await Promise.all([get().loadCalendars(), identities]);
},
/*
@@ -434,15 +433,16 @@ export const useCalendar = create<CalendarState>((set, get) => ({
const session = useSession.getState();
const own = session.ownAccountFor(CAP.calendars);
const accounts = Object.entries(session.session?.accounts ?? {}).filter(([id, a]) => a.isPersonal === false && id !== own);
const found: SharedCalendar[] = [];
for (const [accountId, account] of accounts) {
try {
const res = await client.call<GetResponse<Calendar>>("Calendar/get", { accountId, ids: null, properties: CALENDAR_PROPS });
for (const calendar of res.list) found.push({ accountId, accountName: account.name, calendar });
} catch {
continue;
}
}
// Every account at once, in one request, and listed in the session's order.
const answers = await Promise.all(
accounts.map(([accountId, account]) =>
client.call<GetResponse<Calendar>>("Calendar/get", { accountId, ids: null, properties: CALENDAR_PROPS }).then(
(res) => res.list.map((calendar): SharedCalendar => ({ accountId, accountName: account.name, calendar })),
(): SharedCalendar[] => [],
),
),
);
const found = answers.flat();
set({ sharedCalendars: found });
// Fill in whatever windows are already on screen.
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 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 {
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 });
@@ -310,7 +312,7 @@ export const useContacts = create<ContactsState>((set, get) => ({
*/
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));
if (!wanted.size) continue;
if (!wanted.size) return;
// 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.
const cardsRes = await client.chain([
@@ -325,9 +327,11 @@ export const useContacts = create<ContactsState>((set, get) => ({
} catch {
// 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.
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 });
},
+30 -10
View File
@@ -50,7 +50,10 @@ interface FilesState {
*/
draggingIds: Id[];
/** Whether Files is available and which account is the reader's. No round trip. */
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. */
openAccount(accountId: Id | null): void;
loadChildren(parentId: Id | null): Promise<void>;
@@ -138,6 +141,17 @@ export const useFiles = create<FilesState>((set, get) => ({
const session = useSession.getState();
const ownAccountId = session.ownAccountFor(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:
* 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 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 candidates = Object.entries(s?.accounts ?? {}).filter(([, a]) => a.isPersonal === false);
const sharedAccounts: SharedAccount[] = [];
for (const [id, a] of candidates) {
try {
const res = await client.call<QueryResponse>("FileNode/query", { accountId: id, limit: 1 });
if (res.ids.length) sharedAccounts.push({ id, name: a.name });
} catch {
const answers = await Promise.all(
candidates.map(([id, a]) =>
client.call<QueryResponse>("FileNode/query", { accountId: id, limit: 1 }).then(
(res): SharedAccount | null => (res.ids.length ? { id, name: a.name } : null),
// 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.
const browsing = get().accountId;
const keep = browsing && (browsing === ownAccountId || sharedAccounts.some((a) => a.id === browsing));
if (!keep) set(emptyForAccount(ownAccountId));
set({ available, ownAccountId, sharedAccounts });
set({ sharedAccounts });
},
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 [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(() => {
void files.loadChildren(cur);
// eslint-disable-next-line react-hooks/exhaustive-deps
+1
View File
@@ -32,6 +32,7 @@ async function refreshShares(force = false): Promise<void> {
return;
}
await useFiles.getState().init();
await useFiles.getState().discoverShared();
}