emoji-picker-react 5.0.0 → 5.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/dist/DomUtils/classNames.d.ts +0 -1
  2. package/dist/DomUtils/keyboardNavigation.d.ts +10 -2
  3. package/dist/DomUtils/selectors.d.ts +2 -2
  4. package/dist/data/index.js +1 -1
  5. package/dist/data/skinToneVariations.js +2 -2
  6. package/dist/data/skinToneVariations.ts +8 -7
  7. package/dist/esm/chunks/chunk-7ZGTUZFB.mjs +8021 -12
  8. package/dist/esm/chunks/chunk-ACXAQEKG.mjs +149 -0
  9. package/dist/esm/chunks/chunk-F2KS7DCA.mjs +6735 -0
  10. package/dist/esm/chunks/chunk-L5W4PFRJ.mjs +14 -0
  11. package/dist/esm/chunks/chunk-LO4TPPLH.mjs +32 -42
  12. package/dist/esm/chunks/emojis-6L3GRBAP.mjs +5 -7
  13. package/dist/esm/data.mjs +9 -16
  14. package/dist/esm/index.mjs +230 -340
  15. package/dist/esm/primitives/index.mjs +6 -65
  16. package/dist/hooks/preloadEmoji.d.ts +0 -4
  17. package/dist/hooks/useCategoryHeight.d.ts +2 -1
  18. package/dist/hooks/useEmojiVirtualization.d.ts +1 -0
  19. package/dist/index.js +1 -1
  20. package/dist/primitives/index.js +1 -1
  21. package/dist/virtualization/virtualizationHelpers.d.ts +2 -0
  22. package/llms-full.txt +6 -3
  23. package/package.json +1 -1
  24. package/src/DomUtils/classNames.ts +0 -4
  25. package/src/DomUtils/elementPositionInRow.ts +2 -2
  26. package/src/DomUtils/keyboardNavigation.ts +74 -10
  27. package/src/DomUtils/scrollTo.ts +2 -2
  28. package/src/DomUtils/selectors.ts +10 -16
  29. package/src/components/body/EmojiCategory.tsx +1 -1
  30. package/src/components/body/EmojiList.tsx +54 -23
  31. package/src/components/context/PickerDataContext.tsx +5 -1
  32. package/src/components/header/Search/Search.tsx +4 -2
  33. package/src/components/main/defaultAppearance.tsx +2 -1
  34. package/src/data/skinToneVariations.ts +8 -7
  35. package/src/data-core/pickerData.ts +1 -3
  36. package/src/data-core/prepare.ts +20 -35
  37. package/src/dataUtils/emojiSelectors.ts +2 -1
  38. package/src/dataUtils/parseNativeEmoji.ts +3 -4
  39. package/src/hooks/preloadEmoji.ts +1 -46
  40. package/src/hooks/useActiveCategoryScrollDetection.ts +22 -19
  41. package/src/hooks/useCategoryHeight.ts +38 -6
  42. package/src/hooks/useDisallowedEmojis.ts +18 -13
  43. package/src/hooks/useEmojiVirtualization.tsx +93 -67
  44. package/src/hooks/useFilter.ts +10 -8
  45. package/src/hooks/useIsEmojiHidden.ts +16 -12
  46. package/src/hooks/useKeyboardNavigation.ts +104 -17
  47. package/src/primitives/structuralStyles.tsx +3 -0
  48. package/src/primitives/tokens.ts +1 -0
  49. package/src/virtualization/virtualizationHelpers.ts +9 -2
  50. package/dist/esm/chunks/chunk-46BUDQLE.mjs +0 -228
  51. package/dist/esm/chunks/chunk-FQIJ4HBW.mjs +0 -25
  52. package/dist/esm/chunks/chunk-KQPOIRSC.mjs +0 -9305
@@ -1,52 +1,7 @@
1
1
  import { GetEmojiUrl } from '../components/emoji/BaseEmojiProps';
2
2
  import { DataEmoji } from '../dataUtils/DataTypes';
