Skip to content

Commit 0e5cdf5

Browse files
fix(editor): wrap dispatchCommand in editor.update() to prevent read-only errors (#129)
Lexical's dispatchCommand() triggers command listeners synchronously. When called from a React event handler while an editorState.read() is active on the call stack, mutations (e.g. $toggleLink → splitText) execute in a read-only context, throwing 'Cannot use method in read-only mode.' Wrap all mutating dispatchCommand calls in editor.update() in both floating-link-editor-plugin.tsx and floating-toolbar-plugin.tsx. Add a static analysis test to prevent regressions and document the pattern in .agents/conventions.md. Closes #128 Co-authored-by: Ona <no-reply@ona.com>
1 parent 0be80e1 commit 0e5cdf5

4 files changed

Lines changed: 173 additions & 12 deletions

File tree

.agents/conventions.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -742,6 +742,30 @@ Each custom block type has a paired plugin file that:
742742

743743
The slash command menu imports the command and dispatches it on selection.
744744

745+
### Dispatching mutating commands
746+
747+
Always wrap `editor.dispatchCommand()` in `editor.update()` when the command's
748+
listener mutates state (e.g. `TOGGLE_LINK_COMMAND`, `FORMAT_TEXT_COMMAND`).
749+
Calling `dispatchCommand` from a React event handler without `editor.update()`
750+
can execute mutations in a read-only context if an `editorState.read()` is active
751+
on the call stack. See Sentry MEMO-5.
752+
753+
```typescript
754+
// ✅ Correct — writable context guaranteed
755+
const handleSave = () => {
756+
editor.update(() => {
757+
editor.dispatchCommand(TOGGLE_LINK_COMMAND, url);
758+
});
759+
};
760+
761+
// ❌ Wrong — may run in read-only context
762+
const handleSave = () => {
763+
editor.dispatchCommand(TOGGLE_LINK_COMMAND, url);
764+
};
765+
```
766+
767+
A static analysis test (`lexical-dispatch-safety.test.ts`) enforces this pattern.
768+
745769
### Lexical package versions
746770

747771
All `@lexical/*` packages are pinned to the same version (currently 0.31.0).

src/components/editor/floating-link-editor-plugin.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -133,13 +133,21 @@ export function FloatingLinkEditorPlugin({
133133

134134
const handleSave = useCallback(() => {
135135
if (editedUrl.trim()) {
136-
editor.dispatchCommand(TOGGLE_LINK_COMMAND, editedUrl.trim());
136+
// Wrap in editor.update() so the LinkPlugin command listener runs in a
137+
// writable context. Without this, if dispatchCommand fires while an
138+
// editorState.read() is active (e.g. from the update listener),
139+
// $toggleLink's mutations hit Lexical's read-only guard.
140+
editor.update(() => {
141+
editor.dispatchCommand(TOGGLE_LINK_COMMAND, editedUrl.trim());
142+
});
137143
}
138144
setIsEditing(false);
139145
}, [editor, editedUrl]);
140146

141147
const handleRemove = useCallback(() => {
142-
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null);
148+
editor.update(() => {
149+
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null);
150+
});
143151
setIsVisible(false);
144152
setIsEditing(false);
145153
}, [editor]);

src/components/editor/floating-toolbar-plugin.tsx

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -138,20 +138,30 @@ export function FloatingToolbarPlugin({
138138
};
139139
}, [isVisible, updatePosition]);
140140

141-
const formatBold = () => editor.dispatchCommand(FORMAT_TEXT_COMMAND, "bold");
141+
// All dispatchCommand calls are wrapped in editor.update() so command
142+
// listeners that mutate state run in a writable context. See MEMO-5.
143+
const formatBold = () =>
144+
editor.update(() => editor.dispatchCommand(FORMAT_TEXT_COMMAND, "bold"));
142145
const formatItalic = () =>
143-
editor.dispatchCommand(FORMAT_TEXT_COMMAND, "italic");
146+
editor.update(() => editor.dispatchCommand(FORMAT_TEXT_COMMAND, "italic"));
144147
const formatUnderline = () =>
145-
editor.dispatchCommand(FORMAT_TEXT_COMMAND, "underline");
148+
editor.update(() =>
149+
editor.dispatchCommand(FORMAT_TEXT_COMMAND, "underline")
150+
);
146151
const formatStrikethrough = () =>
147-
editor.dispatchCommand(FORMAT_TEXT_COMMAND, "strikethrough");
148-
const formatCode = () => editor.dispatchCommand(FORMAT_TEXT_COMMAND, "code");
152+
editor.update(() =>
153+
editor.dispatchCommand(FORMAT_TEXT_COMMAND, "strikethrough")
154+
);
155+
const formatCode = () =>
156+
editor.update(() => editor.dispatchCommand(FORMAT_TEXT_COMMAND, "code"));
149157
const toggleLink = () => {
150-
if (isLink) {
151-
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null);
152-
} else {
153-
editor.dispatchCommand(TOGGLE_LINK_COMMAND, "https://");
154-
}
158+
editor.update(() => {
159+
if (isLink) {
160+
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null);
161+
} else {
162+
editor.dispatchCommand(TOGGLE_LINK_COMMAND, "https://");
163+
}
164+
});
155165
};
156166

