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]);
// 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 lastVirtualItemIndex = virtualItems[virtualItems.length - 1]?.index;
const loadMoreTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (lastVirtualItemIndex === undefined) return;
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]);
// 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 }],
limit,
position,
calculateTotal: true,
}, "0"],
["Email/get", {
accountId: targetAccountId,
@@ -694,7 +695,12 @@ export class JMAPClient implements IJMAPClient {
const getResponse = response.methodResponses?.[1]?.[1];
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 hasMore = computeHasMore(position, emails.length, total, limit);
@@ -1214,6 +1220,7 @@ export class JMAPClient implements IJMAPClient {
sort: [{ property: "receivedAt", isAscending: false }],
limit,
position,
calculateTotal: true,
}, "0"],
["Email/get", {
accountId: targetAccountId,
@@ -1223,7 +1230,10 @@ export class JMAPClient implements IJMAPClient {
]);
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 hasMore = computeHasMore(position, emails.length, total, limit);
@@ -1250,6 +1260,7 @@ export class JMAPClient implements IJMAPClient {
sort: [{ property: "receivedAt", isAscending: false }],
limit,
position,
calculateTotal: true,
}, "0"],
["Email/get", {
accountId: targetAccountId,
@@ -1259,7 +1270,10 @@ export class JMAPClient implements IJMAPClient {
]);
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 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
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
// Capture position from current email count before the async call
const position = emails.length;
let result;
const { searchFilters } = get();
@@ -355,9 +358,9 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
if (hasFilters) {
const filter = buildJMAPFilter(searchQuery, searchFilters, jmapMailboxId);
result = await client.advancedSearchEmails(filter, accountId, emailsPerPage, emails.length);
result = await client.advancedSearchEmails(filter, accountId, emailsPerPage, position);
} else {
result = await client.searchEmails(searchQuery, jmapMailboxId, accountId, emailsPerPage, emails.length);
result = await client.searchEmails(searchQuery, jmapMailboxId, accountId, emailsPerPage, position);
}
} else {
// 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)
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({
emails: [...emails, ...result.emails],
emails: [...currentEmails, ...newEmails],
hasMoreEmails: result.hasMore,
totalEmails: result.total,
isLoadingMore: false
@@ -1242,12 +1254,30 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
get().handleNewEmailNotification(result.emails[0]);
}
// Skip state update if emails haven't actually changed to avoid
// unnecessary re-renders that cause a visible list flicker
// Merge the refreshed first page with the existing loaded emails.
// 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 =
currentEmails.length !== result.emails.length ||
result.emails.some((email, i) => {
currentEmails.length !== merged.length ||
merged.some((email, i) => {
const curr = currentEmails[i];
if (!curr) return true;
return (
curr.id !== email.id ||
curr.threadId !== email.threadId ||
@@ -1256,9 +1286,12 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
});
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({
emails: result.emails,
hasMoreEmails: result.hasMore,
emails: merged,
hasMoreEmails: hasMore,
totalEmails: result.total,
});
}