fix: enhance email loading and deduplication logic in email store #119

This commit is contained in:
Linus Rath
2026-03-30 08:32:46 +02:00
parent 2d983c9853
commit 1eebec292a
3 changed files with 73 additions and 15 deletions
+13 -2
View File
@@ -221,15 +221,26 @@ export function EmailList({
} }
}, [client, expandedThreadIds, toggleThreadExpansion, fetchThreadEmails]); }, [client, expandedThreadIds, toggleThreadExpansion, fetchThreadEmails]);
// Range-based load more: trigger when last visible item is near the end // Range-based load more: trigger when last visible item is near the end.
// Debounce to prevent rapid cascade when thread grouping reduces item
// count below the viewport size (e.g. 2400 emails → fewer thread groups).
const virtualItems = virtualizer.getVirtualItems(); const virtualItems = virtualizer.getVirtualItems();
const lastVirtualItemIndex = virtualItems[virtualItems.length - 1]?.index; const lastVirtualItemIndex = virtualItems[virtualItems.length - 1]?.index;
const loadMoreTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => { useEffect(() => {
if (lastVirtualItemIndex === undefined) return; if (lastVirtualItemIndex === undefined) return;
if (lastVirtualItemIndex >= threadGroups.length - 5) { if (lastVirtualItemIndex >= threadGroups.length - 5) {
handleLoadMore(); // Clear any pending timer so we don't stack calls
if (loadMoreTimerRef.current) clearTimeout(loadMoreTimerRef.current);
loadMoreTimerRef.current = setTimeout(() => {
handleLoadMore();
loadMoreTimerRef.current = null;
}, 150);
} }
return () => {
if (loadMoreTimerRef.current) clearTimeout(loadMoreTimerRef.current);
};
}, [lastVirtualItemIndex, threadGroups.length, handleLoadMore]); }, [lastVirtualItemIndex, threadGroups.length, handleLoadMore]);
// Scroll to the thread group containing the selected email // Scroll to the thread group containing the selected email
+17 -3
View File
@@ -682,6 +682,7 @@ export class JMAPClient implements IJMAPClient {
sort: [{ property: "receivedAt", isAscending: false }], sort: [{ property: "receivedAt", isAscending: false }],
limit, limit,
position, position,
calculateTotal: true,
}, "0"], }, "0"],
["Email/get", { ["Email/get", {
accountId: targetAccountId, accountId: targetAccountId,
@@ -694,7 +695,12 @@ export class JMAPClient implements IJMAPClient {
const getResponse = response.methodResponses?.[1]?.[1]; const getResponse = response.methodResponses?.[1]?.[1];
if (response.methodResponses?.[1]?.[0] === "Email/get" && getResponse) { if (response.methodResponses?.[1]?.[0] === "Email/get" && getResponse) {
const emails = getResponse.list || []; const emails = (getResponse.list || []) as Email[];
// Sort client-side as safety net — some servers may not honour
// the query sort for large mailboxes without additional filters.
emails.sort((a: Email, b: Email) =>
new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime()
);
const total = queryResponse?.total || 0; const total = queryResponse?.total || 0;
const hasMore = computeHasMore(position, emails.length, total, limit); const hasMore = computeHasMore(position, emails.length, total, limit);
@@ -1214,6 +1220,7 @@ export class JMAPClient implements IJMAPClient {
sort: [{ property: "receivedAt", isAscending: false }], sort: [{ property: "receivedAt", isAscending: false }],
limit, limit,
position, position,
calculateTotal: true,
}, "0"], }, "0"],
["Email/get", { ["Email/get", {
accountId: targetAccountId, accountId: targetAccountId,
@@ -1223,7 +1230,10 @@ export class JMAPClient implements IJMAPClient {
]); ]);
const queryResponse = response.methodResponses?.[0]?.[1]; const queryResponse = response.methodResponses?.[0]?.[1];
const emails = response.methodResponses?.[1]?.[1]?.list || []; const emails = (response.methodResponses?.[1]?.[1]?.list || []) as Email[];
emails.sort((a: Email, b: Email) =>
new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime()
);
const total = queryResponse?.total || 0; const total = queryResponse?.total || 0;
const hasMore = computeHasMore(position, emails.length, total, limit); const hasMore = computeHasMore(position, emails.length, total, limit);
@@ -1250,6 +1260,7 @@ export class JMAPClient implements IJMAPClient {
sort: [{ property: "receivedAt", isAscending: false }], sort: [{ property: "receivedAt", isAscending: false }],
limit, limit,
position, position,
calculateTotal: true,
}, "0"], }, "0"],
["Email/get", { ["Email/get", {
accountId: targetAccountId, accountId: targetAccountId,
@@ -1259,7 +1270,10 @@ export class JMAPClient implements IJMAPClient {
]); ]);
const queryResponse = response.methodResponses?.[0]?.[1]; const queryResponse = response.methodResponses?.[0]?.[1];
const emails = response.methodResponses?.[1]?.[1]?.list || []; const emails = (response.methodResponses?.[1]?.[1]?.list || []) as Email[];
emails.sort((a: Email, b: Email) =>
new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime()
);
const total = queryResponse?.total || 0; const total = queryResponse?.total || 0;
const hasMore = computeHasMore(position, emails.length, total, limit); const hasMore = computeHasMore(position, emails.length, total, limit);
+43 -10
View File
@@ -342,6 +342,9 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// Get emails per page from settings // Get emails per page from settings
const emailsPerPage = useSettingsStore.getState().emailsPerPage; const emailsPerPage = useSettingsStore.getState().emailsPerPage;
// Capture position from current email count before the async call
const position = emails.length;
let result; let result;
const { searchFilters } = get(); const { searchFilters } = get();
@@ -355,9 +358,9 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
if (hasFilters) { if (hasFilters) {
const filter = buildJMAPFilter(searchQuery, searchFilters, jmapMailboxId); const filter = buildJMAPFilter(searchQuery, searchFilters, jmapMailboxId);
result = await client.advancedSearchEmails(filter, accountId, emailsPerPage, emails.length); result = await client.advancedSearchEmails(filter, accountId, emailsPerPage, position);
} else { } else {
result = await client.searchEmails(searchQuery, jmapMailboxId, accountId, emailsPerPage, emails.length); result = await client.searchEmails(searchQuery, jmapMailboxId, accountId, emailsPerPage, position);
} }
} else { } else {
// Load more from mailbox // Load more from mailbox
@@ -369,11 +372,20 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// Use originalId for JMAP queries (shared mailboxes use namespaced IDs in the store) // Use originalId for JMAP queries (shared mailboxes use namespaced IDs in the store)
const jmapMailboxId = mailbox?.originalId || selectedMailbox; const jmapMailboxId = mailbox?.originalId || selectedMailbox;
result = await client.getEmails(jmapMailboxId, accountId, emailsPerPage, emails.length, selectedKeyword ? `$label:${selectedKeyword}` : undefined); result = await client.getEmails(jmapMailboxId, accountId, emailsPerPage, position, selectedKeyword ? `$label:${selectedKeyword}` : undefined);
} }
// Use fresh state when merging to avoid overwriting concurrent updates
// (e.g. refreshCurrentMailbox running during the load)
const currentEmails = get().emails;
// Deduplicate: the server may return overlapping results if new emails
// arrived between paginated requests and shifted positions.
const existingIds = new Set(currentEmails.map(e => e.id));
const newEmails = result.emails.filter((e: Email) => !existingIds.has(e.id));
set({ set({
emails: [...emails, ...result.emails], emails: [...currentEmails, ...newEmails],
hasMoreEmails: result.hasMore, hasMoreEmails: result.hasMore,
totalEmails: result.total, totalEmails: result.total,
isLoadingMore: false isLoadingMore: false
@@ -1242,12 +1254,30 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
get().handleNewEmailNotification(result.emails[0]); get().handleNewEmailNotification(result.emails[0]);
} }
// Skip state update if emails haven't actually changed to avoid // Merge the refreshed first page with the existing loaded emails.
// unnecessary re-renders that cause a visible list flicker // This avoids discarding already-loaded pages which would cause the
// virtual list to shrink and then rapidly re-load (scroll bounce).
const freshMap = new Map(result.emails.map((e: Email) => [e.id, e]));
// Build the merged list: start with the fresh first page, then append
// existing emails beyond that page (if any), skipping duplicates and
// emails removed from the first page (e.g. deleted or moved).
const merged: Email[] = [...result.emails];
const mergedIds = new Set(result.emails.map((e: Email) => e.id));
for (const email of currentEmails) {
if (!mergedIds.has(email.id)) {
merged.push(email);
mergedIds.add(email.id);
}
}
// Check if anything actually changed to avoid unnecessary re-renders
const hasChanged = const hasChanged =
currentEmails.length !== result.emails.length || currentEmails.length !== merged.length ||
result.emails.some((email, i) => { merged.some((email, i) => {
const curr = currentEmails[i]; const curr = currentEmails[i];
if (!curr) return true;
return ( return (
curr.id !== email.id || curr.id !== email.id ||
curr.threadId !== email.threadId || curr.threadId !== email.threadId ||
@@ -1256,9 +1286,12 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
}); });
if (hasChanged) { if (hasChanged) {
// hasMore should reflect whether there are still more emails beyond
// what we have loaded, using the fresh total from the server.
const hasMore = merged.length < (result.total || 0);
set({ set({
emails: result.emails, emails: merged,
hasMoreEmails: result.hasMore, hasMoreEmails: hasMore,
totalEmails: result.total, totalEmails: result.total,
}); });
} }