From 687f139719b317117c11e20fcb4ccacfcb704c43 Mon Sep 17 00:00:00 2001 From: litruv Date: Mon, 13 Jul 2026 02:40:02 +1000 Subject: [PATCH] Fix Slate crash after inserting emoji from picker/autocomplete --- src/app/components/editor/Elements.tsx | 14 ++++++++--- src/app/components/editor/utils.ts | 24 +++++++++++++++++++ src/app/features/forum/ForumChatComposer.tsx | 22 ++++++++--------- src/app/features/room/RoomInput.tsx | 6 ++--- .../features/room/message/MessageEditor.tsx | 8 +++---- 5 files changed, 53 insertions(+), 21 deletions(-) diff --git a/src/app/components/editor/Elements.tsx b/src/app/components/editor/Elements.tsx index 25f0305..76ce876 100644 --- a/src/app/components/editor/Elements.tsx +++ b/src/app/components/editor/Elements.tsx @@ -82,13 +82,21 @@ function RenderEmoticonElement({ const selected = useSelected(); 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 ( - + {element.key.startsWith('mxc://') ? ( + {children} ); } diff --git a/src/app/components/editor/utils.ts b/src/app/components/editor/utils.ts index 90c549c..6c66cc2 100644 --- a/src/app/components/editor/utils.ts +++ b/src/app/components/editor/utils.ts @@ -1,4 +1,5 @@ import { BasePoint, BaseRange, Editor, Element, Point, Range, Text, Transforms } from 'slate'; +import { ReactEditor } from 'slate-react'; import { BlockType, MarkType } from './types'; import { CommandElement, @@ -206,6 +207,29 @@ export const moveCursor = (editor: Editor, withSpace?: boolean) => { 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 { match: (char: string) => boolean; reverse?: boolean; diff --git a/src/app/features/forum/ForumChatComposer.tsx b/src/app/features/forum/ForumChatComposer.tsx index 7e0ac0c..9aaf9d4 100644 --- a/src/app/features/forum/ForumChatComposer.tsx +++ b/src/app/features/forum/ForumChatComposer.tsx @@ -1,7 +1,6 @@ import React, { KeyboardEventHandler, useCallback, useRef, useState } from 'react'; import { isKeyHotkey } from 'is-hotkey'; import { Editor } from 'slate'; -import { ReactEditor } from 'slate-react'; import { IconButton, Line, PopOut } from 'folds'; import { Icon, Icons } from '../../components/icons'; import { @@ -13,6 +12,7 @@ import { getAutocompleteQuery, createEmoticonElement, moveCursor, + safeFocusEditor, } from '../../components/editor'; import { EmojiBoard, EmojiBoardTab } from '../../components/emoji-board'; import { UseStateProvider } from '../../components/UseStateProvider'; @@ -84,7 +84,7 @@ export function ForumChatComposer({ const handleCloseAutocomplete = useCallback(() => { setAutocompleteQuery(undefined); - ReactEditor.focus(editor); + safeFocusEditor(editor); }, [editor]); return ( @@ -136,15 +136,15 @@ export function ForumChatComposer({ returnFocusOnDeactivate={false} onEmojiSelect={handleEmoticonSelect} onCustomEmojiSelect={handleEmoticonSelect} - requestClose={() => { - setEmojiBoardTab((t) => { - if (t) { - if (!mobileOrTablet()) ReactEditor.focus(editor); - return undefined; - } - return t; - }); - }} + requestClose={() => { + setEmojiBoardTab((t) => { + if (t) { + if (!mobileOrTablet()) safeFocusEditor(editor); + return undefined; + } + return t; + }); + }} /> } > diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index e4e582e..f3bd88e 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -10,7 +10,6 @@ import React, { import { useAtom, useAtomValue } from 'jotai'; import { isKeyHotkey } from 'is-hotkey'; import { EventType, IContent, MsgType, RelationType, Room } from 'matrix-js-sdk'; -import { ReactEditor } from 'slate-react'; import { Transforms, Editor } from 'slate'; import { Box, Dialog, IconButton, Line, Overlay, OverlayBackdrop, OverlayCenter, PopOut, Scroll, Text, color, config, toRem } from 'folds'; @@ -32,6 +31,7 @@ import { EmoticonAutocomplete, createEmoticonElement, moveCursor, + safeFocusEditor, resetEditorHistory, customHtmlEqualsPlainText, trimCustomHtml, @@ -559,7 +559,7 @@ export const RoomInput = forwardRef( const handleCloseAutocomplete = useCallback(() => { setAutocompleteQuery(undefined); - ReactEditor.focus(editor); + safeFocusEditor(editor); }, [editor]); const handleEmoticonSelect = (key: string, shortcode: string) => { @@ -803,7 +803,7 @@ export const RoomInput = forwardRef( requestClose={() => { setEmojiBoardTab((t) => { if (t) { - if (!mobileOrTablet()) ReactEditor.focus(editor); + if (!mobileOrTablet()) safeFocusEditor(editor); return undefined; } return t; diff --git a/src/app/features/room/message/MessageEditor.tsx b/src/app/features/room/message/MessageEditor.tsx index 057a419..7757b65 100644 --- a/src/app/features/room/message/MessageEditor.tsx +++ b/src/app/features/room/message/MessageEditor.tsx @@ -8,7 +8,6 @@ import React, { import { Box, Chip, IconButton, Line, PopOut, RectCords, Spinner, Text, as, config } from 'folds'; import { Icon, Icons } from '../../../components/icons'; import { Editor, Transforms } from 'slate'; -import { ReactEditor } from 'slate-react'; import { IContent, IMentions, MatrixEvent, RelationType, Room } from 'matrix-js-sdk'; import { isKeyHotkey } from 'is-hotkey'; import { @@ -27,6 +26,7 @@ import { htmlToEditorInput, moveCursor, plainToEditorInput, + safeFocusEditor, toMatrixCustomHTML, toPlainText, trimCustomHtml, @@ -185,7 +185,7 @@ export const MessageEditor = as<'div', MessageEditorProps>( ); const handleCloseAutocomplete = useCallback(() => { - ReactEditor.focus(editor); + safeFocusEditor(editor); setAutocompleteQuery(undefined); }, [editor]); @@ -208,7 +208,7 @@ export const MessageEditor = as<'div', MessageEditorProps>( }); editor.insertFragment(initialValue); - if (!mobileOrTablet()) ReactEditor.focus(editor); + if (!mobileOrTablet()) safeFocusEditor(editor); }, [editor, getPrevBodyAndFormattedBody, isMarkdown]); useEffect(() => { @@ -300,7 +300,7 @@ export const MessageEditor = as<'div', MessageEditorProps>( requestClose={() => { setAnchor((v) => { if (v) { - if (!mobileOrTablet()) ReactEditor.focus(editor); + if (!mobileOrTablet()) safeFocusEditor(editor); return undefined; } return v;