fix: stop re-rendering every row when a search starts

This commit is contained in:
Linus Rath
2026-10-18 16:01:43 +02:00
parent ad9837d2d2
commit ea1d1e2870
2 changed files with 26 additions and 5 deletions
@@ -1,4 +1,5 @@
import { render, screen } from '@testing-library/react';
import { Profiler } from 'react';
import { act, render, screen } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ThreadListItem } from '../thread-list-item';
import { useSettingsStore, DEFAULT_KEYWORDS } from '@/stores/settings-store';
@@ -56,6 +57,7 @@ function renderThread(emails: Email[]) {
);
}
const noop = () => {};
const single = () => [makeEmail()];
const conversation = () => [
makeEmail({ id: 'email-1', receivedAt: '2024-01-15T10:00:00Z' }),
@@ -123,5 +125,20 @@ describe('ThreadListItem folder chip in search results (#1172)', () => {
renderThread(emails());
expect(screen.getByTitle('Archive')).toBeTruthy();
});
it('does not re-render a row without a folder when a search starts', () => {
// The folder's own list stays on screen until the results arrive; its
// rows carry no sourceFolder and have no chip to show.
const [thread] = groupEmailsByThread(emails().map((e) => ({ ...e, sourceFolder: undefined })));
const onRender = vi.fn();
render(
<Profiler id="row" onRender={onRender}>
<ThreadListItem thread={thread} isExpanded={false} onToggleExpand={noop} onEmailSelect={noop} />
</Profiler>,
);
onRender.mockClear();
act(() => useEmailStore.setState({ searchQuery: 'invoice', searchMailboxId: '' }));
expect(onRender).not.toHaveBeenCalled();
});
});
});
+8 -4
View File
@@ -253,9 +253,13 @@ function SourceFolderTag({ name }: { name: string }) {
* Whether rows show their folder chip: in the aggregate "All …" views, and in
* search results that span every folder rather than the one picked in the
* search panel (#1172). Rows of a single-folder list carry no sourceFolder.
*
* Such a row never subscribes to the view state: starting a search flips it
* while the old list is still on screen, and every row re-rendered for a chip
* it has nothing to put in.
*/
function useShowSourceFolder(): boolean {
return useEmailStore((state) => state.isUnifiedView || isUnscopedSearchActive(state));
function useShowSourceFolder(sourceFolder: string | undefined): boolean {
return useEmailStore((state) => !!sourceFolder && (state.isUnifiedView || isUnscopedSearchActive(state)));
}
/**
@@ -377,7 +381,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
const interfaceLayout = useSettingsStore((state) => state.interfaceLayout);
const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk;
// Show the originating folder in the aggregate "All …" views.
const showSourceFolder = useShowSourceFolder() && !!email.sourceFolder;
const showSourceFolder = useShowSourceFolder(email.sourceFolder);
const getAccountById = useAccountStore((state) => state.getAccountById);
const account = email.accountId ? getAccountById(email.accountId) : undefined;
const accountColor = account?.avatarColor;
@@ -932,7 +936,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
selectRangeEmails: state.selectRangeEmails,
isUnifiedView: state.isUnifiedView,
})));
const showSourceFolder = useShowSourceFolder() && !!latestEmail.sourceFolder;
const showSourceFolder = useShowSourceFolder(latestEmail.sourceFolder);
const getAccountById = useAccountStore((state) => state.getAccountById);
const threadAccount = latestEmail.accountId ? getAccountById(latestEmail.accountId) : undefined;
const threadAccountColor = threadAccount?.avatarColor;