perf: show the message header from the list row while its body loads

This commit is contained in:
Linus Rath
2026-10-15 00:41:07 +02:00
parent db2c0aff3d
commit 9e5f78ab6d
3 changed files with 151 additions and 7 deletions
+32 -7
View File
@@ -255,6 +255,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// Whether the bootstrap fetch for the default mailbox has landed. Deep links
// wait for it so their folder switch isn't overwritten (#733).
const [initialMailLoadDone, setInitialMailLoadDone] = useState(false);
// Counts opens, so a slow fetch can tell it was overtaken (handleEmailSelect).
const emailOpenSeqRef = useRef(0);
const { isAuthenticated, client, logout, checkAuth, switchAccount, activeAccountId, isLoading: authLoading, connectionLost, isRateLimited, rateLimitUntil } = useAuthStore(useShallow((state) => ({
isAuthenticated: state.isAuthenticated,
client: state.client,
@@ -405,6 +407,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
isLoading,
isLoadingEmail,
setLoadingEmail,
beginOpenEmail,
finishOpenEmail,
abandonOpenEmail,
setPushConnected,
handleStateChange,
clearNewEmailNotification,
@@ -494,6 +499,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
isLoading: state.isLoading,
isLoadingEmail: state.isLoadingEmail,
setLoadingEmail: state.setLoadingEmail,
beginOpenEmail: state.beginOpenEmail,
finishOpenEmail: state.finishOpenEmail,
abandonOpenEmail: state.abandonOpenEmail,
setPushConnected: state.setPushConnected,
handleStateChange: state.handleStateChange,
clearNewEmailNotification: state.clearNewEmailNotification,
@@ -3878,9 +3886,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
setShowComposer(false);
}
// Find the list-level email for metadata (accountId, scheduled flags, etc.)
// but don't select it yet — wait for the full fetch to avoid a toolbar flash
// caused by rendering with the stub (no bodyValues) then re-rendering with the full email.
// Find the list-level email for metadata (accountId, scheduled flags, etc.).
//
// JMAP email ids are only unique per account (RFC 8621 §1.3) and Stalwart
// hands out overlapping id ranges, so in aggregate views one bare id can name
@@ -3895,7 +3901,21 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
&& (email.sourceAccountId === undefined || e.sourceAccountId === email.sourceAccountId))
?? (email.sourceClientAccountId ? email : undefined);
setLoadingEmail(true);
// Only the newest open may touch the viewer: a slow fetch for a message
// the user has already clicked away from must neither replace the one
// they are looking at nor end its loading state.
const openSeq = ++emailOpenSeqRef.current;
const previousEmail = useEmailStore.getState().selectedEmail;
// Paint the header from the row at once (subject, sender and date are in
// hand) and let the body follow; the viewer shows a row without body
// values as loading. Mountain View shows the message inside its
// conversation, which is fetched alongside, so it waits for both.
const rowEmail = !mvConversationView && listEmail && 'receivedAt' in listEmail ? listEmail as Email : null;
if (rowEmail) {
beginOpenEmail(rowEmail);
} else {
setLoadingEmail(true);
}
// On mobile, switch to viewer
if (isMobile) {
@@ -3912,6 +3932,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
const { client: fetchClient, accountId } = resolveEmailBlobContext(listEmail ?? null, client);
if (!fetchClient) {
console.warn('[mail-app] No connected client for email source');
if (openSeq === emailOpenSeqRef.current) abandonOpenEmail(previousEmail);
return;
}
@@ -3921,6 +3942,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
? loadMvConversation(listEmail as Email, client)
: null;
const fullEmail = await fetchClient.getEmail(email.id, accountId);
if (openSeq !== emailOpenSeqRef.current) return;
if (fullEmail) {
if (listEmail?.isScheduled) {
fullEmail.scheduledSendAt = listEmail.scheduledSendAt;
@@ -3949,14 +3971,17 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
new Promise<undefined>((resolve) => setTimeout(() => resolve(undefined), MV_CONVERSATION_WAIT_MS)),
]);
if (conversation !== undefined) setMvConversation(conversation);
if (openSeq !== emailOpenSeqRef.current) return;
}
selectEmail(fullEmail);
// One write: the message, and the end of its loading state.
finishOpenEmail(fullEmail);
// Mark-as-read logic is now handled by useEffect
} else {
abandonOpenEmail(previousEmail);
}
} catch (error) {
console.error('Failed to fetch email content:', error);
} finally {
setLoadingEmail(false);
if (openSeq === emailOpenSeqRef.current) abandonOpenEmail(previousEmail);
}
};
@@ -0,0 +1,75 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { useEmailStore } from '../email-store';
import { emailHooks } from '@/lib/plugin-hooks';
import type { Email } from '@/lib/jmap/types';
// Opening a message shows its list row at once (beginOpenEmail) and swaps in
// the fetched message when it lands (finishOpenEmail). Plugins hear the open
// once, with the full message, and the close of whatever was open before.
const row = (id: string): Email => ({
id, threadId: `t-${id}`, mailboxIds: { inbox: true }, keywords: {}, subject: `Subject ${id}`,
receivedAt: '2026-10-01T00:00:00Z', from: [], to: [], preview: 'preview', size: 1, hasAttachment: false,
} as Email);
const full = (id: string): Email => ({
...row(id),
textBody: [{ partId: '1', type: 'text/plain' }],
bodyValues: { 1: { value: 'body text' } },
} as unknown as Email);
describe('opening a message from the list', () => {
const opened: string[] = [];
const closed: string[] = [];
let offOpen: () => void;
let offClose: () => void;
beforeEach(() => {
opened.length = 0;
closed.length = 0;
const openReg = emailHooks.onEmailOpen.register('test', ((view: { id: string; text?: string }) => { opened.push(`${view.id}:${view.text ?? ''}`); }) as never);
const closeReg = emailHooks.onEmailClose.register('test', ((e: { emailId: string }) => { closed.push(e.emailId); }) as never);
offOpen = () => openReg.dispose();
offClose = () => closeReg.dispose();
useEmailStore.setState({ selectedEmail: full('a'), isLoadingEmail: false });
});
afterEach(() => { offOpen(); offClose(); });
it('shows the row and its loading state in one write, then the fetched message', () => {
// Every state the subscribers see: never the row without its loading
// state (that would be a render with an empty body), never two writes.
const seen: string[] = [];
const unsubscribe = useEmailStore.subscribe((state, prev) => {
if (state.selectedEmail !== prev.selectedEmail || state.isLoadingEmail !== prev.isLoadingEmail) {
seen.push(`${state.selectedEmail?.id}:${state.isLoadingEmail}`);
}
});
useEmailStore.getState().beginOpenEmail(row('b'));
unsubscribe();
expect(seen).toEqual(['b:true']);
expect(useEmailStore.getState()).toMatchObject({ selectedEmail: { id: 'b' }, isLoadingEmail: true });
expect(closed).toEqual(['a']);
// No open for the row: it has no body, headers or attachments yet.
expect(opened).toEqual([]);
useEmailStore.getState().finishOpenEmail(full('b'));
expect(useEmailStore.getState()).toMatchObject({ selectedEmail: { id: 'b', bodyValues: { 1: { value: 'body text' } } }, isLoadingEmail: false });
expect(opened).toEqual(['b:body text']);
expect(closed).toEqual(['a']);
});
it('reopens what was open before when the fetch fails', () => {
const previous = useEmailStore.getState().selectedEmail;
useEmailStore.getState().beginOpenEmail(row('b'));
useEmailStore.getState().abandonOpenEmail(previous);
expect(useEmailStore.getState()).toMatchObject({ selectedEmail: previous, isLoadingEmail: false });
expect(opened).toEqual(['a:body text']);
});
it('announces close and open when the message comes without a row shown first', () => {
useEmailStore.getState().finishOpenEmail(full('c'));
expect(closed).toEqual(['a']);
expect(opened).toEqual(['c:body text']);
});
});
+44
View File
@@ -257,6 +257,17 @@ interface EmailStore {
*/
fetchAccountMailboxes: (client: IJMAPClient, accountId: string) => Promise<void>;
selectEmail: (email: Email | null) => void;
/**
* Shows a list row in the viewer while its body is on its way: selects it
* and marks the body loading in one store write, so the click renders once.
* Plugins hear onEmailOpen only from finishOpenEmail, with the full message:
* a row has no body, headers or attachments to show them.
*/
beginOpenEmail: (row: Email) => void;
/** The fetched message replaces the row beginOpenEmail showed (one write). */
finishOpenEmail: (email: Email) => void;
/** The fetch failed: back to what was open before, without the loading state. */
abandonOpenEmail: (previous: Email | null) => void;
/** Holds a list row in place (see listHold); `rowKey` as from listRowKey. */
holdListRow: (rowKey: string) => void;
selectMailbox: (mailboxId: string) => void;
@@ -977,6 +988,9 @@ export function resolveEmailBlobContext(
*/
let shownFolderList: { key: string; mailboxId: string; pageSize: number } | null = null;
/** The list row beginOpenEmail put in the viewer, until its message arrives. */
let openingRow: Email | null = null;
/**
* The per-thread message counts to store once a page arrived: the counts that
* page brought along (Thread/get chained onto its query) under the keys the
@@ -2184,6 +2198,36 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
emailHooks.onEmailOpen.emitSync(emailToReadView(email));
}
},
beginOpenEmail: (row) => {
const prev = get().selectedEmail;
openingRow = row;
set({ selectedEmail: row, lastSelectedEmailKey: emailKeyFor(row), isLoadingEmail: true });
if (prev && row.id !== prev.id && emailHooks.onEmailClose.size > 0) {
emailHooks.onEmailClose.emitSync({ emailId: prev.id });
}
},
finishOpenEmail: (email) => {
const prev = get().selectedEmail;
const announced = prev !== null && prev !== openingRow;
openingRow = null;
set({ selectedEmail: email, lastSelectedEmailKey: emailKeyFor(email), isLoadingEmail: false });
if (announced && prev.id !== email.id && emailHooks.onEmailClose.size > 0) {
emailHooks.onEmailClose.emitSync({ emailId: prev.id });
}
if ((!announced || prev.id !== email.id) && emailHooks.onEmailOpen.size > 0) {
emailHooks.onEmailOpen.emitSync(emailToReadView(email));
}
},
abandonOpenEmail: (previous) => {
const showing = get().selectedEmail;
const wasRow = showing !== null && showing === openingRow;
openingRow = null;
set({ selectedEmail: previous, isLoadingEmail: false });
// beginOpenEmail announced the close of `previous`; it is open again.
if (wasRow && previous && showing.id !== previous.id && emailHooks.onEmailOpen.size > 0) {
emailHooks.onEmailOpen.emitSync(emailToReadView(previous));
}
},
holdListRow: (rowKey) => {
const { listHold, emails, isScheduledView } = get();
if (listHold?.rowKey === rowKey) return;