Compare commits

...
Author SHA1 Message Date
Sonarly Claude Code 9407ab91cf fix(twenty-front): guard convertTextToTag against stale editor state in multi-item field editor
https://sonarly.com/issue/33779?type=bug

The workflow email editor's multi-recipient field crashes with RangeError when `convertTextToTag` runs inside a `setTimeout` callback with a stale text length, computing a negative `from` position for ProseMirror's `deleteRange`.

Fix: Changed `convertTextToTag` to read the text before cursor from the editor's current state at execution time, instead of accepting a pre-captured `text` parameter from the caller's closure.

**What changed:**

1. **`convertTextToTag` signature**: Changed from `(editor: Editor, text: string)` to `(editor: Editor)`. The function now calls `getTextBeforeCursor(editor)` internally to get the current text before cursor, ensuring it always operates on the editor's actual state rather than a stale closure value.

2. **Safety guard**: Added `if (deleteFrom < 0) return false` check before calling `deleteRange`, as defense-in-depth against any remaining edge case where position arithmetic could produce a negative value.

3. **All 4 call sites updated**:
   - `handleTextInput` setTimeout callback (line 77)
   - `handleKeyDown` setTimeout callback (line 90)
   - `onBlur` handler (line 159) — also simplified since the empty-text check is now inside `convertTextToTag`

**Why this fixes the bug:**

The original code captured `textBefore` via `getTextBeforeCursor()` at event time, then passed it to `convertTextToTag` inside a `setTimeout`. By the time the setTimeout fired, the `onBlur` handler (or another setTimeout) could have already converted that text to a tag node, shrinking the document. The function then used the stale `text.length` against the current `$from.pos`, producing `deleteFrom = small_pos - large_stale_length = negative`, which caused ProseMirror's `Node.resolve(negative)` to throw `RangeError: Position -N out of range`.

Now, `convertTextToTag` reads the current text at execution time. If a prior handler already converted the text, `getTextBeforeCursor` returns an empty string, and the function safely returns `false`.
2026-05-03 23:28:45 +00:00
@@ -29,8 +29,10 @@ const getTextBeforeCursor = (editor: Editor): string => {
return textBefore;
};
const convertTextToTag = (editor: Editor, text: string): boolean => {
const trimmedText = text.trim();
const convertTextToTag = (editor: Editor): boolean => {
const textBefore = getTextBeforeCursor(editor);
const trimmedText = textBefore.trim();
if (trimmedText.length === 0) {
return false;
}
@@ -39,9 +41,13 @@ const convertTextToTag = (editor: Editor, text: string): boolean => {
const { selection } = state;
const { $from } = selection;
const deleteFrom = $from.pos - text.length;
const deleteFrom = $from.pos - textBefore.length;
const deleteTo = $from.pos;
if (deleteFrom < 0) {
return false;
}
editor
.chain()
.focus()
@@ -68,7 +74,7 @@ const CommaToTagExtension = Extension.create({
if (textBefore.trim().length > 0) {
setTimeout(() => {
convertTextToTag(editor, textBefore);
convertTextToTag(editor);
}, 0);
return true;
}
@@ -81,7 +87,7 @@ const CommaToTagExtension = Extension.create({
if (textBefore.trim().length > 0) {
setTimeout(() => {
convertTextToTag(editor, textBefore);
convertTextToTag(editor);
}, 0);
return true;
}
@@ -150,11 +156,7 @@ export const useMultiItemFieldEditor = ({
onUpdate(editor);
},
onBlur: ({ editor }) => {
const textBefore = getTextBeforeCursor(editor);
if (textBefore.trim().length > 0) {
convertTextToTag(editor, textBefore);
}
convertTextToTag(editor);
},
enableInputRules: false,
enablePasteRules: false,