Skip to content

Commit ccb2772

Browse files
committed
perf(emojis): isolate emoji cells from hot picker state via a cell context
EmojiButton is memoized and mounts once per emoji (hundreds at a time), but it subscribed to the public EmojiPickerContext. memo guards prop changes, not context changes, so every mounted cell re-rendered whenever a hot field changed identity — activeCategoryId on each scroll-spy tick, query/searchResults on each keystroke, scrollTarget on each nav click — none of which a cell reads. Move the two cold values a cell needs (resolveNative, selectEmoji) into a dedicated internal EmojiPickerCellContext and subscribe the cell there, mirroring the existing preview-context split. Cells now re-render only when the skin tone or onEmojiSelect changes. Both values remain on the public context for custom grid slots, so there is no public API change.
1 parent eeb8f6d commit ccb2772

5 files changed

Lines changed: 204 additions & 36 deletions

File tree

src/plugins/Emojis/components/EmojiButton.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { memo } from 'react';
2-
import { useEmojiPickerContext } from '../context/EmojiPickerContext';
2+
import { useEmojiPickerCellContext } from '../context/EmojiPickerCellContext';
33
import { useEmojiPickerPreviewContext } from '../context/EmojiPickerPreviewContext';
44
import type { EmojiDataEmoji } from '../data';
55

