diff --git a/src/app/components/editor/utils.ts b/src/app/components/editor/utils.ts index 20c9cd26c..15122cb8e 100644 --- a/src/app/components/editor/utils.ts +++ b/src/app/components/editor/utils.ts @@ -194,22 +194,43 @@ export const createCommandElement = (command: string): CommandElement => ({ }); export const replaceWithElement = (editor: Editor, selectRange: BaseRange, element: Element) => { - Transforms.select(editor, selectRange); - Transforms.insertNodes(editor, element); - Transforms.collapse(editor, { - edge: 'end', - }); + // Wrap the whole sequence: on a stale autocomplete range (the document changed + // between the menu opening and the pick) `insertNodes` — not `select`, which is + // lazy in this Slate version — can throw. This runs inside the pick's event + // handler, so an escape wouldn't hit the error boundary, but keep it contained. + try { + Transforms.select(editor, selectRange); + Transforms.insertNodes(editor, element); + Transforms.collapse(editor, { edge: 'end' }); + } catch { + /* stale range — the pick is a no-op rather than an uncaught error */ + } }; export const moveCursor = (editor: Editor, withSpace?: boolean) => { - // Defer to the next tick so React can flush any pending void-element DOM - // updates (e.g. after inserting a mention) before Slate resolves cursor - // positions via ReactEditor.toDOMNode — otherwise Slate throws - // "Cannot resolve a DOM node from slate node". + // Move the caret out of the just-inserted inline void and land it in a real + // trailing text node — SYNCHRONOUSLY, in the same commit as the insert. + // `Transforms.move` escapes the void (after insertNodes+collapse the caret is + // INSIDE the void's inner text node; insertText there is a no-op, blocked by + // Slate's void guard). The space then lands in a real text node. + // Doing this in the same commit (vs the old deferred setTimeout) means the + // caret never sits on the void's zero-width edge on a racy tick — that edge's + // DOM (a U+FEFF node) isn't populated yet, so slate-react's commit-phase + // selection sync (setBaseAndExtent) threw IndexSizeError mid-render and tripped + // the composer error boundary. Both ops are pure model transforms (no DOM + // resolution), so running them synchronously is safe. + Transforms.move(editor); + if (withSpace) editor.insertText(' '); + // Re-assert focus next tick (a pick usually keeps the editor focused). Guarded + // because ReactEditor.focus resolves the DOM; with the caret now in a real text + // node this is safe, but stay defensive against a mid-flight editor. setTimeout(() => { - ReactEditor.focus(editor); - Transforms.move(editor); - if (withSpace) editor.insertText(' '); + try { + ReactEditor.focus(editor); + } catch { + // The editor DOM can be mid-flight (autocomplete just closed / re-render + // landed). The element is already inserted, so skip the focus nudge. + } }, 0); }; diff --git a/src/app/features/room/RoomView.tsx b/src/app/features/room/RoomView.tsx index c5f4c5ecd..57acb73aa 100644 --- a/src/app/features/room/RoomView.tsx +++ b/src/app/features/room/RoomView.tsx @@ -1,8 +1,9 @@ import React, { useCallback, useMemo, useRef } from 'react'; import { ErrorBoundary } from 'react-error-boundary'; -import { Box, Text, config } from 'folds'; +import { Box, Button, Text, config } from 'folds'; import { EventType } from 'matrix-js-sdk'; import { ReactEditor } from 'slate-react'; +import { Transforms } from 'slate'; import { isKeyHotkey } from 'is-hotkey'; import { useStateEvent } from '../../hooks/useStateEvent'; import { StateEvent } from '../../../types/matrix/room'; @@ -152,17 +153,38 @@ export function RoomView({ eventId }: { eventId?: string }) { <> {canMessage && ( { + // The composer crash is a transient bad-selection render + // (e.g. after an autocomplete insert); the draft content is + // intact. Clear the selection so the remounted composer can + // render — the user clicks in to continue, no page refresh. + try { + Transforms.deselect(editor); + } catch { + /* editor already in a safe state */ + } + }} + fallbackRender={({ resetErrorBoundary }) => ( - - Message composer encountered an error. Try refreshing. - + The message composer hit a snag. + - } + )} >