Prevent composer crash from Slate node-key errors (MAILSPRING-CLIENT-2X) - #2795
Prevent composer crash from Slate node-key errors (MAILSPRING-CLIENT-2X)#2795bengotow wants to merge 1 commit into
Conversation
…f nested quotes or pasting rich content Slate's own compound change functions (unwrapBlockAtRange, insertFragmentAtRange in the vendored slate/lib/slate.js) collect node keys before performing several sequential move/remove operations, and those keys can go stale partway through the same operation for certain nested-blockquote or multi-block-paste shapes. When that happens, Slate's internal moveNodeByKey/assertPath throws "could not find node with path or key", which was previously uncaught and crashed/aborted the composer mid-mutation (MAILSPRING-CLIENT-2X, 50 users impacted). Wrap the two call sites that trigger this (breaking out of a nested blockquote via Enter or the toolbar button, and pasting HTML into the composer) in try/catch so the failure is logged and the command safely no-ops instead of throwing an uncaught exception. Because Slate only commits a value and fires onChange once a top-level command completes successfully, a mid-command throw never reaches the persisted draft — so the safe fallback is simply to stop. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RLUjkxhpBL8yT4tTHrhyQK
|
Warning Indent Zero is shutting down on August 7th. Please migrate over to Indent 2.0 to continue getting PR reviews.
|
|
Warning This organization's free trial has ended, so Indent couldn't start this review. Manage billing to resume reviews on this repository. |
| // function, not something we can fix from a plugin, so we just stop here rather | ||
| // than let the exception escape and abort the whole editor command mid-mutation. | ||
| // See MAILSPRING-CLIENT-2X. | ||
| console.warn('toggleBlockTypeWithBreakout: failed to break out of nested block', err); |
There was a problem hiding this comment.
Latent: tmp.normalize stuck at false after this catch fires.
Both unwrapBlockAtRange (slate.js:7161) and insertFragmentAtRange (slate.js:6721) do their moves inside editor.withoutNormalizing(fn), and that helper (slate.js:11515) is not exception-safe:
function withoutNormalizing(fn) {
var value = this.tmp.normalize;
this.tmp.normalize = false;
fn(controller); // ← throws here
this.tmp.normalize = value; // never runs
normalizeDirtyPaths(this); // never runs
}Once fn throws, this.tmp.normalize stays false for the lifetime of the Editor. normalizeDirtyPaths (slate.js:11697) is a no-op when tmp.normalize === false, so every subsequent command in this composer session runs without schema normalization. Dirty paths accumulate and the document can drift into invalid shapes that ComposerEditor.onChange's getDocumentBrokenReason recovery won't catch (only the most severe shapes are repaired).
The same applies to the sibling catch in composer-editor.tsx around editor.insertFragment. A pragmatic mitigation in both catches: try { editor.tmp.normalize = true; } catch {} (or reset via the controller) before returning, so at least the next command re-enables normalization. Scope is bounded to the current composer session — closing/reopening the draft rebuilds the Editor and clears the flag — but a user who hits this once will keep editing in a non-normalizing editor until then.
There was a problem hiding this comment.
Good catch, thank you — confirmed against the actual vendored bundle and fixed in 6de5899.
One correction to the suggested mitigation: editor.tmp.normalize = true on its own wouldn't have worked here. The editor object these catch blocks receive is slate-react's <Editor> React component, not the core Slate editor — it has its own unrelated tmp (React-internal bookkeeping: resolves/mounted/updates/change/isUpdatingSelection, see slate-react.js ~5212), while the real tmp.normalize/tmp.dirty flags live on editor.controller (the core slate.Editor instance the component forwards commands to, per the "Mimic the API of the Editor controller" comment at slate-react.js:5238). So the fix reaches through editor.controller.tmp.normalize instead, and also calls .controller.normalize() to force an immediate re-normalization pass rather than just waiting for the next incidental command.
That second part turned out to matter for another reason you also flagged in the PR summary: Slate schedules its flush-to-onChange on a microtask queued by the first successful operation in a batch (slate.js:11179-11184), so a partial mutation from an interrupted command does reach ComposerEditor.onChange — my original commit message was wrong to claim otherwise. Calling .normalize() synchronously in the catch, before that pending microtask fires, cleans up the partial mutation so what actually reaches onChange is normalized rather than raw. Extracted both fixes into a shared recoverFromInterruptedSlateCommand helper used by both catch sites.
Generated by Claude Code
There was a problem hiding this comment.
Good catch, thank you — confirmed against the actual vendored bundle and fixed in 6de5899.
One correction to the suggested mitigation: editor.tmp.normalize = true on its own wouldn't have worked here. The editor object these catch blocks receive is slate-react's <Editor> React component, not the core Slate editor — it has its own unrelated tmp (React-internal bookkeeping: resolves/mounted/updates/change/isUpdatingSelection, see slate-react.js ~5212), while the real tmp.normalize/tmp.dirty flags live on editor.controller (the core slate.Editor instance the component forwards commands to, per the "Mimic the API of the Editor controller" comment at slate-react.js:5238). So the fix reaches through editor.controller.tmp.normalize instead, and also calls .controller.normalize() to force an immediate re-normalization pass rather than just waiting for the next incidental command.
That second part turned out to matter for another reason you also flagged in the PR summary: Slate schedules its flush-to-onChange on a microtask queued by the first successful operation in a batch (slate.js:11179-11184), so a partial mutation from an interrupted command does reach ComposerEditor.onChange — my original commit message was wrong to claim otherwise. Calling .normalize() synchronously in the catch, before that pending microtask fires, cleans up the partial mutation so what actually reaches onChange is normalized rather than raw. Extracted both fixes into a shared recoverFromInterruptedSlateCommand helper used by both catch sites.
Generated by Claude Code
…ound command Addresses review feedback on #2795. The prior try/catch stopped the composer from crashing but left two problems unaddressed: 1. Every compound Slate change function that can throw here runs inside Slate's own `withoutNormalizing`, which isn't exception-safe (slate.js:11514) — a throw leaves `tmp.normalize` stuck `false` on the underlying editor for the rest of the composer session, silently disabling schema normalization for every later command. Verified directly against the vendored bundle: `normalizeDirtyPaths` (slate.js:11697) no-ops whenever that flag is false, and there is no other code path that resets it. 2. The commit message on the previous fix claimed a caught mid-command throw "never reaches the persisted draft" — that's wrong. Slate schedules its flush-to-onChange on a microtask queued by the first operation that succeeded before the throw (slate.js:11179-11184), so whatever partial mutation happened before the exception still reaches `ComposerEditor.onChange` on the next tick. Also worth noting for future readers: the `editor` object plugins receive is slate-react's `<Editor>` component, not the core Slate editor — `editor.tmp` is an unrelated object used for React-internal bookkeeping, while the real `tmp.normalize`/ `tmp.dirty` flags live on `editor.controller` (slate-react.js:5238). `recoverFromInterruptedSlateCommand` resets the flag on `editor.controller.tmp` and calls `.normalize()` synchronously, which both re-enables normalization going forward and cleans up the partial mutation before the pending microtask flush delivers it to onChange. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RLUjkxhpBL8yT4tTHrhyQK
What I observed
Sentry issue MAILSPRING-CLIENT-2X —
Error: `Node.assertPath` could not find node with path or key: <n>— is the highest-impact unassigned issue in the last 14 days (50 users / 250 occurrences all-time), withculprit: ElementInterface.<computed> [as assertPath] (slate/lib/slate).Every event's stack trace is 100% inside the vendored Slate library (
in_app_frame_mix: "system-only"— zero Mailspring frames), which is why it's been hard to pin down. Two distinct call patterns show up across events, both bottoming out atslate.js:14824(assertPaththrowing becausedocument.getPath(key)/getKeysToPathsTable()can't find the given key in the current document):Commands$2.moveNodeByKeycalled frominsertFragmentAtRange(slate.js:6809) — triggered byeditor.insertFragment(...).Commands$2.moveNodeByKeycalled fromunwrapBlockAtRange(slate.js:7179, the "unwrap all children of a wrapping block" branch) — triggered byeditor.unwrapBlock(...).I grepped the Mailspring composer source for the only two call sites that invoke these Slate APIs directly, and the line numbers/behavior line up exactly:
app/src/components/composer-editor/composer-editor.tsx—onPastecallseditor.insertFragment(value.document)when pasting HTML.app/src/components/composer-editor/base-block-plugins.tsx—toggleBlockTypeWithBreakout()callseditor.splitBlock()then loops callingeditor.unwrapBlock({ type })once per nesting level. This runs both when clicking the blockquote toolbar button and whenever the user presses Enter while their cursor is inside a (possibly multi-level nested) quoted reply — a very common interaction, which explains the issue's broad user impact.Both of Slate's change functions (
unwrapBlockAtRange,insertFragmentAtRange) are "compound" operations: they snapshot node keys up front, then perform several sequentialmoveNodeByKey/removeNodeByKeycalls against the document. For certain nested-blockquote depths or multi-block paste shapes, an earlier step in the same compound operation invalidates a key a later step still relies on, and Slate's ownassertPaththrows instead of recovering. This is a bug inside the vendored Slate fork's core change functions, not something reachable/fixable from application/plugin code without patching Slate itself.The fix
Since a mid-command exception in Slate is never committed (Slate only calls the parent
onChange— and thus only persists a new value to the draft — once a top-level command fully completes), catching the exception at the two call sites and simply stopping is safe: the user's persisted draft content is never corrupted, and the app no longer crashes/reports to Sentry.splitBlock/unwrapBlockloop intoggleBlockTypeWithBreakoutin a try/catch that logs a warning and aborts cleanly instead of throwing.editor.insertFragment(...)call inonPastein a try/catch that logs a warning; the paste is prevented from partially applying and silently no-ops instead of crashing.Comments at both sites reference this issue and explain why a try/catch (rather than a "real" fix) is the appropriate mitigation given the bug lives in the vendored Slate library.
Fixes MAILSPRING-CLIENT-2X
Test plan
🤖 Generated with Claude Code
https://claude.ai/code/session_01RLUjkxhpBL8yT4tTHrhyQK
Generated by Claude Code