Fix Slate crash after inserting emoji from picker/autocomplete

This commit is contained in:
2026-07-13 02:40:02 +10:00
parent b711646b58
commit 687f139719
5 changed files with 53 additions and 21 deletions

View File

@@ -82,13 +82,21 @@ function RenderEmoticonElement({
const selected = useSelected(); const selected = useSelected();
const focused = useFocused(); const focused = useFocused();
// Void inline: attributes + contentEditable={false} on the same root, children
// as a sibling of the visual (not nested inside another non-editable span).
// Nesting {children} under an inner contentEditable={false} breaks Slate's
// DOM↔node map and crashes ReactEditor.focus after insert.
return ( return (
<span className={css.EmoticonBase} {...attributes}> <span
{...attributes}
contentEditable={false}
className={css.EmoticonBase}
style={{ userSelect: 'none' }}
>
<span <span
className={css.Emoticon({ className={css.Emoticon({
focus: selected && focused, focus: selected && focused,
})} })}
contentEditable={false}
> >
{element.key.startsWith('mxc://') ? ( {element.key.startsWith('mxc://') ? (
<img <img
@@ -99,8 +107,8 @@ function RenderEmoticonElement({
) : ( ) : (
element.key element.key
)} )}
{children}
</span> </span>
{children}
</span> </span>
); );
} }

View File

@@ -1,4 +1,5 @@
import { BasePoint, BaseRange, Editor, Element, Point, Range, Text, Transforms } from 'slate'; import { BasePoint, BaseRange, Editor, Element, Point, Range, Text, Transforms } from 'slate';
import { ReactEditor } from 'slate-react';
import { BlockType, MarkType } from './types'; import { BlockType, MarkType } from './types';
import { import {
CommandElement, CommandElement,
@@ -206,6 +207,29 @@ export const moveCursor = (editor: Editor, withSpace?: boolean) => {
if (withSpace) editor.insertText(' '); if (withSpace) editor.insertText(' ');
}; };
/**
* Focus the editor after React has committed DOM for recent transforms.
* Calling ReactEditor.focus immediately after insertNode/insertText (e.g. emoji +
* trailing space) races Slate's DOM map and throws:
* "Cannot resolve a DOM node from Slate node: {"text":" "}"
*/
export const safeFocusEditor = (editor: Editor) => {
const tryFocus = () => {
try {
ReactEditor.focus(editor);
return true;
} catch {
return false;
}
};
// Defer past the current React commit; retry once if the DOM map isn't ready yet.
requestAnimationFrame(() => {
if (tryFocus()) return;
setTimeout(tryFocus, 0);
});
};
interface PointUntilCharOptions { interface PointUntilCharOptions {
match: (char: string) => boolean; match: (char: string) => boolean;
reverse?: boolean; reverse?: boolean;

View File

@@ -1,7 +1,6 @@
import React, { KeyboardEventHandler, useCallback, useRef, useState } from 'react'; import React, { KeyboardEventHandler, useCallback, useRef, useState } from 'react';
import { isKeyHotkey } from 'is-hotkey'; import { isKeyHotkey } from 'is-hotkey';
import { Editor } from 'slate'; import { Editor } from 'slate';
import { ReactEditor } from 'slate-react';
import { IconButton, Line, PopOut } from 'folds'; import { IconButton, Line, PopOut } from 'folds';
import { Icon, Icons } from '../../components/icons'; import { Icon, Icons } from '../../components/icons';
import { import {
@@ -13,6 +12,7 @@ import {
getAutocompleteQuery, getAutocompleteQuery,
createEmoticonElement, createEmoticonElement,
moveCursor, moveCursor,
safeFocusEditor,
} from '../../components/editor'; } from '../../components/editor';
import { EmojiBoard, EmojiBoardTab } from '../../components/emoji-board'; import { EmojiBoard, EmojiBoardTab } from '../../components/emoji-board';
import { UseStateProvider } from '../../components/UseStateProvider'; import { UseStateProvider } from '../../components/UseStateProvider';
@@ -84,7 +84,7 @@ export function ForumChatComposer({
const handleCloseAutocomplete = useCallback(() => { const handleCloseAutocomplete = useCallback(() => {
setAutocompleteQuery(undefined); setAutocompleteQuery(undefined);
ReactEditor.focus(editor); safeFocusEditor(editor);
}, [editor]); }, [editor]);
return ( return (
@@ -139,7 +139,7 @@ export function ForumChatComposer({
requestClose={() => { requestClose={() => {
setEmojiBoardTab((t) => { setEmojiBoardTab((t) => {
if (t) { if (t) {
if (!mobileOrTablet()) ReactEditor.focus(editor); if (!mobileOrTablet()) safeFocusEditor(editor);
return undefined; return undefined;
} }
return t; return t;

View File

@@ -10,7 +10,6 @@ import React, {
import { useAtom, useAtomValue } from 'jotai'; import { useAtom, useAtomValue } from 'jotai';
import { isKeyHotkey } from 'is-hotkey'; import { isKeyHotkey } from 'is-hotkey';
import { EventType, IContent, MsgType, RelationType, Room } from 'matrix-js-sdk'; import { EventType, IContent, MsgType, RelationType, Room } from 'matrix-js-sdk';
import { ReactEditor } from 'slate-react';
import { Transforms, Editor } from 'slate'; import { Transforms, Editor } from 'slate';
import { Box, Dialog, IconButton, Line, Overlay, OverlayBackdrop, OverlayCenter, PopOut, Scroll, Text, color, config, toRem } from 'folds'; import { Box, Dialog, IconButton, Line, Overlay, OverlayBackdrop, OverlayCenter, PopOut, Scroll, Text, color, config, toRem } from 'folds';
@@ -32,6 +31,7 @@ import {
EmoticonAutocomplete, EmoticonAutocomplete,
createEmoticonElement, createEmoticonElement,
moveCursor, moveCursor,
safeFocusEditor,
resetEditorHistory, resetEditorHistory,
customHtmlEqualsPlainText, customHtmlEqualsPlainText,
trimCustomHtml, trimCustomHtml,
@@ -559,7 +559,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const handleCloseAutocomplete = useCallback(() => { const handleCloseAutocomplete = useCallback(() => {
setAutocompleteQuery(undefined); setAutocompleteQuery(undefined);
ReactEditor.focus(editor); safeFocusEditor(editor);
}, [editor]); }, [editor]);
const handleEmoticonSelect = (key: string, shortcode: string) => { const handleEmoticonSelect = (key: string, shortcode: string) => {
@@ -803,7 +803,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
requestClose={() => { requestClose={() => {
setEmojiBoardTab((t) => { setEmojiBoardTab((t) => {
if (t) { if (t) {
if (!mobileOrTablet()) ReactEditor.focus(editor); if (!mobileOrTablet()) safeFocusEditor(editor);
return undefined; return undefined;
} }
return t; return t;

View File

@@ -8,7 +8,6 @@ import React, {
import { Box, Chip, IconButton, Line, PopOut, RectCords, Spinner, Text, as, config } from 'folds'; import { Box, Chip, IconButton, Line, PopOut, RectCords, Spinner, Text, as, config } from 'folds';
import { Icon, Icons } from '../../../components/icons'; import { Icon, Icons } from '../../../components/icons';
import { Editor, Transforms } from 'slate'; import { Editor, Transforms } from 'slate';
import { ReactEditor } from 'slate-react';
import { IContent, IMentions, MatrixEvent, RelationType, Room } from 'matrix-js-sdk'; import { IContent, IMentions, MatrixEvent, RelationType, Room } from 'matrix-js-sdk';
import { isKeyHotkey } from 'is-hotkey'; import { isKeyHotkey } from 'is-hotkey';
import { import {
@@ -27,6 +26,7 @@ import {
htmlToEditorInput, htmlToEditorInput,
moveCursor, moveCursor,
plainToEditorInput, plainToEditorInput,
safeFocusEditor,
toMatrixCustomHTML, toMatrixCustomHTML,
toPlainText, toPlainText,
trimCustomHtml, trimCustomHtml,
@@ -185,7 +185,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
); );
const handleCloseAutocomplete = useCallback(() => { const handleCloseAutocomplete = useCallback(() => {
ReactEditor.focus(editor); safeFocusEditor(editor);
setAutocompleteQuery(undefined); setAutocompleteQuery(undefined);
}, [editor]); }, [editor]);
@@ -208,7 +208,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
}); });
editor.insertFragment(initialValue); editor.insertFragment(initialValue);
if (!mobileOrTablet()) ReactEditor.focus(editor); if (!mobileOrTablet()) safeFocusEditor(editor);
}, [editor, getPrevBodyAndFormattedBody, isMarkdown]); }, [editor, getPrevBodyAndFormattedBody, isMarkdown]);
useEffect(() => { useEffect(() => {
@@ -300,7 +300,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
requestClose={() => { requestClose={() => {
setAnchor((v) => { setAnchor((v) => {
if (v) { if (v) {
if (!mobileOrTablet()) ReactEditor.focus(editor); if (!mobileOrTablet()) safeFocusEditor(editor);
return undefined; return undefined;
} }
return v; return v;