fix: keep loading older mail when a saved or refreshed list has no total from the server #1364

This commit is contained in:
Linus Rath
2026-10-26 20:01:09 +01:00
parent 8940f45baa
commit 613451dfef
3 changed files with 67 additions and 3 deletions
@@ -88,6 +88,31 @@ describe('email boot snapshot folder', { timeout: 30_000 }, () => {
expect(state.emailListSync).toEqual(emailListSync);
});
// A snapshot that says "no more mail" beside rows its total does not cover
// kept the inbox at one page across reloads: the delta catch-up after boot
// never re-reads the total (#1364).
it('re-reads a page whose saved total does not cover its rows', async () => {
const rows = [{ id: 'e1' }, { id: 'e2' }];
const emailListSync = { state: 's1', accountId: 'acc', mailboxId: 'm-inbox' };
const state = await boot('/', {
mailboxes: FOLDERS, selectedMailbox: 'm-inbox', emails: rows, totalEmails: 0, hasMoreEmails: false, emailListSync,
});
expect(state.emails).toHaveLength(2);
const { useEmailStore } = await import('@/stores/email-store');
const { useSettingsStore } = await import('@/stores/settings-store');
useSettingsStore.setState({ emailsPerPage: 2 });
const client = {
getAccountId: () => 'acc',
getEmailChanges: vi.fn(async () => ({ oldState: 's1', newState: 's1', hasMoreChanges: false, created: [], updated: [], destroyed: [], updatedProperties: null })),
getEmails: vi.fn(async () => ({ emails: rows, hasMore: true, total: 10853, state: 's1' })),
getThreadEmailCounts: vi.fn(async () => ({})),
};
await useEmailStore.getState().fetchEmails(client as never, 'm-inbox', { background: true });
expect(client.getEmails).toHaveBeenCalled();
expect(useEmailStore.getState().totalEmails).toBe(10853);
expect(useEmailStore.getState().hasMoreEmails).toBe(true);
});
it('keeps the snapshot folder when the account has no inbox of its own', async () => {
const state = await boot('/', { mailboxes: FOLDERS.slice(1), selectedMailbox: 'm-trash', emails: TRASH_ROWS });
expect(state.selectedMailbox).toBe('m-trash');
@@ -178,6 +178,33 @@ describe('Email/changes delta sync', () => {
expect(useEmailStore.getState().emailListSync).toEqual({ state: 's2', accountId: 'acc', mailboxId: 'inbox' });
});
// A total of 0 beside the rows it was read with is a total the server did
// not give; it says nothing about mail past the page (#1364).
it('keeps paging when the refresh after new mail comes back without a total', async () => {
useEmailStore.setState({ totalEmails: 10, hasMoreEmails: true });
const client = makeClient({
getEmailChanges: vi.fn(async () => delta({ created: ['new'] })),
getSomeEmails: vi.fn(async () => [makeEmail('new')]),
getEmails: vi.fn(async () => ({ emails: [makeEmail('new'), makeEmail('a'), makeEmail('b')], hasMore: true, total: 0, state: 's2' })),
});
await useEmailStore.getState().handleStateChange(push({ acc: { Email: 's2' } }), client);
expect(useEmailStore.getState().emails.map((e) => e.id)).toEqual(['new', 'a', 'b']);
expect(useEmailStore.getState().hasMoreEmails).toBe(true);
});
it('keeps paging when a delta is applied to a list whose total is not known', async () => {
useEmailStore.setState({ totalEmails: 0, hasMoreEmails: true });
const client = makeClient({ getEmailChanges: vi.fn(async () => delta({ destroyed: ['c'] })) });
await useEmailStore.getState().handleStateChange(push({ acc: { Email: 's2' } }), client);
expect(client.getEmails).not.toHaveBeenCalled();
expect(useEmailStore.getState().emails.map((e) => e.id)).toEqual(['a', 'b']);
expect(useEmailStore.getState().hasMoreEmails).toBe(true);
});
it('falls back when the server cannot compute the delta', async () => {
const client = makeClient({ getEmailChanges: vi.fn(async () => null) });
await useEmailStore.getState().handleStateChange(push({ acc: { Email: 's2' } }), client);
+15 -3
View File
@@ -952,10 +952,12 @@ async function applyEmailDeltaNow(client: IJMAPClient, newState: string | null,
const threadEmailsCache = new Map(state.threadEmailsCache);
for (const tid of changedThreadIds) threadEmailsCache.delete(tid);
const totalEmails = Math.max(state.totalEmails - (current.length - next.length), next.length);
// A total below the rows shown is one the server did not give (#1364).
const totalKnown = state.totalEmails >= current.length;
return {
emails: next,
totalEmails,
hasMoreEmails: state.hasMoreEmails && next.length < totalEmails,
hasMoreEmails: state.hasMoreEmails && (!totalKnown || next.length < totalEmails),
threadEmailsCache,
emailListSync: state.emailListSync?.mailboxId === sync.mailboxId ? { ...sync, state: delta.newState } : state.emailListSync,
};
@@ -6506,7 +6508,11 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// hasMore should reflect whether there are still more emails beyond
// what we have loaded, using the fresh total from the server. The
// Unread section's rows are not part of that total.
const hasMore = merged.length - sectionRows.length < effectiveTotal;
// A total below the page it came with is one the server did not give:
// then only a full page tells that more may follow (#1364).
const hasMore = (result.total || 0) >= result.emails.length
? merged.length - sectionRows.length < effectiveTotal
: result.hasMore;
// Invalidate thread email caches for threads whose composition changed
// so expanded threads pick up new/removed emails.
@@ -8002,7 +8008,13 @@ if (typeof window !== 'undefined') {
// by it, so without it an "unread first" page shows by date
// until the live fetch lands and re-sorts it.
...(Array.isArray(snap.listOrder) ? { listOrder: sanitizeSortLevels(snap.listOrder) } : {}),
...(isEmailListSync(snap.emailListSync, snapMailbox) ? { emailListSync: snap.emailListSync } : {}),
// Without the baseline the live load re-reads the page and its
// total: a saved total that doesn't cover the rows is wrong, and
// a catch-up from the baseline would keep it, and "no more
// mail" with it, across reloads (#1364).
...(isEmailListSync(snap.emailListSync, snapMailbox) && !(snap.totalEmails < snap.emails.length)
? { emailListSync: snap.emailListSync }
: {}),
}
: {}),
});