perf: reopen a visited folder without re-reading or redrawing its list

This commit is contained in:
Linus Rath
2026-10-15 01:03:02 +02:00
parent c161676089
commit a0563c1fed
3 changed files with 135 additions and 9 deletions
+10
View File
@@ -14,6 +14,14 @@ export interface FolderList {
emails: Email[];
totalEmails: number;
hasMoreEmails: boolean;
/**
* The Email state the rows were current at, for a plain folder list. A
* revisit then asks the server only what changed since (Email/changes)
* instead of re-reading the page, and keeps its rows when nothing did.
*/
sync?: { state: string; accountId: string; mailboxId: string } | null;
/** Conversation sizes of the rows, which a folder switch clears. */
threadEmailCounts?: Map<string, number>;
}
/** Folders remembered per tab; the oldest visited is dropped first. */
@@ -55,6 +63,8 @@ export function rememberFolderList(key: string, list: FolderList, pageSize: numb
emails: list.emails.slice(0, pageSize),
totalEmails: list.totalEmails,
hasMoreEmails: list.hasMoreEmails || list.emails.length > pageSize,
sync: list.sync ?? null,
threadEmailCounts: list.threadEmailCounts,
});
while (lists.size > MAX_FOLDERS) {
const oldest = lists.keys().next().value;
@@ -44,7 +44,7 @@ const folder = (id: string, role: string | null) =>
myRights: {},
}) as unknown as Mailbox;
type Page = { emails: Email[]; hasMore: boolean; total: number; state?: string };
type Page = { emails: Email[]; hasMore: boolean; total: number; state?: string; threadCounts?: Record<string, number> };
function makeClient(pages: Record<string, () => Promise<Page>>) {
const getEmails = vi.fn((mailboxId: string) => pages[mailboxId]());
@@ -173,3 +173,83 @@ describe('remembered folder lists', () => {
expect(ids()).toEqual(['w']);
});
});
describe('remembered folder lists with Email/changes', () => {
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,
});
});
function clientWithChanges(changes: { created?: string[]; updated?: string[]; destroyed?: string[] }) {
const { client, getEmails } = makeClient({
inbox: async () => ({ emails: [makeEmail('a'), makeEmail('b')], hasMore: false, total: 2, state: 's1', threadCounts: { 't-a': 3, 't-b': 1 } } as Page),
work: async () => ({ emails: [makeEmail('w')], hasMore: false, total: 1, state: 's1' }),
});
const getEmailChanges = vi.fn(async () => ({
oldState: 's1', newState: 's2', hasMoreChanges: false,
created: changes.created ?? [], updated: changes.updated ?? [], destroyed: changes.destroyed ?? [],
}));
const getSomeEmails = vi.fn(async (wanted: string[]) => wanted.map((id) => makeEmail(id, { keywords: { $seen: true } })));
Object.assign(client, { getEmailChanges, getSomeEmails });
return { client, getEmails, getEmailChanges };
}
it('asks only what changed on a revisit and keeps the rows when nothing did', async () => {
const { client, getEmails, getEmailChanges } = clientWithChanges({});
await open(client, 'inbox');
await open(client, 'work');
getEmails.mockClear();
const loading = open(client, 'inbox');
const painted = useEmailStore.getState().emails;
await loading;
expect(getEmailChanges).toHaveBeenCalledWith('s1', undefined, expect.any(Number));
expect(getEmails).not.toHaveBeenCalled();
// The very same list: nothing for the rows to render again.
expect(useEmailStore.getState().emails).toBe(painted);
expect(useEmailStore.getState().emailListSync?.state).toBe('s2');
// The conversation sizes come back with the page; a switch cleared them.
expect(useEmailStore.getState().threadEmailCounts.get('t-a')).toBe(3);
});
it('patches a row that changed elsewhere without re-reading the page', async () => {
const { client, getEmails } = clientWithChanges({ updated: ['a'] });
await open(client, 'inbox');
await open(client, 'work');
getEmails.mockClear();
await open(client, 'inbox');
expect(getEmails).not.toHaveBeenCalled();
expect(ids()).toEqual(['a', 'b']);
expect(useEmailStore.getState().emails[0].keywords).toEqual({ $seen: true });
});
it('re-reads the page when mail arrived in the folder', async () => {
const { client, getEmails } = clientWithChanges({ created: ['new'] });
(client.getSomeEmails as ReturnType<typeof vi.fn>).mockImplementation(async (wanted: string[]) => wanted.map((id) => makeEmail(id)));
await open(client, 'inbox');
await open(client, 'work');
getEmails.mockClear();
await open(client, 'inbox');
expect(getEmails).toHaveBeenCalledTimes(1);
});
});
+44 -8
View File
@@ -19,7 +19,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 { folderListKey, forgetFolderLists, rememberFolderList, rememberedFolderList, type FolderList } 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";
@@ -664,7 +664,7 @@ function refillAfterKeywordChange(get: StoreGet, client: IJMAPClient, changed: I
void s.refreshCurrentMailbox(client);
}
async function applyEmailDeltaNow(client: IJMAPClient, newState: string, get: StoreGet, set: StoreSet): Promise<boolean> {
async function applyEmailDeltaNow(client: IJMAPClient, newState: string | null, get: StoreGet, set: StoreSet): Promise<boolean> {
const s = get();
const sync = s.emailListSync;
if (!sync) return false;
@@ -688,8 +688,9 @@ async function applyEmailDeltaNow(client: IJMAPClient, newState: string, get: St
const effectiveClient = resolveActionClient(client);
if (!effectiveClient.getEmailChanges) return false;
// The push carries the state we already hold (our own Email/set raised it,
// or a duplicate notification): nothing to do, and no request either.
if (sync.state === newState) return true;
// or a duplicate notification): nothing to do, and no request either. A
// null newState (a folder repainted from memory) only the server can judge.
if (newState !== null && sync.state === newState) return true;
const accountArg = mailbox?.isShared ? mailbox.accountId : undefined;
const delta = await effectiveClient.getEmailChanges(sync.state, accountArg, EMAIL_DELTA_MAX_CHANGES);
@@ -736,6 +737,15 @@ async function applyEmailDeltaNow(client: IJMAPClient, newState: string, get: St
annotateScheduledEmails(replacements, get().scheduledSubmissionByEmailId).map((e) => [e.id, e]),
);
// Nothing on the page changed: advance the baseline and leave the list be,
// rather than hand it a new array that renders the same rows again.
if (removed.size === 0 && replacementById.size === 0) {
set((state) => ({
emailListSync: state.emailListSync?.mailboxId === sync.mailboxId ? { ...sync, state: delta.newState } : state.emailListSync,
}));
return true;
}
// Re-read the list: the requests above may have overlapped a user action.
const current = get().emails;
const next = current
@@ -988,6 +998,17 @@ export function resolveEmailBlobContext(
*/
let shownFolderList: { key: string; mailboxId: string; pageSize: number } | null = null;
/** What a folder's list is remembered as (see lib/folder-list-cache). */
function folderListState(s: EmailStore): FolderList {
return {
emails: s.emails,
totalEmails: s.totalEmails,
hasMoreEmails: s.hasMoreEmails,
sync: s.emailListSync,
threadEmailCounts: s.threadEmailCounts,
};
}
/** The list row beginOpenEmail put in the viewer, until its message arrives. */
let openingRow: Email | null = null;
@@ -2610,8 +2631,10 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
hasMoreEmails: remembered.hasMoreEmails,
totalEmails: remembered.totalEmails,
listOrder: order,
// The remembered page has no state a push delta could start from.
emailListSync: null,
// The state the page was current at, if it was a plain folder list:
// a push delta, and the catch-up below, start from it.
emailListSync: remembered.sync?.mailboxId === targetMailboxId ? remembered.sync : null,
threadEmailCounts: remembered.threadEmailCounts ?? new Map(),
threadEmailsCache: new Map(),
expandedThreadIds: new Set(),
isLoadingThread: null,
@@ -2622,6 +2645,19 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// whatever was showing before and must not be remembered under this key.
shownFolderList = remembered ? { key: listKey, mailboxId: targetMailboxId, pageSize: emailsPerPage } : null;
// A remembered page with a baseline only needs what changed since
// (Email/changes): usually nothing, and then the rows stay as they are
// instead of being re-read and rendered again. Anything the delta
// cannot settle falls through to the full page below.
if (remembered && get().emailListSync && !categoryFilter) {
const upToDate = await applyEmailDeltaNow(client, null, get, set);
if (!isCurrentView()) return;
if (upToDate) {
rememberFolderList(listKey, folderListState(get()), emailsPerPage);
return;
}
}
const result = await effectiveClient.getEmails(
jmapMailboxId,
accountId,
@@ -2660,7 +2696,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
isLoading: false
});
shownFolderList = { key: listKey, mailboxId: targetMailboxId, pageSize: emailsPerPage };
rememberFolderList(listKey, get(), emailsPerPage);
rememberFolderList(listKey, folderListState(get()), emailsPerPage);
// Only a server that did not answer the chained Thread/get needs a
// request of its own for the conversation sizes.
if (!('threadEmailCounts' in threadCounts)) void get().fetchThreadEmailCounts(client);
@@ -6626,7 +6662,7 @@ useEmailStore.subscribe((state, prev) => {
shownFolderList = null;
return;
}
rememberFolderList(shownFolderList.key, state, shownFolderList.pageSize);
rememberFolderList(shownFolderList.key, folderListState(state), shownFolderList.pageSize);
});
// ---------------------------------------------------------------------------