3
3
  import { emojiUnified, emojiVariations } from '../dataUtils/emojiUtils';
4
- import { EmojiStyle, EmojiStyleValue } from '../types/exposedTypes';
5
- import { Dimensions } from '../virtualization/virtualizationHelpers';
6
-
7
- // eslint-disable-next-line max-params
8
- export function preloadEmojiIfNeeded(
9
- emoji: undefined | DataEmoji,
10
- emojiStyles: EmojiStyleValue,
11
- scrollTop: number,
12
- clientHeight: number,
13
- topOffset: number,
14
- style: { top: number } | undefined,
15
- dimensions: Dimensions,
16
- getEmojiUrl: GetEmojiUrl,
17
- ): void {
18
- if (!emoji) {
19
- return;
20
- }
21
-
22
- if (emojiStyles === EmojiStyle.NATIVE) {
23
- return;
24
- }
25
-
26
- const unified = emojiUnified(emoji);
27
-
28
- if (preloadedEmojs.has(unified)) {
29
- return;
30
- }
31
-
32
- // preload only if the emoji is a few rows below the viewport
33
- if (!style || !dimensions) {
34
- return;
35
- }
36
-
37
- setTimeout(() => {
38
- const emojiTop = topOffset + style.top;
39
- const viewportBottom = scrollTop + clientHeight;
40
-
41
- const isJustBelowViewport =
42
- emojiTop >= viewportBottom &&
43
- emojiTop < viewportBottom + dimensions.emojiSize * 2;
44
-
45
- if (isJustBelowViewport) {
46
- preloadEmoji(getEmojiUrl, emoji, emojiStyles);
47
- }
48
- });
49
- }
4
+ import { EmojiStyleValue } from '../types/exposedTypes';
50
5
 
