Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .agents/conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -742,6 +742,30 @@ Each custom block type has a paired plugin file that:

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

### Dispatching mutating commands

Always wrap `editor.dispatchCommand()` in `editor.update()` when the command's
listener mutates state (e.g. `TOGGLE_LINK_COMMAND`, `FORMAT_TEXT_COMMAND`).
Calling `dispatchCommand` from a React event handler without `editor.update()`
can execute mutations in a read-only context if an `editorState.read()` is active
on the call stack. See Sentry MEMO-5.

```typescript
// ✅ Correct — writable context guaranteed
const handleSave = () => {
editor.update(() => {
editor.dispatchCommand(TOGGLE_LINK_COMMAND, url);
});
};

// ❌ Wrong — may run in read-only context
const handleSave = () => {
editor.dispatchCommand(TOGGLE_LINK_COMMAND, url);
};
```

A static analysis test (`lexical-dispatch-safety.test.ts`) enforces this pattern.

### Lexical package versions

All `@lexical/*` packages are pinned to the same version (currently 0.31.0).
Expand Down
12 changes: 10 additions & 2 deletions src/components/editor/floating-link-editor-plugin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -133,13 +133,21 @@ export function FloatingLinkEditorPlugin({

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

const handleRemove = useCallback(() => {
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null);
editor.update(() => {
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null);
});
setIsVisible(false);
setIsEditing(false);
}, [editor]);
Expand Down
30 changes: 20 additions & 10 deletions src/components/editor/floating-toolbar-plugin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -138,20 +138,30 @@ export function FloatingToolbarPlugin({
};
}, [isVisible, updatePosition]);

const formatBold = () => editor.dispatchCommand(FORMAT_TEXT_COMMAND, "bold");
// All dispatchCommand calls are wrapped in editor.update() so command
// listeners that mutate state run in a writable context. See MEMO-5.
const formatBold = () =>
editor.update(() => editor.dispatchCommand(FORMAT_TEXT_COMMAND, "bold"));
const formatItalic = () =>
editor.dispatchCommand(FORMAT_TEXT_COMMAND, "italic");
editor.update(() => editor.dispatchCommand(FORMAT_TEXT_COMMAND, "italic"));
const formatUnderline = () =>
editor.dispatchCommand(FORMAT_TEXT_COMMAND, "underline");
editor.update(() =>
editor.dispatchCommand(FORMAT_TEXT_COMMAND, "underline")
);
const formatStrikethrough = () =>
editor.dispatchCommand(FORMAT_TEXT_COMMAND, "strikethrough");
const formatCode = () => editor.dispatchCommand(FORMAT_TEXT_COMMAND, "code");
editor.update(() =>
editor.dispatchCommand(FORMAT_TEXT_COMMAND, "strikethrough")
);
const formatCode = () =>
editor.update(() => editor.dispatchCommand(FORMAT_TEXT_COMMAND, "code"));
const toggleLink = () => {
if (isLink) {
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null);
} else {
editor.dispatchCommand(TOGGLE_LINK_COMMAND, "https://");
}
editor.update(() => {
if (isLink) {
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null);
} else {
editor.dispatchCommand(TOGGLE_LINK_COMMAND, "https://");
}
});
};

if (!isVisible) return null;
Expand Down
119 changes: 119 additions & 0 deletions src/components/editor/lexical-dispatch-safety.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { describe, it, expect } from "vitest";
import { readFileSync, readdirSync, statSync } from "fs";
import { resolve, join } from "path";

/**
* Regression test for Sentry MEMO-5: "Cannot use method in read-only mode."
*
* Lexical's dispatchCommand() triggers command listeners synchronously. If a
* listener mutates the editor state (e.g. $toggleLink calling splitText) and
* dispatchCommand was called outside editor.update(), the mutation can execute
* in a read-only context — especially when an editorState.read() is active on
* the call stack.
*
* This test scans editor source files for bare editor.dispatchCommand() calls
* that are NOT wrapped in editor.update(). Commands that mutate state (like
* TOGGLE_LINK_COMMAND) must be dispatched inside editor.update().
*/

/** Recursively collect .ts/.tsx files under a directory */
function collectSourceFiles(dir: string): string[] {
const files: string[] = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) {
files.push(...collectSourceFiles(full));
} else if (/\.tsx?$/.test(entry) && !entry.includes(".test.")) {
files.push(full);
}
}
return files;
}

/**
* Commands known to mutate editor state when handled by their default
* @lexical/react plugin listeners.
*/
const MUTATING_COMMANDS = [
"TOGGLE_LINK_COMMAND",
"INSERT_PARAGRAPH_COMMAND",
"INSERT_LINE_BREAK_COMMAND",
"DELETE_CHARACTER_COMMAND",
"DELETE_WORD_COMMAND",
"DELETE_LINE_COMMAND",
"FORMAT_TEXT_COMMAND",
"FORMAT_ELEMENT_COMMAND",
"INSERT_TAB_COMMAND",
"INDENT_CONTENT_COMMAND",
"OUTDENT_CONTENT_COMMAND",
"REMOVE_TEXT_COMMAND",
"PASTE_COMMAND",
"CUT_COMMAND",
];

describe("Lexical dispatchCommand safety — mutating commands inside editor.update()", () => {
const editorDir = resolve(__dirname);
const sourceFiles = collectSourceFiles(editorDir);

it("mutating commands are not dispatched outside editor.update()", () => {
const violations: string[] = [];
const commandPattern = new RegExp(
`editor\\.dispatchCommand\\(\\s*(${MUTATING_COMMANDS.join("|")})`,
"g"
);

for (const filePath of sourceFiles) {
const content = readFileSync(filePath, "utf-8");
const lines = content.split("\n");

for (let i = 0; i < lines.length; i++) {
const match = lines[i].match(commandPattern);
if (!match) continue;

// Check if editor.update( appears on the same line before dispatchCommand
const colIdx = lines[i].indexOf("editor.dispatchCommand");
const linePrefix = lines[i].substring(0, colIdx);
if (/editor\.update\s*\(/.test(linePrefix)) continue;

// Walk backwards through preceding lines to find an enclosing
// editor.update() block. Track brace/paren depth to stay within
// the same scope.
let insideUpdate = false;
let braceDepth = 0;

for (let j = i - 1; j >= 0 && j >= i - 30; j--) {
const line = lines[j];

for (let c = line.length - 1; c >= 0; c--) {
if (line[c] === "}" || line[c] === ")") braceDepth++;
if (line[c] === "{" || line[c] === "(") braceDepth--;
}

if (braceDepth < 0 && /editor\.update\s*\(/.test(line)) {
insideUpdate = true;
break;
}
// If we've exited the enclosing scope, stop
if (braceDepth > 2) break;
}

if (!insideUpdate) {
const relative = filePath.replace(
resolve(__dirname, "../..") + "/",
""
);
violations.push(
`${relative}:${i + 1}: ${match[0]} — must be inside editor.update()`
);
}
}
}

expect(
violations,
"Mutating Lexical commands must be dispatched inside editor.update() to " +
"avoid read-only mode errors. See Sentry MEMO-5.\n\nViolations:\n" +
violations.join("\n")
).toHaveLength(0);
});
});
Loading