From a0563c1feddb4e4048ebbcd3a2f61999d216fa1e Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 15 Oct 2026 01:03:02 +0200 Subject: [PATCH] perf: reopen a visited folder without re-reading or redrawing its list --- lib/folder-list-cache.ts | 10 +++ .../email-store-folder-list-cache.test.ts | 82 ++++++++++++++++++- stores/email-store.ts | 52 ++++++++++-- 3 files changed, 135 insertions(+), 9 deletions(-) diff --git a/lib/folder-list-cache.ts b/lib/folder-list-cache.ts index 48312c648..2e8e3fe55 100644 --- a/lib/folder-list-cache.ts +++ b/lib/folder-list-cache.ts @@ -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; } /** 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; diff --git a/stores/__tests__/email-store-folder-list-cache.test.ts b/stores/__tests__/email-store-folder-list-cache.test.ts index dbf38bd1b..b6fd6d3f2 100644 --- a/stores/__tests__/email-store-folder-list-cache.test.ts +++ b/stores/__tests__/email-store-folder-list-cache.test.ts @@ -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 }; function makeClient(pages: Record Promise>) { 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).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); + }); +}); diff --git a/stores/email-store.ts b/stores/email-store.ts index 26ef3c847..08ac299f2 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -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 { +async function applyEmailDeltaNow(client: IJMAPClient, newState: string | null, get: StoreGet, set: StoreSet): Promise { 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((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((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((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); }); // ---------------------------------------------------------------------------