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:
@@ -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);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user