fix: drop ticked messages when the search changes, so Delete can't act on mail no longer listed

This commit is contained in:
Linus Rath
2026-11-03 09:00:32 +01:00
parent 9a992e3ddd
commit d5db3d4051
2 changed files with 57 additions and 2 deletions
+46
View File
@@ -1,5 +1,6 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { useEmailStore } from '../email-store';
import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
function makeEmail(id: string, threadId = `thread-${id}`) {
return {
@@ -31,6 +32,51 @@ describe('email-store selection', () => {
});
});
describe('a different search', () => {
const ticked = () => useEmailStore.getState().selectedEmailKeys.size;
beforeEach(() => {
useEmailStore.setState({ searchQuery: 'beacon', searchFilters: { ...DEFAULT_SEARCH_FILTERS } });
useEmailStore.getState().toggleEmailSelection({ id: 'b' });
expect(ticked()).toBe(1);
});
it('drops the ticks when the query changes, also to nothing', () => {
useEmailStore.getState().setSearchQuery('');
expect(ticked()).toBe(0);
expect(useEmailStore.getState().lastSelectedEmailKey).toBeNull();
});
it('keeps them when the same query is set again', () => {
useEmailStore.getState().setSearchQuery('beacon');
expect(ticked()).toBe(1);
});
it('drops them when a search runs for another query, before its results land', () => {
const pending = new Promise<never>(() => {});
void useEmailStore.getState().searchEmails({ searchEmails: () => pending } as never, 'rollback');
expect(ticked()).toBe(0);
});
it('keeps them when the same search runs again', () => {
const pending = new Promise<never>(() => {});
void useEmailStore.getState().searchEmails({ searchEmails: () => pending } as never, 'beacon');
expect(ticked()).toBe(1);
});
it('drops them when a filter changes or the filters are cleared', () => {
useEmailStore.getState().setSearchFilters({ isUnread: true });
expect(ticked()).toBe(0);
useEmailStore.getState().toggleEmailSelection({ id: 'b' });
useEmailStore.getState().clearSearchFilters();
expect(ticked()).toBe(0);
});
it('keeps them when clearing filters that were not set', () => {
useEmailStore.getState().clearSearchFilters();
expect(ticked()).toBe(1);
});
});
describe('toggleEmailSelection', () => {
it('should add email to selection', () => {
useEmailStore.getState().toggleEmailSelection({ id: 'b' });
+11 -2
View File
@@ -69,6 +69,8 @@ type PendingUndoSend = {
submissionAccountId?: string;
};
const NO_SELECTION = () => ({ selectedEmailKeys: new Set<string>(), lastSelectedEmailKey: null });
/** Drop the matching emails from a key-based selection. */
function dropFromSelection(
state: { emails: Email[]; selectedEmailKeys: Set<string> },
@@ -3603,7 +3605,12 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
setLoading: (loading) => set({ isLoading: loading }),
setLoadingEmail: (loading) => set({ isLoadingEmail: loading }),
setError: (error) => set({ error }),
setSearchQuery: (query) => set({ searchQuery: query }),
// Another search lists other mail: ticks kept from the last list would stay
// counted in the selection bar, with Delete acting on rows nobody can see.
setSearchQuery: (query) => set((state) => ({
searchQuery: query,
...(query === state.searchQuery ? {} : NO_SELECTION()),
})),
setQuota: (quota) => set({ quota }),
toggleEmailSelection: (email) => {
@@ -5360,7 +5367,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// trip later. What the mail view renders from is written only once the
// request is on its way (see afterRequestDispatch); the controller is
// recorded at once so a superseding search can still cancel this one.
set({ searchAbortController: controller });
set({ searchAbortController: controller, ...(typedQuery === get().searchQuery ? {} : NO_SELECTION()) });
afterRequestDispatch(() => {
if (get().searchAbortController === controller) set({ isLoading: true, error: null, searchQuery: typedQuery });
});
@@ -5598,6 +5605,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
setSearchFilters: (filters) => {
set((state) => ({
searchFilters: { ...state.searchFilters, ...filters },
...NO_SELECTION(),
}));
},
@@ -5617,6 +5625,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
searchFilters: { ...DEFAULT_SEARCH_FILTERS },
searchMailboxId: currentSearchScopeDefault(mailboxesInView(state), state.selectedMailbox),
searchScopeFollowsFolder: false,
...(isFilterEmpty(state.searchFilters) ? {} : NO_SELECTION()),
}));
},