mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 16:13:15 +00:00
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:
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user