mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 16:15:39 +00:00
perf: show the message header from the list row while its body loads
This commit is contained in:
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user