mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-30 16:16:19 +00:00
perf: reopen a visited folder without re-reading or redrawing its list
This commit is contained in:
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user