feat: implement prefetching of initial email data

This commit is contained in:
Linus Rath
2026-05-11 15:17:30 +02:00
parent b0640c9ecc
commit b3dc2e32b8
3 changed files with 78 additions and 32 deletions
+26 -32
View File
@@ -650,7 +650,10 @@ export default function Home() {
} }
}, [initialCheckDone, isAuthenticated, authLoading]); }, [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(() => { useEffect(() => {
if (isAuthenticated && client && mailboxes.length === 0) { if (isAuthenticated && client && mailboxes.length === 0) {
let retryTimer: ReturnType<typeof setTimeout> | null = null; let retryTimer: ReturnType<typeof setTimeout> | null = null;
@@ -658,18 +661,14 @@ export default function Home() {
const loadData = async (attempt = 1) => { const loadData = async (attempt = 1) => {
try { try {
// First fetch mailboxes and quota (inbox will be auto-selected in fetchMailboxes)
await Promise.all([ await Promise.all([
fetchMailboxes(client), fetchMailboxes(client),
fetchQuota(client) fetchQuota(client)
]); ]);
// Get the selected mailbox (should be inbox by default)
const state = useEmailStore.getState(); const state = useEmailStore.getState();
const selectedMailboxId = state.selectedMailbox; 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) { if (state.mailboxes.length === 0 && attempt <= 5 && !cancelled) {
const delay = Math.min(1000 * attempt, 5000); const delay = Math.min(1000 * attempt, 5000);
debug.log('jmap', `[Mailbox] No mailboxes returned (attempt ${attempt}), retrying in ${delay}ms`); debug.log('jmap', `[Mailbox] No mailboxes returned (attempt ${attempt}), retrying in ${delay}ms`);
@@ -677,34 +676,13 @@ export default function Home() {
return; return;
} }
// Fetch emails for the selected mailbox
if (selectedMailboxId) { if (selectedMailboxId) {
await fetchEmails(client, selectedMailboxId); await fetchEmails(client, selectedMailboxId);
} else { } else {
await fetchEmails(client); await fetchEmails(client);
} }
// Fetch tag counts
fetchTagCounts(client); 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) { } catch (error) {
console.error('Error loading email data:', error); console.error('Error loading email data:', error);
} }
@@ -714,17 +692,33 @@ export default function Home() {
return () => { return () => {
cancelled = true; cancelled = true;
if (retryTimer) clearTimeout(retryTimer); if (retryTimer) clearTimeout(retryTimer);
client.closePushNotifications();
}; };
} }
}, [isAuthenticated, client, mailboxes.length, fetchMailboxes, fetchEmails, fetchQuota, fetchTagCounts]);
// Cleanup push notifications on unmount // Push notifications: set up once per client and tear down when the client
return () => { // goes away (logout or account switch). Kept separate from the fetch effect
if (client) { // above so it still runs when data was prefetched at login time.
client.closePushNotifications(); 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 // 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 // than one account is connected. Runs whenever the set of connected accounts
+21
View File
@@ -512,6 +512,15 @@ export const useAuthStore = create<AuthState>()(
activeAccountId: accountId, 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 // Schedule token refresh for TOTP-upgraded sessions
if (upgradedToOAuth && oauthExpiresIn > 0) { if (upgradedToOAuth && oauthExpiresIn > 0) {
scheduleRefresh(oauthExpiresIn, get().refreshAccessToken, accountId); scheduleRefresh(oauthExpiresIn, get().refreshAccessToken, accountId);
@@ -709,6 +718,12 @@ export const useAuthStore = create<AuthState>()(
activeAccountId: accountId, 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); scheduleRefresh(expires_in, get().refreshAccessToken, accountId);
notifyParent('sso:auth-success', { username }); notifyParent('sso:auth-success', { username });
@@ -841,6 +856,12 @@ export const useAuthStore = create<AuthState>()(
activeAccountId: accountId, 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); scheduleRefresh(expires_in, get().refreshAccessToken, accountId);
notifyParent('sso:auth-success', { username }); notifyParent('sso:auth-success', { username });
+31
View File
@@ -72,6 +72,10 @@ interface EmailStore {
// JMAP operations // JMAP operations
fetchMailboxes: (client: IJMAPClient) => Promise<void>; fetchMailboxes: (client: IJMAPClient) => Promise<void>;
fetchEmails: (client: IJMAPClient, mailboxId?: string) => Promise<void>; fetchEmails: (client: IJMAPClient, mailboxId?: string) => Promise<void>;
// 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<void>;
loadMoreEmails: (client: IJMAPClient) => Promise<void>; loadMoreEmails: (client: IJMAPClient) => Promise<void>;
fetchEmailContent: (client: IJMAPClient, emailId: string) => Promise<Email | null>; fetchEmailContent: (client: IJMAPClient, emailId: string) => Promise<Email | null>;
fetchQuota: (client: IJMAPClient) => Promise<void>; fetchQuota: (client: IJMAPClient) => Promise<void>;
@@ -350,6 +354,33 @@ export const useEmailStore = create<EmailStore>((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<void> };
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) => { fetchEmails: async (client, mailboxId) => {
set({ isLoading: true, error: null }); // Keep previous emails visible during transition set({ isLoading: true, error: null }); // Keep previous emails visible during transition
try { try {