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
+28 -6
View File
@@ -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 && (
<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
role="alert"
style={{ padding: config.space.S200 }}
direction="Column"
alignItems="Center"
justifyContent="Center"
gap="200"
>
<Text align="Center">
Message composer encountered an error. Try refreshing.
</Text>
<Text align="Center">The message composer hit a snag.</Text>
<Button
size="300"
variant="Secondary"
fill="Soft"
radii="300"
onClick={resetErrorBoundary}
>
<Text size="B300">Reload composer</Text>
</Button>
</RoomInputPlaceholder>
}
)}
>
<RoomInput
room={room}