157167
if (!isVisible) return null;
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { describe, it, expect } from "vitest";
2+
import { readFileSync, readdirSync, statSync } from "fs";
3+
import { resolve, join } from "path";
4+
5+
/**
6+
* Regression test for Sentry MEMO-5: "Cannot use method in read-only mode."
7+
*
8+
* Lexical's dispatchCommand() triggers command listeners synchronously. If a
9+
* listener mutates the editor state (e.g. $toggleLink calling splitText) and
10+
* dispatchCommand was called outside editor.update(), the mutation can execute
11+
* in a read-only context — especially when an editorState.read() is active on
12+
* the call stack.
13+
*
14+
* This test scans editor source files for bare editor.dispatchCommand() calls
15+
* that are NOT wrapped in editor.update(). Commands that mutate state (like
16+
* TOGGLE_LINK_COMMAND) must be dispatched inside editor.update().
17+
*/
18+
19+
/** Recursively collect .ts/.tsx files under a directory */
20+
function collectSourceFiles(dir: string): string[] {
21+
const files: string[] = [];
22+
for (const entry of readdirSync(dir)) {
23+
const full = join(dir, entry);
24+
if (statSync(full).isDirectory()) {
25+
files.push(...collectSourceFiles(full));
26+
} else if (/\.tsx?$/.test(entry) && !entry.includes(".test.")) {
27+
files.push(full);
28+
}
29+
}
30+
return files;
31+
}
32+
33+
/**
34+
* Commands known to mutate editor state when handled by their default
35+
* @lexical/react plugin listeners.
36+
*/
37+
const MUTATING_COMMANDS = [
38+
"TOGGLE_LINK_COMMAND",
39+
"INSERT_PARAGRAPH_COMMAND",
40+
"INSERT_LINE_BREAK_COMMAND",
41+
"DELETE_CHARACTER_COMMAND",
42+
"DELETE_WORD_COMMAND",
43+
"DELETE_LINE_COMMAND",
44+
"FORMAT_TEXT_COMMAND",
45+
"FORMAT_ELEMENT_COMMAND",
46+
"INSERT_TAB_COMMAND",
47+
"INDENT_CONTENT_COMMAND",
48+
"OUTDENT_CONTENT_COMMAND",
49+
"REMOVE_TEXT_COMMAND",
50+
"PASTE_COMMAND",
51+
"CUT_COMMAND",
52+
];
53+
54+
describe("Lexical dispatchCommand safety — mutating commands inside editor.update()", () => {
55+
const editorDir = resolve(__dirname);
56+
const sourceFiles = collectSourceFiles(editorDir);
57+
58+
it("mutating commands are not dispatched outside editor.update()", () => {
59+
const violations: string[] = [];
60+
const commandPattern = new RegExp(
61+
`editor\\.dispatchCommand\\(\\s*(${MUTATING_COMMANDS.join("|")})`,
62+
"g"
63+
);
64+
65+
for (const filePath of sourceFiles) {
66+
const content = readFileSync(filePath, "utf-8");
67+
const lines = content.split("\n");
68+
69+
for (let i = 0; i < lines.length; i++) {
70+
const match = lines[i].match(commandPattern);
71+
if (!match) continue;
72+
73+
// Check if editor.update( appears on the same line before dispatchCommand
74+
const colIdx = lines[i].indexOf("editor.dispatchCommand");
75+
const linePrefix = lines[i].substring(0, colIdx);
76+
if (/editor\.update\s*\(/.test(linePrefix)) continue;
77+
78+
// Walk backwards through preceding lines to find an enclosing
79+
// editor.update() block. Track brace/paren depth to stay within
80+
// the same scope.
81+
let insideUpdate = false;
82+
let braceDepth = 0;
83+
84+
for (let j = i - 1; j >= 0 && j >= i - 30; j--) {
85+
const line = lines[j];
86+
87+
for (let c = line.length - 1; c >= 0; c--) {
88+
if (line[c] === "}" || line[c] === ")") braceDepth++;
89+
if (line[c] === "{" || line[c] === "(") braceDepth--;
90+
}
91+
92+
if (braceDepth < 0 && /editor\.update\s*\(/.test(line)) {
93+
insideUpdate = true;
94+
break;
95+
}
96+
// If we've exited the enclosing scope, stop
97+
if (braceDepth > 2) break;
98+
}
99+
100+
if (!insideUpdate) {
101+
const relative = filePath.replace(
102+
resolve(__dirname, "../..") + "/",
103+
""
104+
);
105+
violations.push(
106+
`${relative}:${i + 1}: ${match[0]} — must be inside editor.update()`
107+
);
108+
}
109+
}
110+
}
111+
112+
expect(
113+
violations,
114+
"Mutating Lexical commands must be dispatched inside editor.update() to " +
115+
"avoid read-only mode errors. See Sentry MEMO-5.\n\nViolations:\n" +
116+
violations.join("\n")
117+
).toHaveLength(0);
118+
});
119+
});

0 commit comments

Comments
 (0)