Skip to content

Commit 378eb4f

Browse files
kfaracikclaude
andcommitted
perf(drawer): sort the chat list once instead of on every keystroke
buildChatSections re-sorted the whole list on each render, so typing in search ran an O(n log n) sort per keystroke. Move sorting into a memoized sortChatsByRecency keyed on the chats, leaving only the cheap filter to re-run while typing. Adds unit tests for the section/label helpers. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 715c32f commit 378eb4f

5 files changed

Lines changed: 116 additions & 6 deletions

File tree

__tests__/DrawerMenu.test.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ const setPlatform = (os: string) => {
6969
Object.defineProperty(Platform, 'OS', { get: () => os, configurable: true });
7070
};
7171

72+
const ORIGINAL_OS = Platform.OS;
73+
7274
const defaultProps = {
7375
searching: false,
7476
search: '',
@@ -91,6 +93,8 @@ beforeEach(() => {
9193
setPlatform('ios');
9294
});
9395

96+
afterEach(() => setPlatform(ORIGINAL_OS));
97+
9498
describe('DrawerMenu — collapsed', () => {
9599
it('keeps New chat, Models and App Info at the top', () => {
96100
renderMenu();

__tests__/chatLabel.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { chatLabel } from '../utils/chatLabel';
2+
3+
describe('chatLabel', () => {
4+
it('returns the title when the chat has one', () => {
5+
expect(chatLabel({ id: 3, title: 'Trip to Rome' })).toBe('Trip to Rome');
6+
});
7+
8+
it('falls back to "Chat <id>" for an empty title', () => {
9+
expect(chatLabel({ id: 9, title: '' })).toBe('Chat 9');
10+
});
11+
});

__tests__/chatSections.test.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import {
2+
buildChatSections,
3+
getRelativeDateSection,
4+
sortChatsByRecency,
5+
} from '../components/drawer/chatSections';
6+
import { Chat } from '../database/chatRepository';
7+
8+
const HOUR = 60 * 60 * 1000;
9+
const DAY = 24 * HOUR;
10+
const NOW = new Date('2026-03-10T12:00:00Z').getTime();
11+
12+
const makeChat = (over: Partial<Chat>): Chat => ({
13+
id: 1,
14+
modelId: 1,
15+
title: 'Chat',
16+
lastUsed: NOW,
17+
...over,
18+
});
19+
20+
describe('getRelativeDateSection', () => {
21+
const now = new Date(NOW);
22+
23+
it('labels the same day as Today', () => {
24+
expect(getRelativeDateSection(new Date(NOW - HOUR), now)).toBe('Today');
25+
});
26+
27+
it('labels the previous day as Yesterday', () => {
28+
expect(getRelativeDateSection(new Date(NOW - DAY), now)).toBe('Yesterday');
29+
});
30+
31+
it('labels 2-6 days back as "<n> days ago"', () => {
32+
expect(getRelativeDateSection(new Date(NOW - 2 * DAY), now)).toBe(
33+
'2 days ago'
34+
);
35+
});
36+
37+
it('labels anything older than a year as "More than a year ago"', () => {
38+
expect(getRelativeDateSection(new Date(NOW - 400 * DAY), now)).toBe(
39+
'More than a year ago'
40+
);
41+
});
42+
});
43+
44+
describe('sortChatsByRecency', () => {
45+
it('orders chats most-recent first without mutating the input', () => {
46+
const input = [
47+
makeChat({ id: 1, lastUsed: NOW - DAY }),
48+
makeChat({ id: 2, lastUsed: NOW - HOUR }),
49+
makeChat({ id: 3, lastUsed: NOW - 2 * DAY }),
50+
];
51+
52+
const sorted = sortChatsByRecency(input);
53+
54+
expect(sorted.map((chat) => chat.id)).toEqual([2, 1, 3]);
55+
expect(input.map((chat) => chat.id)).toEqual([1, 2, 3]);
56+
});
57+
});
58+
59+
describe('buildChatSections', () => {
60+
const chats = [
61+
makeChat({ id: 1, title: 'Pizza recipe', lastUsed: NOW - HOUR }),
62+
makeChat({ id: 2, title: 'Meeting notes', lastUsed: NOW - 2 * HOUR }),
63+
makeChat({ id: 3, title: 'Trip to Rome', lastUsed: NOW - DAY }),
64+
];
65+
66+
it('groups pre-sorted chats by their relative date section', () => {
67+
const sections = buildChatSections(chats, '', NOW);
68+
69+
expect(sections).toEqual([
70+
['Today', [chats[0], chats[1]]],
71+
['Yesterday', [chats[2]]],
72+
]);
73+
});
74+
75+
it('filters by a case-insensitive label match', () => {
76+
const sections = buildChatSections(chats, 'rome', NOW);
77+
78+
expect(sections).toEqual([['Yesterday', [chats[2]]]]);
79+
});
80+
81+
it('matches the "Chat <id>" fallback label of untitled chats', () => {
82+
const untitled = makeChat({ id: 42, title: '', lastUsed: NOW - HOUR });
83+
84+
const sections = buildChatSections([untitled], 'chat 42', NOW);
85+
86+
expect(sections).toEqual([['Today', [untitled]]]);
87+
});
88+
89+
it('returns no sections when nothing matches', () => {
90+
expect(buildChatSections(chats, 'nonexistent', NOW)).toEqual([]);
91+
});
92+
});

components/drawer/DrawerMenu.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { DrawerItem } from './DrawerItem';
1919
import { DrawerTopBar } from './DrawerTopBar';
2020
import { DrawerNavSection } from './DrawerNavSection';
2121
import { DrawerEmptyState } from './DrawerEmptyState';
22-
import { buildChatSections } from './chatSections';
22+
import { buildChatSections, sortChatsByRecency } from './chatSections';
2323
import { useDrawerChatMenu } from './useDrawerChatMenu';
2424
import {
2525
DRAWER_HORIZONTAL_PADDING,
@@ -85,9 +85,10 @@ const DrawerMenu = ({
8585
const query = search.trim().toLowerCase();
8686
const isFiltering = query.length > 0;
8787

88+
const sortedChats = useMemo(() => sortChatsByRecency(chats), [chats]);
8889
const sections = useMemo(
89-
() => buildChatSections(chats, query, now),
90-
[chats, query, now]
90+
() => buildChatSections(sortedChats, query, now),
91+
[sortedChats, query, now]
9192
);
9293

9394
const hasNoResults = isFiltering && sections.length === 0;

components/drawer/chatSections.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,18 @@ export const getRelativeDateSection = (date: Date, now: Date): string => {
2222

2323
export type ChatSection = [string, Chat[]];
2424

25+
export const sortChatsByRecency = (chats: Chat[]): Chat[] =>
26+
[...chats].sort((a, b) => b.lastUsed - a.lastUsed);
27+
2528
export const buildChatSections = (
2629
chats: Chat[],
2730
query: string,
2831
now: number
2932
): ChatSection[] => {
3033
const nowDate = new Date(now);
31-
const sorted = [...chats].sort((a, b) => b.lastUsed - a.lastUsed);
3234
const matching = query
33-
? sorted.filter((chat) => chatLabel(chat).toLowerCase().includes(query))
34-
: sorted;
35+
? chats.filter((chat) => chatLabel(chat).toLowerCase().includes(query))
36+
: chats;
3537

3638
const sections: Record<string, Chat[]> = {};
3739
matching.forEach((chat) => {

0 commit comments

Comments
 (0)