mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
perf(mail): open a visited folder at once and load it behind its rows
Switching folders kept the previous folder's mail under a spinner until the new page arrived, and a failed load emptied the folder. The store now remembers the first page of each plain folder it showed (in memory, 30 folders, keyed by login, shared owner, folder, category filter and sort). Opening a remembered folder paints those rows straight away and fetches in the background; the fresh page replaces them, and a failure leaves them up with the error. What happens to the list on screen - a message read, archived, deleted - is remembered with it.
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