@@ -9,10 +9,12 @@ export type EmojiButtonProps = {
99

1010
/**
1111
* A single selectable emoji cell rendering the native unicode glyph for the active
12-
* skin tone. Memoized because the grid can render ~1800 of these.
12+
* skin tone. Memoized because the grid can render ~1800 of these — and it subscribes to
13+
* the cold cell context (not the public picker context) so scroll-spy/search updates,
14+
* which a cell reads none of, never re-render the grid.
1315
*/
1416
export const EmojiButton = memo(function EmojiButton({ emoji }: EmojiButtonProps) {
15-
const { resolveNative, selectEmoji } = useEmojiPickerContext();
17+
const { resolveNative, selectEmoji } = useEmojiPickerCellContext();
1618
const { setPreviewedEmoji } = useEmojiPickerPreviewContext();
1719

1820
return (

src/plugins/Emojis/components/EmojiPickerRoot.tsx

Lines changed: 43 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { type EmojiPickerCategory } from './EmojiGrid';
1010
import { EMOJI_CATEGORY_META } from './categories';
1111
import { resolveFrequentlyUsedEmoji } from './frequentlyUsed';
1212
import { SKIN_TONES } from './skinTones';
13+
import { EmojiPickerCellProvider } from '../context/EmojiPickerCellContext';
1314
import {
1415
type EmojiPickerContextValue,
1516
EmojiPickerProvider,
@@ -248,41 +249,50 @@ export const EmojiPickerRoot = ({
248249
[previewedEmoji],
249250
);
250251

252+
// The cold subset the default cell subscribes to, so the grid's ~hundreds of cells
253+
// don't re-render on hot state (scroll-spy active category, live query, scroll target).
254+
const cellValue = useMemo(
255+
() => ({ resolveNative, selectEmoji }),
256+
[resolveNative, selectEmoji],
257+
);
258+
251259
return (
252260
<EmojiPickerProvider value={contextValue}>
253-
<EmojiPickerPreviewProvider value={previewValue}>
254-
<div
255-
aria-label={t('aria/Emoji picker')}
256-
className={clsx('str-chat__emoji-picker', themeClassName(theme), className)}
257-
onKeyDown={(event) => {
258-
if (event.key === 'Escape') {
259-
event.stopPropagation();
260-
onClose?.();
261-
}
262-
}}
263-
role='dialog'
264-
style={style}
265-
>
266-
{status === 'ready' ? (
267-
children
268-
) : status === 'error' ? (
269-
<div className='str-chat__emoji-picker__error' role='alert'>
270-
<p className='str-chat__emoji-picker__error-message'>
271-
{t('Failed to load emojis')}
272-
</p>
273-
<button
274-
className='str-chat__emoji-picker__error-retry'
275-
onClick={retry}
276-
type='button'
277-
>
278-
{t('Retry')}
279-
</button>
280-
</div>
281-
) : (
282-
<div aria-busy='true' className='str-chat__emoji-picker__loading' />
283-
)}
284-
</div>
285-
</EmojiPickerPreviewProvider>
261+
<EmojiPickerCellProvider value={cellValue}>
262+
<EmojiPickerPreviewProvider value={previewValue}>
263+
<div
264+
aria-label={t('aria/Emoji picker')}
265+
className={clsx('str-chat__emoji-picker', themeClassName(theme), className)}
266+
onKeyDown={(event) => {
267+
if (event.key === 'Escape') {
268+
event.stopPropagation();
269+
onClose?.();
270+
}
271+
}}
272+
role='dialog'
273+
style={style}
274+
>
275+
{status === 'ready' ? (
276+
children
277+
) : status === 'error' ? (
278+
<div className='str-chat__emoji-picker__error' role='alert'>
279+
<p className='str-chat__emoji-picker__error-message'>
280+
{t('Failed to load emojis')}
281+
</p>
282+
<button
283+
className='str-chat__emoji-picker__error-retry'
284+
onClick={retry}
285+
type='button'
286+
>
287+
{t('Retry')}
288+
</button>
289+
</div>
290+
) : (
291+
<div aria-busy='true' className='str-chat__emoji-picker__loading' />
292+
)}
293+
</div>
294+
</EmojiPickerPreviewProvider>
295+
</EmojiPickerCellProvider>
286296
</EmojiPickerProvider>
287297
);
288298
};
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { useMemo, useState } from 'react';
2+
import { fireEvent, render, screen } from '@testing-library/react';
3+
import { EmojiButton } from '../EmojiButton';
4+
import {
5+
type EmojiPickerContextValue,
6+
EmojiPickerProvider,
7+
} from '../../context/EmojiPickerContext';
8+
import { EmojiPickerCellProvider } from '../../context/EmojiPickerCellContext';
9+
import { EmojiPickerPreviewProvider } from '../../context/EmojiPickerPreviewContext';
10+
11+
type Emoji = { skins: { native: string }[] };
12+
13+
const emoji = (id: string, native: string, name = id) => ({
14+
id,
15+
keywords: [],
16+
name,
17+
skins: [{ native, unified: '' }],
18+
version: 1,
19+
});
20+
21+
const STABLE_EMOJI = emoji('grinning', '😀', 'Grinning');
22+
const selectEmoji = vi.fn();
23+
// Called once per EmojiButton render (in its body via resolveNative), so its call count
24+
// is a render counter that survives the resolveNative reference changing (skin tone).
25+
const renderProbe = vi.fn();
26+
const countingResolveNative = (e: Emoji) => {
27+
renderProbe();
28+
return e.skins[0]?.native ?? '';
29+
};
30+
31+
const previewValue = { previewedEmoji: null, setPreviewedEmoji: vi.fn() };
32+
33+
const baseMain = (activeCategoryId: string): EmojiPickerContextValue => ({
34+
activeCategoryId,
35+
categories: [],
36+
isSearching: false,
37+
query: '',
38+
requestScrollToCategory: vi.fn(),
39+
resolveNative: countingResolveNative,
40+
retry: vi.fn(),
41+
scrollTarget: null,
42+
searchResults: null,
43+
selectEmoji,
44+
setActiveCategory: vi.fn(),
45+
setQuery: vi.fn(),
46+
setSkinTone: vi.fn(),
47+
skinToneIndex: 0,
48+
skinTones: [],
49+
status: 'ready',
50+
});
51+
52+
// `resolveNative`/`selectEmoji` live in BOTH the public context (custom grids) and the
53+
// cell context (the default cell); a cell must subscribe to the cold cell context so hot
54+
// state changes never reach it. `scroll-spy` mutates only hot public state; `skin-tone`
55+
// bumps the tone the cell resolver closes over, changing its identity — mirroring Root,
56+
// where resolveNative is a useCallback keyed on skinToneIndex.
57+
function Harness() {
58+
const [hotCategory, setHotCategory] = useState('people');
59+
const [tone, setTone] = useState(0);
60+
const mainValue = useMemo(() => baseMain(hotCategory), [hotCategory]);
61+
const cellResolveNative = useMemo(
62+
() => (e: Emoji) => {
63+
renderProbe();
64+
return e.skins[tone]?.native ?? e.skins[0]?.native ?? '';
65+
},
66+
[tone],
67+
);
68+
const cellValue = useMemo(
69+
() => ({ resolveNative: cellResolveNative, selectEmoji }),
70+
[cellResolveNative],
71+
);
72+
73+
return (
74+
<EmojiPickerProvider value={mainValue}>
75+
<EmojiPickerCellProvider value={cellValue}>
76+
<EmojiPickerPreviewProvider value={previewValue}>
77+
<button
78+
onClick={() => setHotCategory((c) => (c === 'people' ? 'flags' : 'people'))}
79+
type='button'
80+
>
81+
scroll-spy
82+
</button>
83+
<button onClick={() => setTone((v) => v + 1)} type='button'>
84+
skin-tone
85+
</button>
86+
<EmojiButton emoji={STABLE_EMOJI} />
87+
</EmojiPickerPreviewProvider>
88+
</EmojiPickerCellProvider>
89+
</EmojiPickerProvider>
90+
);
91+
}
92+
93+
describe('EmojiButton context subscription', () => {
94+
beforeEach(() => {
95+
renderProbe.mockClear();
96+
});
97+
98+
it('does not re-render when hot picker state (scroll-spy active category) changes', () => {
99+
render(<Harness />);
100+
const initial = renderProbe.mock.calls.length;
101+
expect(initial).toBeGreaterThan(0);
102+
103+
fireEvent.click(screen.getByRole('button', { name: 'scroll-spy' }));
104+
105+
// The cell reads none of the hot fields, so a scroll-spy update must not re-render it.
106+
expect(renderProbe).toHaveBeenCalledTimes(initial);
107+
});
108+
109+
it('re-renders when the resolved glyph changes (skin tone)', () => {
110+
render(<Harness />);
111+
const initial = renderProbe.mock.calls.length;
112+
113+
fireEvent.click(screen.getByRole('button', { name: 'skin-tone' }));
114+
115+
expect(renderProbe.mock.calls.length).toBeGreaterThan(initial);
116+
});
117+
});

src/plugins/Emojis/components/__tests__/EmojiGrid.test.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ const { ctx, preview } = vi.hoisted(() => ({
5050
vi.mock('../../context/EmojiPickerContext', () => ({
5151
useEmojiPickerContext: () => ctx,
5252
}));
53+
vi.mock('../../context/EmojiPickerCellContext', () => ({
54+
useEmojiPickerCellContext: () => ctx,
55+
}));
5356
vi.mock('../../context/EmojiPickerPreviewContext', () => ({
5457
useEmojiPickerPreviewContext: () => preview,
5558
}));
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { createContext, useContext } from 'react';
2+
import type { EmojiDataEmoji } from '../data';
3+
4+
export type EmojiPickerCellContextValue = {
5+
/** Resolves an emoji's native glyph for the active skin tone. */
6+
resolveNative: (emoji: EmojiDataEmoji) => string;
7+
/** Report the user's emoji choice back to the SDK (inserts it). */
8+
selectEmoji: (emoji: EmojiDataEmoji) => void;
9+
};
10+
11+
const EmojiPickerCellContext = createContext<EmojiPickerCellContextValue | undefined>(
12+
undefined,
13+
);
14+
15+
export const EmojiPickerCellProvider = EmojiPickerCellContext.Provider;
16+
17+
/**
18+
* Internal (NOT exported from the `emojis` entry): the cold subset of the picker
19+
* contract the default emoji cell needs — resolving a glyph and reporting a selection.
20+
* The grid can mount hundreds of cells, and React context has no partial subscription:
21+
* a cell reading the public `EmojiPickerContext` would re-render on every change to its
22+
* hot fields (scroll-spy `activeCategoryId`, the live `query`, `scrollTarget`), even
23+
* though the cell reads none of them. These two values change only when the skin tone or
24+
* `onEmojiSelect` changes — the cases where a cell genuinely must re-render — so the
25+
* default `EmojiButton` subscribes here instead. The same values remain on the public
26+
* `EmojiPickerContext` for custom grid slots.
27+
*/
28+
export const useEmojiPickerCellContext = () => {
29+
const context = useContext(EmojiPickerCellContext);
30+
if (!context) {
31+
throw new Error(
32+
'Emoji picker cells must be rendered within a StreamEmojiPicker.Root.',
33+
);
34+
}
35+
return context;
36+
};

0 commit comments

Comments
 (0)