diff --git a/lib/folder-list-cache.ts b/lib/folder-list-cache.ts new file mode 100644 index 000000000..48312c648 --- /dev/null +++ b/lib/folder-list-cache.ts @@ -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(); + +/** + * 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(); +} diff --git a/stores/__tests__/email-store-folder-list-cache.test.ts b/stores/__tests__/email-store-folder-list-cache.test.ts new file mode 100644 index 000000000..dbf38bd1b --- /dev/null +++ b/stores/__tests__/email-store-folder-list-cache.test.ts @@ -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 => + ({ + 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 Promise>) { + const getEmails = vi.fn((mailboxId: string) => pages[mailboxId]()); + return { + client: { + getEmails, + getAccountId: () => 'acc', + getThreadEmails: vi.fn(async () => []), + } as unknown as IJMAPClient, + getEmails, + }; +} + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((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(); + 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(); + 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']); + }); +}); diff --git a/stores/email-store.ts b/stores/email-store.ts index 38e881cad..acae4e44a 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -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((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((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((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