diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 31ffe05ad..2b8631906 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -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((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); } }; diff --git a/stores/__tests__/email-store-open-email.test.ts b/stores/__tests__/email-store-open-email.test.ts new file mode 100644 index 000000000..7a2fd61a3 --- /dev/null +++ b/stores/__tests__/email-store-open-email.test.ts @@ -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']); + }); +}); diff --git a/stores/email-store.ts b/stores/email-store.ts index 9d7fd91c0..26ef3c847 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -257,6 +257,17 @@ interface EmailStore { */ fetchAccountMailboxes: (client: IJMAPClient, accountId: string) => Promise; 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((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;