Skip to content

Commit 98ba7ff

Browse files
committed
perf(emojis): debounce picker search and share one search index
- Debounce the query that drives in-panel search (via a version-safe useDebouncedValue hook — useDeferredValue is React 18+), so a broad query no longer re-scans the ~1,900-entry index and re-renders up to 90 result cells on every keystroke; clearing the field still exits search immediately. - Memoize buildEmojiSearchData by its data object so the picker panel and the composer middleware share a single index build instead of computing it twice.
1 parent e539909 commit 98ba7ff

5 files changed

Lines changed: 117 additions & 8 deletions

File tree

src/plugins/Emojis/components/EmojiPickerPanel.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
type EmojiPickerContextValue,
1414
EmojiPickerProvider,
1515
} from '../context/EmojiPickerContext';
16+
import { useDebouncedValue } from '../hooks/useDebouncedValue';
1617
import { useEmojiPickerState } from '../hooks/useEmojiPickerState';
1718
import { useGridKeyboardNav } from '../hooks/useGridKeyboardNav';
1819
import { buildEmojiSearchData, runSearch } from '../search';
@@ -86,6 +87,9 @@ export const EmojiPickerPanel = ({
8687
const [previewedEmoji, setPreviewedEmoji] = useState<EmojiDataEmoji | null>(null);
8788
const [activeCategoryId, setActiveCategoryId] = useState<string | undefined>(undefined);
8889
const [query, setQuery] = useState('');
90+
// Debounce the query that drives the search so typing doesn't re-scan the index and
91+
// re-render up to 90 result cells on every keystroke (clearing still exits at once).
92+
const debouncedQuery = useDebouncedValue(query, 120);
8993
const emojiGridRef = useRef<EmojiGridHandle>(null);
9094
const bodyRef = useRef<HTMLDivElement>(null);
9195

@@ -123,14 +127,16 @@ export const EmojiPickerPanel = ({
123127

124128
const searchIndex = useMemo(() => (data ? buildEmojiSearchData(data) : []), [data]);
125129

126-
// `null` when not searching; otherwise the (possibly empty) list of matches.
130+
// `null` when not searching; otherwise the (possibly empty) list of matches. Clearing
131+
// the field (empty live `query`) exits search immediately; a non-empty query is taken
132+
// from the debounced value.
127133
const searchedEmojis = useMemo<EmojiDataEmoji[] | null>(() => {
128-
const trimmed = query.trim();
134+
const trimmed = query.trim() && debouncedQuery.trim();
129135
if (!trimmed || !data) return null;
130136
return (runSearch(searchIndex, trimmed) ?? [])
131137
.map((result) => data.emojis[result.id])
132138
.filter(Boolean);
133-
}, [data, query, searchIndex]);
139+
}, [data, debouncedQuery, query, searchIndex]);
134140

135141
const onSelectEmoji = useCallback(
136142
(emoji: EmojiDataEmoji) => {
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { act, renderHook } from '@testing-library/react';
2+
import { useDebouncedValue } from '../useDebouncedValue';
3+
4+
describe('useDebouncedValue', () => {
5+
beforeEach(() => vi.useFakeTimers());
6+
afterEach(() => vi.useRealTimers());
7+
8+
it('returns the initial value immediately', () => {
9+
const { result } = renderHook(() => useDebouncedValue('a', 100));
10+
expect(result.current).toBe('a');
11+
});
12+
13+
it('updates to the latest value only after the delay elapses', () => {
14+
const { rerender, result } = renderHook(({ v }) => useDebouncedValue(v, 100), {
15+
initialProps: { v: 'a' },
16+
});
17+
18+
rerender({ v: 'b' });
19+
expect(result.current).toBe('a'); // not yet
20+
21+
act(() => vi.advanceTimersByTime(100));
22+
expect(result.current).toBe('b');
23+
});
24+
25+
it('collapses rapid changes, emitting only the final value', () => {
26+
const { rerender, result } = renderHook(({ v }) => useDebouncedValue(v, 100), {
27+
initialProps: { v: 'a' },
28+
});
29+
30+
rerender({ v: 'ab' });
31+
act(() => vi.advanceTimersByTime(50));
32+
rerender({ v: 'abc' });
33+
act(() => vi.advanceTimersByTime(50)); // only 50ms since 'abc' → still stale
34+
expect(result.current).toBe('a');
35+
36+
act(() => vi.advanceTimersByTime(50)); // now 100ms since 'abc'
37+
expect(result.current).toBe('abc'); // intermediate 'ab' was skipped
38+
});
39+
});
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { useEffect, useState } from 'react';
2+
3+
/**
4+
* Returns `value` debounced by `delayMs`: the result only catches up to the latest value
5+
* once `delayMs` has elapsed without a further change. Version-safe across React 17–19
6+
* (unlike `useDeferredValue`, which is React 18+).
7+
*/
8+
export const useDebouncedValue = <T>(value: T, delayMs: number): T => {
9+
const [debounced, setDebounced] = useState(value);
10+
11+
useEffect(() => {
12+
const id = setTimeout(() => setDebounced(value), delayMs);
13+
return () => clearTimeout(id);
14+
}, [value, delayMs]);
15+
16+
return debounced;
17+
};
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { buildEmojiSearchData } from '../buildEmojiSearchData';
2+
import type { EmojiData } from '../../data/types';
3+
4+
const makeData = (): EmojiData =>
5+
({
6+
aliases: {},
7+
categories: [],
8+
emojis: {
9+
smile: {
10+
id: 'smile',
11+
keywords: ['happy'],
12+
name: 'Smile',
13+
skins: [{ native: '😄', unified: '1f604' }],
14+
version: 1,
15+
},
16+
},
17+
}) as unknown as EmojiData;
18+
19+
describe('buildEmojiSearchData', () => {
20+
it('memoizes by data object so the panel and middleware share a single build', () => {
21+
const data = makeData();
22+
expect(buildEmojiSearchData(data)).toBe(buildEmojiSearchData(data));
23+
});
24+
25+
it('builds a distinct index for a different data object', () => {
26+
expect(buildEmojiSearchData(makeData())).not.toBe(buildEmojiSearchData(makeData()));
27+
});
28+
29+
it('produces a comma-anchored haystack and resolves the native glyph', () => {
30+
const [entry] = buildEmojiSearchData(makeData());
31+
expect(entry.id).toBe('smile');
32+
expect(entry.search).toContain(',smile');
33+
expect(entry.native).toBe('😄');
34+
});
35+
});

src/plugins/Emojis/search/buildEmojiSearchData.ts

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,17 +42,29 @@ const buildHaystack = (emoji: EmojiDataEmoji): string => {
4242
return haystack;
4343
};
4444

45+
// Cache the built index per data object. The dataset is loaded once (a memoized dynamic
46+
// import), so the picker panel and the composer middleware pass the same object here and
47+
// share a single ~1,900-entry build instead of computing it twice.
48+
const cache = new WeakMap<EmojiData, SearchableEmoji[]>();
49+
4550
/**
46-
* Transforms the vendored emoji dataset into a flat, search-ready index. Pure and
47-
* side-effect free — the produced `search` haystack matches emoji-mart's format so
48-
* that ranking parity with `emoji-mart`'s `SearchIndex` is preserved.
51+
* Transforms the vendored emoji dataset into a flat, search-ready index (memoized by the
52+
* data object). Pure and side-effect free — the produced `search` haystack matches
53+
* emoji-mart's format so that ranking parity with `emoji-mart`'s `SearchIndex` is
54+
* preserved.
4955
*/
50-
export const buildEmojiSearchData = (data: EmojiData): SearchableEmoji[] =>
51-
Object.values(data.emojis).map((emoji) => ({
56+
export const buildEmojiSearchData = (data: EmojiData): SearchableEmoji[] => {
57+
const cached = cache.get(data);
58+
if (cached) return cached;
59+
60+
const built = Object.values(data.emojis).map((emoji) => ({
5261
emoticons: emoji.emoticons,
5362
id: emoji.id,
5463
name: emoji.name,
5564
native: emoji.skins[0]?.native ?? '',
5665
search: buildHaystack(emoji),
5766
skins: emoji.skins,
5867
}));
68+
cache.set(data, built);
69+
return built;
70+
};

0 commit comments

Comments
 (0)