51
6
  export function preloadEmoji(
52
7
  getEmojiUrl: GetEmojiUrl,
@@ -1,9 +1,12 @@
1
1
  import { useEffect } from 'react';
2
2
 
3
3
  import { categoryNameFromDom } from '../DomUtils/categoryNameFromDom';
4
- import { asSelectors, ClassNames } from '../DomUtils/classNames';
4
+ import { ClassNames } from '../DomUtils/classNames';
5
5
  import { useBodyRef } from '../components/context/ElementRefContext';
6
- import { CategoriesConfig } from '../config/categoryConfig';
6
+ import {
7
+ CategoriesConfig,
8
+ categoryIdFromCategoryConfig,
9
+ } from '../config/categoryConfig';
7
10
 
8
11
  export function useActiveCategoryScrollDetection({
9
12
  setActiveCategory,
@@ -22,9 +25,11 @@ export function useActiveCategoryScrollDetection({
22
25
  const BodyRef = useBodyRef();
23
26
 
24
27
  useEffect(() => {
25
- const visibleCategories = new Map<string, number>();
26
- const intersectingCategories = new Map<string, boolean>();
27
- const targets = new Map<string, Element>();
28
+ if (typeof IntersectionObserver === 'undefined') {
29
+ setVisibleCategories(categories.map(categoryIdFromCategoryConfig));
30
+ return;
31
+ }
32
+ const observed = new Map<string, IntersectionObserverEntry>();
28
33
  const bodyRef = BodyRef.current;
29
34
  const observer = new IntersectionObserver(
30
35
  (entries) => {
@@ -39,31 +44,31 @@ export function useActiveCategoryScrollDetection({
39
44
  continue;
40
45
  }
41
46
 
42
- visibleCategories.set(id, entry.intersectionRatio);
43
- intersectingCategories.set(id, entry.isIntersecting);
44
- targets.set(id, entry.target);
47
+ observed.set(id, entry);
45
48
  }
46
49
 
47
- const ratios = Array.from(visibleCategories);
50
+ const ratios = Array.from(observed);
48
51
  const visibleCats = ratios
49
- .filter(([id, ratio]) => ratio > 0 || intersectingCategories.get(id))
52
+ .filter(
53
+ ([, entry]) => entry.intersectionRatio > 0 || entry.isIntersecting,
54
+ )
50
55
  .map(([id]) => id);
51
56
 
52
57
  setVisibleCategories(visibleCats);
53
58
  const lastCategory = ratios[ratios.length - 1];
54
59
 
55
- if (lastCategory?.[1] == 1) {
60
+ if (lastCategory?.[1].intersectionRatio === 1) {
56
61
  return setActiveCategory(lastCategory[0]);
57
62
  }
58
63
 
59
64
  const viewportTop = bodyRef.getBoundingClientRect().top;
60
- for (const [id, ratio] of ratios) {
65
+ for (const [id, entry] of ratios) {
61
66
  // Fractional offsets can leave less than a pixel of the preceding
62
67
  // section intersecting after a jump. It must not steal the tab
63
68
  // highlight from the section aligned with the viewport top.
64
69
  if (
65
- ratio &&
66
- targets.get(id)!.getBoundingClientRect().bottom > viewportTop + 1
70
+ entry.intersectionRatio &&
71
+ entry.target.getBoundingClientRect().bottom > viewportTop + 1
67
72
  ) {
68
73
  setActiveCategory(id);
69
74
  break;
@@ -75,11 +80,9 @@ export function useActiveCategoryScrollDetection({
75
80
  threshold: [0, 1],
76
81
  },
77
82
  );
78
- bodyRef
79
- ?.querySelectorAll(asSelectors(ClassNames.category))
80
- .forEach((el) => {
81
- observer.observe(el);
82
- });
83
+ bodyRef?.querySelectorAll(`.${ClassNames.category}`).forEach((el) => {
84
+ observer.observe(el);
85
+ });
83
86
 
84
87
  return () => {
85
88
  observer.disconnect();
@@ -1,11 +1,12 @@
1
1
  import * as React from 'react';
2
2
 
3
- import { asSelectors, ClassNames } from '../DomUtils/classNames';
3
+ import { ClassNames } from '../DomUtils/classNames';
4
4
  import {
5
5
  elementBorderBoxHeight,
6
6
  EmojiButtonSelector,
7
7
  } from '../DomUtils/selectors';
8
8
  import {
9
+ useBodyRef,
9
10
  useEmojiListRef,
10
11
  usePickerMainRef,
11
12
  } from '../components/context/ElementRefContext';
@@ -33,7 +34,7 @@ function firstVisibleContentWidth(
33
34
  fallbackWidth: number,
34
35
  ): number {
35
36
  const contentBoxes = listEl.querySelectorAll(
36
- asSelectors(ClassNames.categoryContent),
37
+ `.${ClassNames.categoryContent}`,
37
38
  );
38
39
  for (const box of Array.from(contentBoxes)) {
39
40
  const width = (box as HTMLElement).clientWidth;
@@ -60,15 +61,20 @@ function trackColumnCount(
60
61
  previousRef.current = emojisPerRow;
61
62
  }
62
63
 
63
- export function useCategoryHeight(emojiCount: number):
64
+ export function useCategoryHeight(
65
+ emojiCount: number,
66
+ ready = true,
67
+ ):
64
68
  | {
65
69
  categoryHeight: number;
66
70
  emojisPerRow: number;
67
71
  emojiSize: number;
68
72
  rowWidth: number;
73
+ viewportHeight?: number;
69
74
  }
70
75
  | undefined {
71
76
  const EmojiListRef = useEmojiListRef();
77
+ const BodyRef = useBodyRef();
72
78
  const [isReactionsMode] = useReactionsModeState();
73
79
  const PickerMainRef = usePickerMainRef();
74
80
  const emojiSizeRef = React.useRef<number | undefined>(undefined);
@@ -82,6 +88,7 @@ export function useCategoryHeight(emojiCount: number):
82
88
  emojisPerRow: number;
83
89
  emojiSize: number;
84
90
  rowWidth: number;
91
+ viewportHeight?: number;
85
92
  }>();
86
93
 
87
94
  // Helper to compute and store dimensions based on current DOM
@@ -99,8 +106,8 @@ export function useCategoryHeight(emojiCount: number):
99
106
  emojiSizeRef.current = measured;
100
107
  }
101
108
  const emojiSize =
102
- emojiSizeFromContext ||
103
109
  measured ||
110
+ emojiSizeFromContext ||
104
111
  emojiSizeRef.current ||
105
112
  EMOJI_SIZE_DEFAULT;
106
113
  const pickerWidth = listEl.clientWidth;
@@ -113,13 +120,36 @@ export function useCategoryHeight(emojiCount: number):
113
120
  const rowCount = Math.ceil(emojiCount / emojisPerRow);
114
121
  const categoryHeight = rowCount * emojiSize;
115
122
 
123
+ const next = {
124
+ categoryHeight,
125
+ emojisPerRow,
126
+ emojiSize,
127
+ rowWidth,
128
+ // Virtualization reads the viewport height during render, so a picker
129
+ // that mounts collapsed (reactions first) must re-render once it opens.
130
+ viewportHeight: BodyRef.current?.clientHeight,
131
+ };
132
+
116
133
  trackColumnCount(emojisPerRowRef, emojisPerRow, registry);
117
- setDimensions({ categoryHeight, emojisPerRow, emojiSize, rowWidth });
118
- }, [EmojiListRef, emojiCount, emojiSizeFromContext, registry, columns]);
134
+ // Returning `previous` when nothing changed is what stops the layout
135
+ // effect below, which depends on `dimensions`, from looping forever.
136
+ // Both objects come from the literal above, so their keys share an order.
137
+ setDimensions((previous) =>
138
+ JSON.stringify(previous) === JSON.stringify(next) ? previous : next,
139
+ );
140
+ }, [
141
+ EmojiListRef,
142
+ BodyRef,
143
+ emojiCount,
144
+ emojiSizeFromContext,
145
+ registry,
146
+ columns,
147
+ ]);
119
148
 
120
149
  // Restore measured row heights in the same commit as the filtered
121
150
  // contents. A passive effect leaves one frame of stale offsets that
122
151
  // category navigation can observe immediately after clearing search.
152
+ // Recheck an estimated size when positioned/visible cells first mount.
123
153
  useIsomorphicLayoutEffect(() => {
124
154
  computeAndSetDimensions();
125
155
  }, [
@@ -127,6 +157,8 @@ export function useCategoryHeight(emojiCount: number):
127
157
  isReactionsMode,
128
158
  computeAndSetDimensions,
129
159
  visibleCategories.length,
160
+ dimensions,
161
+ ready,
130
162
  ]);
131
163
 
132
164
  // Fluid layouts (width: 100%, sheets, docks) resize without a CSS
@@ -1,3 +1,5 @@
1
+ import { useCallback } from 'react';
2
+
1
3
  import {
2
4
  useActiveSkinToneState,
3
5
  useNativeEmojiSupport,
@@ -20,17 +22,20 @@ export function useIsEmojiDisallowed() {
20
22
  const unicodeToHide = useUnicodeToHide();
21
23
  const [activeSkinTone] = useActiveSkinToneState();
22
24
 
23
- return function isEmojiDisallowed(
24
- emoji: DataEmoji,
25
- renderedUnified = emojiUnified(emoji, activeSkinTone),
26
- ) {
27
- if (unicodeToHide.has(unifiedWithoutSkinTone(emojiUnified(emoji))))
28
- return true;
29
- if (isCustomEmoji(emoji)) return false;
30
- const version = addedIn(emoji);
31
- return (
32
- version > cap ||
33
- !isNativeEmojiSupported(nativeSupport, renderedUnified, version)
34
- );
35
- };
25
+ return useCallback(
26
+ function isEmojiDisallowed(
27
+ emoji: DataEmoji,
28
+ renderedUnified = emojiUnified(emoji, activeSkinTone),
29
+ ) {
30
+ if (unicodeToHide.has(unifiedWithoutSkinTone(emojiUnified(emoji))))
31
+ return true;
32
+ if (isCustomEmoji(emoji)) return false;
33
+ const version = addedIn(emoji);
34
+ return (
35
+ version > cap ||
36
+ !isNativeEmojiSupported(nativeSupport, renderedUnified, version)
37
+ );
38
+ },
39
+ [activeSkinTone, unicodeToHide, cap, nativeSupport],
40
+ );
36
41
  }
@@ -13,17 +13,20 @@ import {
13
13
  useLazyLoadEmojisConfig,
14
14
  useSkinTonesDisabledConfig,
15
15
  } from '../config/useConfig';
16
- import { DataEmojis } from '../dataUtils/DataTypes';
16
+ import { DataEmoji, DataEmojis } from '../dataUtils/DataTypes';
17
17
  import { emojiUnified } from '../dataUtils/emojiUtils';
18
+ import { EmojiStyle } from '../types/exposedTypes';
18
19
  import {
19
20
  getEmojiPositionStyle,
20
21
  shouldVirtualize,
22
+ isJustBelowViewport,
21
23
  } from '../virtualization/virtualizationHelpers';
22
24
 
23
- import { preloadEmojiIfNeeded } from './preloadEmoji';
25
+ import { preloadEmoji } from './preloadEmoji';
24
26
  import { useCategoryHeight } from './useCategoryHeight';
25
27
  import { useIsEmojiDisallowed } from './useDisallowedEmojis';
26
28
  import { useIsEmojiHidden } from './useIsEmojiHidden';
29
+ import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect';
27
30
 
28
31
  export function useEmojiVirtualization({
29
32
  categoryEmojis,
@@ -53,31 +56,42 @@ export function useEmojiVirtualization({
53
56
  // Native glyphs checked exactly: the cells this pass renders (visible
54
57
  // rows plus virtualization's margin rows), nothing else.
55
58
  const probe: string[] = [];
56
-
57
- let virtualizedCounter = 0;
59
+ const preloads: DataEmoji[] = [];
60
+ const [committed, setCommitted] = React.useState(false);
58
61
 
59
62
  // A section shows each rendered emoji once. Recents can hold the same
60
63
  // emoji under two tones (e.g. neutral and medium); the active tone
61
64
  // renders both as one identity, which must not be listed or keyed twice.
62
- const rendered = new Set<string>();
63
- const emojisToPush = categoryEmojis.filter((emoji) => {
64
- const isDisallowed = isEmojiDisallowed(emoji);
65
- const { hidden } = isEmojiHidden(emoji);
65
+ const emojisToPush = React.useMemo(() => {
66
+ const rendered = new Set<string>();
67
+ return categoryEmojis.filter((emoji) => {
68
+ const isDisallowed = isEmojiDisallowed(emoji);
69
+ const { hidden } = isEmojiHidden(emoji);
66
70
 
67
- if (hidden || isDisallowed) {
68
- return false;
69
- }
70
- const unified = emojiUnified(emoji, activeSkinTone);
71
- if (rendered.has(unified)) {
72
- return false;
73
- }
74
- rendered.add(unified);
75
- return true;
76
- });
71
+ if (hidden || isDisallowed) {
72
+ return false;
73
+ }
74
+ const unified = emojiUnified(emoji, activeSkinTone);
75
+ if (rendered.has(unified)) {
76
+ return false;
77
+ }
78
+ rendered.add(unified);
79
+ return true;
80
+ });
81
+ }, [categoryEmojis, activeSkinTone, isEmojiHidden, isEmojiDisallowed]);
77
82
 
78
- const dimensions = useCategoryHeight(emojisToPush.length);
83
+ const dimensions = useCategoryHeight(
84
+ emojisToPush.length,
85
+ isCategoryVisible && positioned,
86
+ );
79
87
 
88
+ // Guarantee a render even when a hidden picker has no measurement cell.
89
+ // A passive update avoids flushing all observer effects before first paint.
80
90
  useEffect(() => {
91
+ if (!dimensions) setCommitted(true);
92
+ }, [dimensions]);
93
+
94
+ useIsomorphicLayoutEffect(() => {
81
95
  if (dimensions) {
82
96
  onHeightReady(dimensions.categoryHeight);
83
97
  }
@@ -94,61 +108,73 @@ export function useEmojiVirtualization({
94
108
  dimensions,
95
109
  });
96
110
 
97
- const emojis = emojisToPush.reduce((accumulator, emoji, index) => {
98
- const unified = emojiUnified(emoji, activeSkinTone);
99
- const style = getEmojiPositionStyle(dimensions, index);
100
-
101
- if (isVirtualized(style)) {
102
- virtualizedCounter++;
103
- preloadEmojiIfNeeded(
104
- emoji,
105
- emojiStyle,
106
- scrollTop,
107
- BodyRef.current?.clientHeight ?? 0,
108
- topOffset,
109
- style,
110
- dimensions,
111
- getEmojiUrl,
112
- );
113
- return accumulator;
114
- }
111
+ const needsPreload = (style: { top: number } | undefined) =>
112
+ emojiStyle !== EmojiStyle.NATIVE &&
113
+ dimensions &&
114
+ style &&
115
+ isJustBelowViewport(
116
+ topOffset + style.top,
117
+ scrollTop + (BodyRef.current?.clientHeight ?? 0),
118
+ dimensions.emojiSize,
119
+ );
115
120
 
116
- if (!isCategoryVisible) {
117
- virtualizedCounter++;
118
- return accumulator;
119
- }
121
+ const waitingForLayout =
122
+ (!committed && !dimensions) ||
123
+ ((!dimensions || !positioned) && BodyRef.current?.clientWidth);
120
124
 
121
- // Wait for layout: an unmeasured pass renders a whole category, and
122
- // while sections above are unmeasured this one renders off-screen
123
- // cells as if it were at the top.
124
- if (dimensions && positioned) probe.push(unified);
125
- accumulator.push(
126
- <ClickableEmoji
127
- showVariations={showVariations}
128
- key={unified}
129
- emoji={emoji}
130
- unified={unified}
131
- emojiStyle={emojiStyle}
132
- lazyLoad={lazyLoadEmojis}
133
- getEmojiUrl={getEmojiUrl}
134
- style={{
135
- ...style,
136
- position: 'absolute',
137
- }}
138
- index={index}
139
- // Grid semantics (issue #508): each emoji is a cell of its
140
- // category row; native button activation is kept.
141
- role="gridcell"
142
- />,
143
- );
144
- return accumulator;
145
- }, [] as ReactNode[]);
125
+ // MeasureEmoji supplies geometry before cells are built. Layout-free
126
+ // environments can render after the first commit without measurements.
127
+ const emojis = waitingForLayout
128
+ ? []
129
+ : emojisToPush.reduce((accumulator, emoji, index) => {
130
+ const style = getEmojiPositionStyle(dimensions, index);
131
+
132
+ if (isVirtualized(style)) {
133
+ if (needsPreload(style)) preloads.push(emoji);
134
+ return accumulator;
135
+ }
136
+
137
+ if (!isCategoryVisible) {
138
+ return accumulator;
139
+ }
140
+
141
+ const unified = emojiUnified(emoji, activeSkinTone);
142
+ // Wait for layout: an unmeasured pass renders a whole category, and
143
+ // while sections above are unmeasured this one renders off-screen
144
+ // cells as if it were at the top.
145
+ if (dimensions && positioned) probe.push(unified);
146
+ accumulator.push(
147
+ <ClickableEmoji
148
+ showVariations={showVariations}
149
+ key={unified}
150
+ emoji={emoji}
151
+ unified={unified}
152
+ emojiStyle={emojiStyle}
153
+ lazyLoad={lazyLoadEmojis}
154
+ getEmojiUrl={getEmojiUrl}
155
+ style={{
156
+ ...style,
157
+ position: 'absolute',
158
+ }}
159
+ index={index}
160
+ // Grid semantics (issue #508): each emoji is a cell of its
161
+ // category row; native button activation is kept.
162
+ role="gridcell"
163
+ />,
164
+ );
165
+ return accumulator;
166
+ }, [] as ReactNode[]);
146
167
 
147
168
  // Every commit: requests for already-checked glyphs are free.
148
169
  useEffect(() => requestNativeProbe(probe));
170
+ // Image requests belong to committed work, never an abandoned render.
171
+ // The proximity and cache checks run synchronously, without per-cell timers.
172
+ useEffect(() => {
173
+ preloads.forEach((emoji) => preloadEmoji(getEmojiUrl, emoji, emojiStyle));
174
+ });
149
175
 
150
176
  return {
151
- virtualizedCounter,
177
+ virtualizedCounter: emojisToPush.length - emojis.length,
152
178
  emojis,
153
179
  dimensions,
154
180
  emojiCount: emojisToPush.length,
@@ -100,13 +100,11 @@ function useApplySearch() {
100
100
  if (!mountedRef.current) {
101
101
  return;
102
102
  }
103
- setSearchTerm(searchTerm ? searchTerm?.toLowerCase() : searchTerm).then(
104
- () => {
105
- if (registry.isCurrent(token)) {
106
- scrollTo(PickerMainRef.current, 0, () => registry.isCurrent(token));
107
- }
108
- },
109
- );
103
+ setSearchTerm(searchTerm).then(() => {
104
+ if (registry.isCurrent(token)) {
105
+ scrollTo(PickerMainRef.current, 0, () => registry.isCurrent(token));
106
+ }
107
+ });
110
108
  });
111
109
  };
112
110
  }
@@ -115,7 +113,11 @@ export function useIsEmojiFiltered(): (unified: string) => boolean {
115
113
  const { current: filter } = useFilterRef();
116
114
  const [searchTerm] = useSearchTermState();
117
115
 
118
- return (unified) => isEmojiFilteredBySearchTerm(unified, filter, searchTerm);
116
+ return React.useCallback(
117
+ (unified: string) =>
118
+ isEmojiFilteredBySearchTerm(unified, filter, searchTerm),
119
+ [filter, searchTerm],
120
+ );
119
121
  }
120
122
 
121
123
  export function isEmojiFilteredBySearchTerm(
@@ -1,3 +1,5 @@
1
+ import { useCallback } from 'react';
2
+
1
3
  import {
2
4
  useActiveSkinToneState,
3
5
  useEmojisThatFailedToLoadState,
@@ -9,21 +11,23 @@ import { useIsEmojiFiltered } from './useFilter';
9
11
 
10
12
  export function useIsEmojiHidden(): (emoji: DataEmoji) => IsHiddenReturn {
11
13
  const [activeSkinTone] = useActiveSkinToneState();
12
- const [emojisThatFailedToLoad] = useEmojisThatFailedToLoadState();
14
+ // The tuple changes when its Root-local Set is mutated by an image error.
15
+ const failures = useEmojisThatFailedToLoadState();
13
16
  const isEmojiFiltered = useIsEmojiFiltered();
14
17
 
15
- return (emoji: DataEmoji): IsHiddenReturn => {
16
- const failedToLoad = emojisThatFailedToLoad.has(
17
- emojiUnified(emoji, activeSkinTone),
18
- );
19
- const filteredOut = isEmojiFiltered(emojiCanonicalUnified(emoji));
18
+ return useCallback(
19
+ (emoji: DataEmoji): IsHiddenReturn => {
20
+ const failedToLoad = failures[0].has(emojiUnified(emoji, activeSkinTone));
21
+ const filteredOut = isEmojiFiltered(emojiCanonicalUnified(emoji));
20
22
 
21
- return {
22
- failedToLoad,
23
- filteredOut,
24
- hidden: failedToLoad || filteredOut,
25
- };
26
- };
23
+ return {
24
+ failedToLoad,
25
+ filteredOut,
26
+ hidden: failedToLoad || filteredOut,
27
+ };
28
+ },
29
+ [activeSkinTone, failures, isEmojiFiltered],
30
+ );
27
31
  }
28
32
 
29
33
  type IsHiddenReturn = {