From 477df4ae3246a3bbe58a6759e6737d0f72a4eded Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Sun, 26 Jul 2026 16:12:52 -0400 Subject: [PATCH] fix(composer): stop the intermittent autocomplete-insert crash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picking an autocomplete item (mention/emoji/command — all inline voids) occasionally tripped the composer error boundary, forcing a page refresh, even though the element had already inserted. Root cause (traced through slate-react): moveCursor deferred its cursor work to setTimeout(0), leaving the caret on the just-inserted void's zero-width edge whose DOM (a U+FEFF node) isn't populated on that tick. slate-react's commit-phase selection sync then calls setBaseAndExtent(voidEdge, 1) and throws IndexSizeError mid-render → boundary. Prevention: do the cursor work SYNCHRONOUSLY, in the same commit as the insert — Transforms.move (escapes the void into the real trailing text node) then insertText(' '). The caret is then always a resolvable text point when the selection sync runs. (moveCursor's focus stays deferred+guarded, unchanged.) Recovery (belt-and-suspenders): the composer error boundary is now recoverable — a "Reload composer" button (resetErrorBoundary) + onReset Transforms.deselect clears a transient bad selection so it remounts with the draft intact, no page refresh. + role="alert" for screen readers. Three review agents: two root-caused the exact slate-react throw and proved the try/catch-only version merely recovered; a third reproduced the transforms headlessly and caught that a first "sync insertText WITHOUT move" attempt hit Slate's void guard (space dropped, caret trapped) — the move is required to escape the void. Not unit-testable (needs the live DOM + the timing race). Gate-green (tsc, eslint, prettier, 925 tests, build). Co-Authored-By: Claude Opus 4.8 --- src/app/components/editor/utils.ts | 45 ++++++++++++++++++++++-------- src/app/features/room/RoomView.tsx | 34 ++++++++++++++++++---- 2 files changed, 61 insertions(+), 18 deletions(-) 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. + - } + )} >