From a7c01a287b8a4456b40fc838e6cd8acc3778fa71 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Tue, 9 Jun 2026 16:25:42 +0300 Subject: [PATCH] fix: dispatch grapheme delete on live view in web composer The Backspace handler computed positions from the `view` it was handed (`view.state.selection`, `view.state.doc`) but executed the delete against the closed-over React `editor`. When the editor is recreated (e.g. theme toggle resets the instance and doc), `editor.state.doc` is briefly a smaller, mismatched document, so a position valid in `view` overflows it - ProseMirror throws "Position N out of range" (APP-SBSP: ~87k events). Dispatch on the `view` the handler received so positions and document come from the same state, and clamp to docSize as a backstop. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../com/composer/text-input/TextInput.web.tsx | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx index bb314f116c..22cf8f29a0 100644 --- a/src/view/com/composer/text-input/TextInput.web.tsx +++ b/src/view/com/composer/text-input/TextInput.web.tsx @@ -231,10 +231,19 @@ export function TextInput({ // otherwise, delete the last grapheme using deleteRange, // so that emojis are deleted as a whole const deleteFrom = cursorPosition - lastGrapheme.length - editor?.commands.deleteRange({ - from: deleteFrom, - to: cursorPosition, - }) + // Resolve positions against `view`, not the closed-over + // `editor`. The `editor` ref can briefly point at a stale + // instance (e.g. after the editor is recreated on a theme + // change or content reset) whose document is out of sync + // with the live `view`, causing deleteRange to throw + // "Position X out of range". Clamp to the live doc as a + // final guard. See APP-SBSP -sfn + const docSize = view.state.doc.content.size + const from = Math.max(0, Math.min(deleteFrom, docSize)) + const to = Math.max(from, Math.min(cursorPosition, docSize)) + if (from < to) { + view.dispatch(view.state.tr.delete(from, to)) + } return true } }