Merge pull request #1195 from lucamzanon/perf/folder-list-cache

perf(mail): open a visited folder at once and load it behind its rows
This commit is contained in:
Linus Rath
2026-10-10 19:23:06 +02:00
3 changed files with 310 additions and 1 deletions
+68
View File
@@ -0,0 +1,68 @@
import type { Email } from '@/lib/jmap/types';
/**
* The last list seen in each folder, so opening a folder paints the rows it
* had a moment ago while the fresh page loads behind them - and keeps them if
* that load fails. On a slow server or a large folder the difference is an
* instant folder against seconds of the previous folder's mail under a
* spinner.
*
* In memory only: mail does not outlive the tab here, unlike the one-list boot
* snapshot in the email store, and the live fetch always runs.
*/
export interface FolderList {
emails: Email[];
totalEmails: number;
hasMoreEmails: boolean;
}
/** Folders remembered per tab; the oldest visited is dropped first. */
const MAX_FOLDERS = 30;
const lists = new Map<string, FolderList>();
/**
* What a remembered list belongs to. Folder ids repeat across accounts and
* servers, and a category tab or another sort
* order is another list of the same folder.
*/
export function folderListKey(parts: {
login: string | null;
viewingAccountId: string | null;
accountId: string;
mailboxId: string;
filter: unknown;
order: unknown;
}): string {
return JSON.stringify([
parts.login ?? '',
parts.viewingAccountId ?? '',
parts.accountId,
parts.mailboxId,
parts.filter ?? null,
parts.order ?? null,
]);
}
export function rememberedFolderList(key: string): FolderList | undefined {
return lists.get(key);
}
export function rememberFolderList(key: string, list: FolderList, pageSize: number): void {
lists.delete(key);
lists.set(key, {
// One page is what a folder opens on; load-more fetches the rest again.
emails: list.emails.slice(0, pageSize),
totalEmails: list.totalEmails,
hasMoreEmails: list.hasMoreEmails || list.emails.length > pageSize,
});
while (lists.size > MAX_FOLDERS) {
const oldest = lists.keys().next().value;
if (oldest === undefined) break;
lists.delete(oldest);
}
}
export function forgetFolderLists(): void {
lists.clear();
}
@@ -0,0 +1,175 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useEmailStore } from '../email-store';
import { useSettingsStore } from '../settings-store';
import { forgetFolderLists } from '@/lib/folder-list-cache';
import type { Email, Mailbox } from '@/lib/jmap/types';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
/**
* Opening a folder paints the rows it showed last time and loads the fresh
* page behind them, so a slow server does
* not hold the previous folder's mail under a spinner - and a failed load
* does not empty a folder that had mail a moment ago.
*/
const makeEmail = (id: string, extra: Partial<Email> = {}): Email =>
({
id,
threadId: `t-${id}`,
mailboxIds: { inbox: true },
keywords: {},
from: [{ email: 'a@example.com' }],
to: [{ email: 'b@example.com' }],
subject: `mail ${id}`,
receivedAt: '2026-10-08T10:00:00Z',
preview: '',
hasAttachment: false,
size: 1,
...extra,
}) as unknown as Email;
const folder = (id: string, role: string | null) =>
({
id,
originalId: id,
name: id,
role,
accountId: 'acc',
totalEmails: 2,
unreadEmails: 0,
totalThreads: 2,
unreadThreads: 0,
sortOrder: 0,
isSubscribed: true,
myRights: {},
}) as unknown as Mailbox;
type Page = { emails: Email[]; hasMore: boolean; total: number; state?: string };
function makeClient(pages: Record<string, () => Promise<Page>>) {
const getEmails = vi.fn((mailboxId: string) => pages[mailboxId]());
return {
client: {
getEmails,
getAccountId: () => 'acc',
getThreadEmails: vi.fn(async () => []),
} as unknown as IJMAPClient,
getEmails,
};
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
const ids = () => useEmailStore.getState().emails.map((e) => e.id);
async function open(client: IJMAPClient, mailboxId: string) {
useEmailStore.getState().selectMailbox(mailboxId);
return useEmailStore.getState().fetchEmails(client, mailboxId);
}
describe('remembered folder lists', () => {
beforeEach(() => {
forgetFolderLists();
useSettingsStore.setState({ emailsPerPage: 50 });
useEmailStore.setState({
selectedMailbox: 'inbox',
mailboxes: [folder('inbox', 'inbox'), folder('work', null)],
accountMailboxes: {},
viewingAccountId: null,
emails: [],
totalEmails: 0,
hasMoreEmails: false,
emailListSync: null,
isUnifiedView: false,
isScheduledView: false,
selectedKeyword: null,
searchQuery: '',
isLoading: false,
error: null,
});
});
it('paints a visited folder at once and replaces it when the fresh page lands', async () => {
const fresh = deferred<Page>();
let inboxCalls = 0;
const { client } = makeClient({
inbox: () =>
++inboxCalls === 1
? Promise.resolve({ emails: [makeEmail('a'), makeEmail('b')], hasMore: false, total: 2, state: 's1' })
: fresh.promise,
work: async () => ({ emails: [makeEmail('w')], hasMore: false, total: 1, state: 's1' }),
});
await open(client, 'inbox');
await open(client, 'work');
expect(ids()).toEqual(['w']);
const loading = open(client, 'inbox');
expect(ids()).toEqual(['a', 'b']);
expect(useEmailStore.getState().isLoading).toBe(false);
fresh.resolve({ emails: [makeEmail('new'), makeEmail('a'), makeEmail('b')], hasMore: false, total: 3, state: 's2' });
await loading;
expect(ids()).toEqual(['new', 'a', 'b']);
expect(useEmailStore.getState().emailListSync?.state).toBe('s2');
});
it('keeps the remembered rows when the refresh fails', async () => {
let inboxCalls = 0;
const { client } = makeClient({
inbox: async () => {
if (++inboxCalls > 1) throw new Error('rate limited; retry later');
return { emails: [makeEmail('a')], hasMore: false, total: 1, state: 's1' };
},
work: async () => ({ emails: [makeEmail('w')], hasMore: false, total: 1, state: 's1' }),
});
vi.spyOn(console, 'error').mockImplementation(() => {});
await open(client, 'inbox');
await open(client, 'work');
await open(client, 'inbox');
expect(ids()).toEqual(['a']);
expect(useEmailStore.getState().error).toBe('rate limited; retry later');
});
it('remembers what happened to the list on screen, not the list as it was loaded', async () => {
const { client } = makeClient({
inbox: async () => ({ emails: [makeEmail('a'), makeEmail('b')], hasMore: false, total: 2, state: 's1' }),
work: async () => ({ emails: [makeEmail('w')], hasMore: false, total: 1, state: 's1' }),
});
await open(client, 'inbox');
useEmailStore.setState({ emails: [makeEmail('a', { keywords: { $seen: true } })], totalEmails: 1 });
await open(client, 'work');
useEmailStore.getState().selectMailbox('inbox');
void useEmailStore.getState().fetchEmails(client, 'inbox');
expect(ids()).toEqual(['a']);
expect(useEmailStore.getState().emails[0].keywords).toEqual({ $seen: true });
});
it('shows the spinner for a folder never opened in this tab', async () => {
const fresh = deferred<Page>();
const { client } = makeClient({
inbox: async () => ({ emails: [makeEmail('a')], hasMore: false, total: 1, state: 's1' }),
work: () => fresh.promise,
});
await open(client, 'inbox');
const loading = open(client, 'work');
expect(useEmailStore.getState().isLoading).toBe(true);
// The inbox rows stay up during the transition but are not this folder's.
fresh.resolve({ emails: [makeEmail('w')], hasMore: false, total: 1, state: 's1' });
await loading;
expect(ids()).toEqual(['w']);
});
});
+67 -1
View File
@@ -15,6 +15,7 @@ import { defaultSearchScopeFor, isAllFoldersSearchScope, SEARCH_SCOPE_ALL_FOLDER
import { useAuthStore } from "@/stores/auth-store";
import { pathNamesMailFolder } from "@/lib/deep-links";
import { currentStoreEpoch } from "@/lib/store-epoch";
import { folderListKey, forgetFolderLists, rememberFolderList, rememberedFolderList } from "@/lib/folder-list-cache";
import { keywordPointer } from "@/lib/jmap/patch-pointer";
import { useAccountStore } from "@/stores/account-store";
import { useMessageListTabsStore } from "@/stores/message-list-tabs-store";
@@ -826,6 +827,13 @@ export function resolveEmailBlobContext(
// List requests can finish after navigation. Never apply an old folder/tag
// response (or error) to the view the user has since selected.
/**
* The plain folder list on screen and the key it is remembered under (see
* lib/folder-list-cache). Null while the rows showing belong to no folder
* list - a search, a tag, a unified view, or a folder still loading.
*/
let shownFolderList: { key: string; mailboxId: string; pageSize: number } | null = null;
function captureEmailListView(): () => boolean {
const view = useEmailStore.getState();
const activeAccountId = useAuthStore.getState().activeAccountId;
@@ -2037,7 +2045,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// A background refresh (e.g. after an account switch restored a cached list)
// repopulates the list without showing the loading overlay, so switching to
// an already-visited account doesn't flash a spinner over the visible mail.
const background = opts?.background ?? false;
let background = opts?.background ?? false;
// Loading a real mailbox is a fresh navigation (leaving any cross view), so
// drop the unread/starred retain set and the held row.
set(background ? { error: null, retainedInViewIds: new Set(), listHold: null } : { isLoading: true, error: null, retainedInViewIds: new Set(), listHold: null }); // Keep previous emails visible during transition
@@ -2154,6 +2162,38 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// The configured list order (#718).
const order = getMessageListOrderFor(mailbox?.role);
// Paint what this folder showed last time and load behind it: the rows
// are there at once, the fresh page replaces them when it lands, and a
// failed load leaves them in place instead of emptying the folder.
const listKey = folderListKey({
login: useAuthStore.getState().activeAccountId,
viewingAccountId: get().viewingAccountId,
// The login tells own accounts apart; a shared folder adds its owner.
accountId: accountId ?? '',
mailboxId: targetMailboxId,
filter: categoryFilter,
order,
});
const remembered = background ? undefined : rememberedFolderList(listKey);
if (remembered) {
background = true;
set({
emails: annotateScheduledEmails(remembered.emails, get().scheduledSubmissionByEmailId),
hasMoreEmails: remembered.hasMoreEmails,
totalEmails: remembered.totalEmails,
listOrder: order,
// The remembered page has no state a push delta could start from.
emailListSync: null,
threadEmailsCache: new Map(),
expandedThreadIds: new Set(),
isLoadingThread: null,
isLoading: false,
});
}
// Until this folder's rows are on screen, edits to the list belong to
// whatever was showing before and must not be remembered under this key.
shownFolderList = remembered ? { key: listKey, mailboxId: targetMailboxId, pageSize: emailsPerPage } : null;
const result = await effectiveClient.getEmails(
jmapMailboxId,
accountId,
@@ -2189,6 +2229,8 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
isLoadingThread: null,
isLoading: false
});
shownFolderList = { key: listKey, mailboxId: targetMailboxId, pageSize: emailsPerPage };
rememberFolderList(listKey, get(), emailsPerPage);
// Fetch full thread counts in the background (non-blocking)
void get().fetchThreadEmailCounts(client);
} catch (error) {
@@ -5980,6 +6022,30 @@ useEmailStore.subscribe((state, prev) => {
state.holdListRow(listRowKey(email, state.isScheduledView));
});
// A folder's remembered list follows what happens to it on screen - a message
// read, archived or deleted here is shown that way when the folder is opened
// again, rather than as it was when the folder was loaded.
useEmailStore.subscribe((state, prev) => {
if (state.mailboxes.length === 0 && state.emails.length === 0) {
shownFolderList = null;
forgetFolderLists();
return;
}
if (state.emails === prev.emails || !shownFolderList) return;
if (
state.selectedMailbox !== shownFolderList.mailboxId ||
state.searchQuery ||
!isFilterEmpty(state.searchFilters) ||
state.selectedKeyword ||
state.isUnifiedView ||
state.isScheduledView
) {
shownFolderList = null;
return;
}
rememberFolderList(shownFolderList.key, state, shownFolderList.pageSize);
});
// ---------------------------------------------------------------------------
// Boot snapshot: persist the mailbox list and the first page of the current
// list so a returning visit paints rows the moment auth resolves, instead of