fix(composer): stop the intermittent autocomplete-insert crash

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 16:12:52 -04:00
co-authored by Claude Opus 4.8
parent 15d85f52c4
commit 477df4ae32
2 changed files with 61 additions and 18 deletions
+33 -12
View File
@@ -194,22 +194,43 @@ export const createCommandElement = (command: string): CommandElement => ({
}); });
export const replaceWithElement = (editor: Editor, selectRange: BaseRange, element: Element) => { export const replaceWithElement = (editor: Editor, selectRange: BaseRange, element: Element) => {
Transforms.select(editor, selectRange); // Wrap the whole sequence: on a stale autocomplete range (the document changed
Transforms.insertNodes(editor, element); // between the menu opening and the pick) `insertNodes` — not `select`, which is
Transforms.collapse(editor, { // lazy in this Slate version — can throw. This runs inside the pick's event
edge: 'end', // 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) => { export const moveCursor = (editor: Editor, withSpace?: boolean) => {
// Defer to the next tick so React can flush any pending void-element DOM // Move the caret out of the just-inserted inline void and land it in a real
// updates (e.g. after inserting a mention) before Slate resolves cursor // trailing text node — SYNCHRONOUSLY, in the same commit as the insert.
// positions via ReactEditor.toDOMNode — otherwise Slate throws // `Transforms.move` escapes the void (after insertNodes+collapse the caret is
// "Cannot resolve a DOM node from slate node". // 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(() => { setTimeout(() => {
ReactEditor.focus(editor); try {
Transforms.move(editor); ReactEditor.focus(editor);
if (withSpace) editor.insertText(' '); } 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); }, 0);
}; };
+28 -6
View File
@@ -1,8 +1,9 @@
import React, { useCallback, useMemo, useRef } from 'react'; import React, { useCallback, useMemo, useRef } from 'react';
import { ErrorBoundary } from 'react-error-boundary'; 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 { EventType } from 'matrix-js-sdk';
import { ReactEditor } from 'slate-react'; import { ReactEditor } from 'slate-react';
import { Transforms } from 'slate';
import { isKeyHotkey } from 'is-hotkey'; import { isKeyHotkey } from 'is-hotkey';
import { useStateEvent } from '../../hooks/useStateEvent'; import { useStateEvent } from '../../hooks/useStateEvent';
import { StateEvent } from '../../../types/matrix/room'; import { StateEvent } from '../../../types/matrix/room';
@@ -152,17 +153,38 @@ export function RoomView({ eventId }: { eventId?: string }) {
<> <>
{canMessage && ( {canMessage && (
<ErrorBoundary <ErrorBoundary
fallback={ onReset={() => {
// 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 }) => (
<RoomInputPlaceholder <RoomInputPlaceholder
role="alert"
style={{ padding: config.space.S200 }} style={{ padding: config.space.S200 }}
direction="Column"
alignItems="Center" alignItems="Center"
justifyContent="Center" justifyContent="Center"
gap="200"
> >
<Text align="Center"> <Text align="Center">The message composer hit a snag.</Text>
Message composer encountered an error. Try refreshing. <Button
</Text> size="300"
variant="Secondary"
fill="Soft"
radii="300"
onClick={resetErrorBoundary}
>
<Text size="B300">Reload composer</Text>
</Button>
</RoomInputPlaceholder> </RoomInputPlaceholder>
} )}
> >
<RoomInput <RoomInput
room={room} room={room}