Skip to content

Commit 2913f0f

Browse files
committed
fix: replace MutationObserver with layout effects for emoji selection
Use useLayoutEffect to resolve the selected emoji character and useEffect with rAF polling as a fallback for async cases (initial data load, virtualised rows). The Emoji render function reads selectedChar from state to apply data-selected, which forces frimousse's memoized cells to re-render when the selection changes.
1 parent 74b13d9 commit 2913f0f

1 file changed

Lines changed: 76 additions & 71 deletions

File tree

packages/react/src/components/SuggestionMenu/EmojiPicker/InlineEmojiPicker.tsx

Lines changed: 76 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { EmojiPicker } from "frimousse";
2-
import { useEffect, useRef, useState } from "react";
2+
import { useEffect, useLayoutEffect, useRef, useState } from "react";
33

44
import { useBlockNoteEditor } from "../../../hooks/useBlockNoteEditor.js";
55
import { useEditorDOMElement } from "../../../hooks/useEditorDomElement.js";
@@ -51,6 +51,26 @@ function findButtonAtIndex(
5151
return buttons[localIndex] ?? null;
5252
}
5353

54+
function scrollViewportTo(root: HTMLElement, selectedIndex: number) {
55+
const viewport = root.querySelector<HTMLElement>("[frimousse-viewport]");
56+
if (!viewport) {
57+
return;
58+
}
59+
60+
const rowHeight = getRowHeight(root);
61+
const headerHeight = getCategoryHeaderHeight(root);
62+
const targetRow = Math.floor(selectedIndex / COLUMNS);
63+
const estimatedY = targetRow * rowHeight;
64+
const viewportHeight = viewport.clientHeight;
65+
66+
if (
67+
estimatedY < viewport.scrollTop ||
68+
estimatedY + rowHeight > viewport.scrollTop + viewportHeight - headerHeight
69+
) {
70+
viewport.scrollTop = Math.max(0, estimatedY - viewportHeight / 3);
71+
}
72+
}
73+
5474
export function InlineEmojiPicker(props: {
5575
query: string;
5676
closeMenu: () => void;
@@ -67,41 +87,27 @@ export function InlineEmojiPicker(props: {
6787
const [selectedIndex, setSelectedIndex] = useState(0);
6888
const [selectedEmoji, setSelectedEmoji] = useState({ emoji: "", label: "" });
6989

70-
const selectedIndexRef = useRef(selectedIndex);
71-
selectedIndexRef.current = selectedIndex;
72-
7390
useEffect(() => {
7491
setSelectedIndex(0);
7592
}, [props.query]);
7693

77-
// Apply data-selected and scroll to the target emoji.
78-
// Runs via MutationObserver because frimousse virtualises rows and may
79-
// re-render them at any time, destroying previous DOM mutations.
80-
useEffect(() => {
94+
// Resolve the emoji character at selectedIndex. When the button is already
95+
// in the DOM (common case: arrow keys within the visible viewport), the
96+
// layout effect finds it immediately. The state change triggers a
97+
// synchronous re-render before paint, so the Emoji component applies
98+
// data-selected without a visible flash.
99+
useLayoutEffect(() => {
81100
const root = rootRef.current;
82101
if (!root) {
83102
return;
84103
}
85104

86-
const applySelection = () => {
87-
const idx = selectedIndexRef.current;
88-
const viewport = root.querySelector<HTMLElement>("[frimousse-viewport]");
89-
if (!viewport) {
90-
return;
91-
}
92-
93-
const btn = findButtonAtIndex(root, idx);
94-
if (!btn) {
95-
return;
96-
}
97-
98-
if (!btn.hasAttribute("data-selected")) {
99-
for (const el of root.querySelectorAll("[data-selected]")) {
100-
el.removeAttribute("data-selected");
101-
}
102-
btn.setAttribute("data-selected", "");
103-
}
105+
// Scroll first so frimousse can virtualise the target row.
106+
scrollViewportTo(root, selectedIndex);
104107

108+
const btn = findButtonAtIndex(root, selectedIndex);
109+
if (btn) {
110+
btn.scrollIntoView({ block: "nearest" });
105111
const emoji = btn.textContent ?? "";
106112
const label = btn.getAttribute("aria-label") ?? "";
107113
setSelectedEmoji((prev) => {
@@ -110,61 +116,55 @@ export function InlineEmojiPicker(props: {
110116
}
111117
return { emoji, label };
112118
});
113-
};
114-
115-
applySelection();
116-
117-
let raf = 0;
118-
const observer = new MutationObserver(() => {
119-
cancelAnimationFrame(raf);
120-
raf = requestAnimationFrame(applySelection);
121-
});
122-
observer.observe(root, { childList: true, subtree: true });
123-
return () => {
124-
observer.disconnect();
125-
cancelAnimationFrame(raf);
126-
};
127-
}, [resolvedLocale]);
119+
}
120+
}, [selectedIndex, props.query, resolvedLocale]);
128121

129-
// Scroll viewport when selection changes.
122+
// Fallback for when the target button isn't in the DOM during the layout
123+
// effect — either because frimousse is still loading emoji data on first
124+
// mount, because a search query is being processed via requestIdleCallback,
125+
// or because the viewport scroll from the layout effect triggered
126+
// virtualisation and the new rows haven't rendered yet.
127+
// Polls with requestAnimationFrame until the button appears.
130128
useEffect(() => {
131129
const root = rootRef.current;
132130
if (!root) {
133131
return;
134132
}
135-
const viewport = root.querySelector<HTMLElement>("[frimousse-viewport]");
136-
if (!viewport) {
133+
134+
const btn = findButtonAtIndex(root, selectedIndex);
135+
if (btn) {
136+
// Button already handled by the layout effect.
137137
return;
138138
}
139139

140-
const rowHeight = getRowHeight(root);
141-
const headerHeight = getCategoryHeaderHeight(root);
142-
const targetRow = Math.floor(selectedIndex / COLUMNS);
143-
const estimatedY = targetRow * rowHeight;
144-
const viewportHeight = viewport.clientHeight;
140+
// Ensure viewport is scrolled to the target area.
141+
scrollViewportTo(root, selectedIndex);
145142

146-
if (
147-
estimatedY < viewport.scrollTop ||
148-
estimatedY + rowHeight >
149-
viewport.scrollTop + viewportHeight - headerHeight
150-
) {
151-
viewport.scrollTop = Math.max(0, estimatedY - viewportHeight / 3);
152-
}
143+
let cancelled = false;
144+
let frameId = 0;
153145

154-
// Clear old selection and apply new one.
155-
for (const el of root.querySelectorAll("[data-selected]")) {
156-
el.removeAttribute("data-selected");
157-
}
158-
const btn = findButtonAtIndex(root, selectedIndex);
159-
if (btn) {
160-
btn.setAttribute("data-selected", "");
161-
btn.scrollIntoView({ block: "nearest" });
162-
setSelectedEmoji({
163-
emoji: btn.textContent ?? "",
164-
label: btn.getAttribute("aria-label") ?? "",
165-
});
166-
}
167-
}, [selectedIndex, props.query]);
146+
const poll = () => {
147+
if (cancelled) {
148+
return;
149+
}
150+
const btn = findButtonAtIndex(root, selectedIndex);
151+
if (btn) {
152+
btn.scrollIntoView({ block: "nearest" });
153+
setSelectedEmoji({
154+
emoji: btn.textContent ?? "",
155+
label: btn.getAttribute("aria-label") ?? "",
156+
});
157+
return;
158+
}
159+
frameId = requestAnimationFrame(poll);
160+
};
161+
162+
frameId = requestAnimationFrame(poll);
163+
return () => {
164+
cancelled = true;
165+
cancelAnimationFrame(frameId);
166+
};
167+
}, [selectedIndex, props.query, resolvedLocale]);
168168

169169
useEffect(() => {
170170
const handleKeyDown = (event: KeyboardEvent) => {
@@ -221,6 +221,7 @@ export function InlineEmojiPicker(props: {
221221

222222
const frimousseLocale = resolvedLocale as any;
223223
const placeholder = `${i18n?.search ?? "Search"}…`;
224+
const selectedChar = selectedEmoji.emoji;
224225

225226
return (
226227
<EmojiPicker.Root
@@ -260,7 +261,11 @@ export function InlineEmojiPicker(props: {
260261
</div>
261262
),
262263
Emoji: ({ emoji, ...emojiProps }) => (
263-
<button className="bn-frimousse-emoji" {...emojiProps}>
264+
<button
265+
className="bn-frimousse-emoji"
266+
data-selected={emoji.emoji === selectedChar ? "" : undefined}
267+
{...emojiProps}
268+
>
264269
{emoji.emoji}
265270
</button>
266271
),

0 commit comments

Comments
 (0)