From b3dc2e32b8020c3ada9a4180667cc6eade053278 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 11 May 2026 15:17:30 +0200 Subject: [PATCH] feat: implement prefetching of initial email data --- app/[locale]/page.tsx | 58 +++++++++++++++++++------------------------ stores/auth-store.ts | 21 ++++++++++++++++ stores/email-store.ts | 31 +++++++++++++++++++++++ 3 files changed, 78 insertions(+), 32 deletions(-) diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index aeee0eac..504e194f 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -650,7 +650,10 @@ export default function Home() { } }, [initialCheckDone, isAuthenticated, authLoading]); - // Load mailboxes and emails when authenticated (only if not already loaded) + // Fallback fetch for paths that didn't go through login()'s prefetch + // (notably checkAuth on page refresh). The prefetch in auth-store/login() + // populates mailboxes before this effect first runs, so on the post-login + // path this block is a no-op. useEffect(() => { if (isAuthenticated && client && mailboxes.length === 0) { let retryTimer: ReturnType | null = null; @@ -658,18 +661,14 @@ export default function Home() { const loadData = async (attempt = 1) => { try { - // First fetch mailboxes and quota (inbox will be auto-selected in fetchMailboxes) await Promise.all([ fetchMailboxes(client), fetchQuota(client) ]); - // Get the selected mailbox (should be inbox by default) const state = useEmailStore.getState(); const selectedMailboxId = state.selectedMailbox; - // On first login the server may still be provisioning mailboxes. - // Retry a few times with back-off before giving up. if (state.mailboxes.length === 0 && attempt <= 5 && !cancelled) { const delay = Math.min(1000 * attempt, 5000); debug.log('jmap', `[Mailbox] No mailboxes returned (attempt ${attempt}), retrying in ${delay}ms`); @@ -677,34 +676,13 @@ export default function Home() { return; } - // Fetch emails for the selected mailbox if (selectedMailboxId) { await fetchEmails(client, selectedMailboxId); } else { await fetchEmails(client); } - // Fetch tag counts fetchTagCounts(client); - - // Setup push notifications after successful data load - try { - // Register state change callback - client.onStateChange((change) => handleStateChange(change, client)); - - // Start receiving push notifications - const pushEnabled = client.setupPushNotifications(); - - if (pushEnabled) { - setPushConnected(true); - debug.log('push', '[Push] Push notifications successfully enabled'); - } else { - debug.log('push', '[Push] Push notifications not available on this server'); - } - } catch (error) { - // Push notifications are optional - don't break the app if they fail - debug.log('push', '[Push] Failed to setup push notifications:', error); - } } catch (error) { console.error('Error loading email data:', error); } @@ -714,17 +692,33 @@ export default function Home() { return () => { cancelled = true; if (retryTimer) clearTimeout(retryTimer); - client.closePushNotifications(); }; } + }, [isAuthenticated, client, mailboxes.length, fetchMailboxes, fetchEmails, fetchQuota, fetchTagCounts]); - // Cleanup push notifications on unmount - return () => { - if (client) { - client.closePushNotifications(); + // Push notifications: set up once per client and tear down when the client + // goes away (logout or account switch). Kept separate from the fetch effect + // above so it still runs when data was prefetched at login time. + useEffect(() => { + if (!isAuthenticated || !client) return; + + try { + client.onStateChange((change) => handleStateChange(change, client)); + const pushEnabled = client.setupPushNotifications(); + if (pushEnabled) { + setPushConnected(true); + debug.log('push', '[Push] Push notifications successfully enabled'); + } else { + debug.log('push', '[Push] Push notifications not available on this server'); } + } catch (error) { + debug.log('push', '[Push] Failed to setup push notifications:', error); + } + + return () => { + client.closePushNotifications(); }; - }, [isAuthenticated, client, mailboxes.length, fetchMailboxes, fetchEmails, fetchQuota, fetchTagCounts, handleStateChange, setPushConnected]); + }, [isAuthenticated, client, handleStateChange, setPushConnected]); // Keep unified mailbox counts in sync when the feature is enabled and more // than one account is connected. Runs whenever the set of connected accounts diff --git a/stores/auth-store.ts b/stores/auth-store.ts index f6490b03..3fa8fa93 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -512,6 +512,15 @@ export const useAuthStore = create()( activeAccountId: accountId, }); + // Kick off mailbox/quota/email fetches now so they overlap with the + // soft-nav + home-page hydration that follows login. Dynamic import + // avoids a static circular dep with email-store. + import('@/stores/email-store').then(({ useEmailStore }) => { + useEmailStore.getState().prefetchInitialData(client).catch((err) => { + debug.error('Initial data prefetch failed:', err); + }); + }).catch(() => {}); + // Schedule token refresh for TOTP-upgraded sessions if (upgradedToOAuth && oauthExpiresIn > 0) { scheduleRefresh(oauthExpiresIn, get().refreshAccessToken, accountId); @@ -709,6 +718,12 @@ export const useAuthStore = create()( activeAccountId: accountId, }); + import('@/stores/email-store').then(({ useEmailStore }) => { + useEmailStore.getState().prefetchInitialData(client).catch((err) => { + debug.error('Initial data prefetch failed:', err); + }); + }).catch(() => {}); + scheduleRefresh(expires_in, get().refreshAccessToken, accountId); notifyParent('sso:auth-success', { username }); @@ -841,6 +856,12 @@ export const useAuthStore = create()( activeAccountId: accountId, }); + import('@/stores/email-store').then(({ useEmailStore }) => { + useEmailStore.getState().prefetchInitialData(client).catch((err) => { + debug.error('Initial data prefetch failed:', err); + }); + }).catch(() => {}); + scheduleRefresh(expires_in, get().refreshAccessToken, accountId); notifyParent('sso:auth-success', { username }); diff --git a/stores/email-store.ts b/stores/email-store.ts index f8d9d699..ea9d7d0f 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -72,6 +72,10 @@ interface EmailStore { // JMAP operations fetchMailboxes: (client: IJMAPClient) => Promise; fetchEmails: (client: IJMAPClient, mailboxId?: string) => Promise; + // Eager post-login bootstrap: fires mailboxes/quota/emails so the round-trips + // overlap with Next's soft-nav + home-page hydration. Safe to call multiple + // times; later calls are no-ops while a prior one is in flight. + prefetchInitialData: (client: IJMAPClient) => Promise; loadMoreEmails: (client: IJMAPClient) => Promise; fetchEmailContent: (client: IJMAPClient, emailId: string) => Promise; fetchQuota: (client: IJMAPClient) => Promise; @@ -350,6 +354,33 @@ export const useEmailStore = create((set, get) => ({ } }, + prefetchInitialData: async (client) => { + // Coalesce overlapping callers (e.g. login() and a slow home-page useEffect + // racing for the same fetch). The promise is stashed on the client so we + // don't need a separate keyed map and stale entries can't outlive the client. + const target = client as IJMAPClient & { __prefetchPromise?: Promise }; + if (target.__prefetchPromise) return target.__prefetchPromise; + target.__prefetchPromise = (async () => { + try { + await Promise.all([ + get().fetchMailboxes(client), + get().fetchQuota(client), + ]); + const { selectedMailbox } = get(); + if (selectedMailbox) { + await get().fetchEmails(client, selectedMailbox); + } else { + await get().fetchEmails(client); + } + // Tag counts can finish whenever; don't block the prefetch on them. + void get().fetchTagCounts(client); + } finally { + delete target.__prefetchPromise; + } + })(); + return target.__prefetchPromise; + }, + fetchEmails: async (client, mailboxId) => { set({ isLoading: true, error: null }); // Keep previous emails visible during transition try {