Fix composer crash when a list_item has no list around it (MAILSPRING-CLIENT-EV) - #2813
Fix composer crash when a list_item has no list around it (MAILSPRING-CLIENT-EV)#2813bengotow wants to merge 3 commits into
Conversation
…-CLIENT-EV) Slate throws "Cannot read properties of null (reading 'type')" from Commands.splitNodeByPath when the composer contains a `list_item` block that is a direct child of the document — an `<li>` deserialized from email HTML with no `<ul>`/`<ol>` around it. slate-edit-list's `getCurrentItem()` answers "is the cursor in a list?" by returning any parent block whose type is `list_item`, without checking that it is actually inside a list. So Enter (in an empty item) and Backspace (at its start) route into `unwrapList()`, which calls `editor.unwrapNodeByKey(item.key)`. The orphan's path has a single element, so `unwrapNodeByPath` lifts it to the empty root path; `document.assertNode([])` accepts that (Node#getNode returns the document itself for an empty path) so nothing asserts, and when the orphan is neither the first nor the last child of the document the "middle child" branch calls `splitNodeByPath([], index)`. There `document.getDescendant([])` returns null by definition, and reading `node.type` throws — matching the reported frames exactly (slate.js:8142 -> 8022). Wrap EditListPlugin so key events fall through to the default handling whenever the current item is not really inside a list. The document should never hold that shape: slate-edit-list's schema declares a list parent for `list_item` and normalization rewrites an orphan to a `div`. But `Editor#withoutNormalizing` sets `tmp.normalize = false`, runs the callback and restores the flag *without* a try/finally, so any command that throws leaves normalization disabled for the rest of that editor's life. Exceptions from React event handlers don't unmount anything, so after the first Slate error the composer stays open and stops repairing its document — which is what lets the orphan survive. Patch `withoutNormalizing` to restore the flag on the way out so a single error can't cascade. Verified against the pinned slate (cd6f40e8) and @bengotow/slate-edit-list (b868e108) builds: the unguarded plugin reproduces the exact stack, the guarded one falls through, and every in-list behaviour (split item, exit list, indent, backspace-unwrap) is byte-identical before and after. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QfnK1t6VeRn37exPfshj7P
|
Warning Indent Zero is shutting down on August 7th. Please migrate over to Indent 2.0 to continue getting PR reviews.
|
|
- Only import patch-slate-normalizing inside the describe block that needs it, instead of at module scope, so it doesn't mutate Editor.prototype for every other spec in the run. - Early-exit EditListPlugin's onKeyDown on the same three keys slate-edit-list itself dispatches on (Enter/Tab/Backspace), so the guard's extra tree walks don't run on every keystroke. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QfnK1t6VeRn37exPfshj7P
The repo pins @types/jasmine 1.x, which doesn't declare `beforeAll` (a Jasmine 2.0 addition), so tsc failed with TS2304. A `describe` body runs once and synchronously before its `it`s, so requiring the patch there keeps the same scoping without needing a setup hook. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QfnK1t6VeRn37exPfshj7P
Fixes MAILSPRING-CLIENT-EV —
Error: Cannot read properties of null (reading 'type')inCommands$2.splitNodeByPath.What the Sentry report shows
The stack is entirely inside vendored Slate, with no app frames:
Mapping those line numbers against the pinned builds (
slate@cd6f40e8,slate-react@0.45.1-react) pins them down exactly:slate.js:8022:18istype: node.typeinCommands.splitNodeByPath, right aftervar node = document.getDescendant(path).slate.js:8142:14iseditor.splitNodeByPath(parentPath, index)— the "middle child" branch insideCommands.unwrapNodeByPath'swithoutNormalizingblock.So:
unwrapNodeByPathwas called on a node whoseparentPathdoes not resolve to a descendant.Root cause
getDescendantreturnsnullfor the empty root path by definition (if (!path || !path.size) return null).parentPathisPathUtils.lift(path), so the only way to hit that is a path with a single element — a node that is a direct child of the document.The node in question is a
list_item. Email HTML regularly contains an<li>with no<ul>/<ol>around it, and our HTML deserializer maps<li>→list_itemregardless of its parent, so an orphanedlist_itemcan end up as a top-level block.From there:
slate-edit-list'sgetCurrentItem()answers "is the cursor in a list?" by returning any parent block whose type islist_item— it never checks that the item is actually inside a list.onEnter(in an empty item) andonBackspace(at its start) therefore callunwrapList(), which callseditor.unwrapNodeByKey(item.key).unwrapNodeByPathcomputesparentPath = lift([N])=[].document.assertNode([])succeeds —Node#getNodereturnsthisfor an empty path — so nothing asserts.parentIndexisundefined,isFirst/isLastare computed against the document's children.elsebranch runssplitNodeByPath([], index)→getDescendant([])→null→node.typethrows.The document should never hold that shape:
slate-edit-list's schema declares a list parent forlist_item, and normalization rewrites an orphan to adiv. The reason it survives is a second bug:Editor#withoutNormalizingsetstmp.normalize = false, runs the callback, then restores the flag — without atry/finally:Almost every structural command runs inside
withoutNormalizing, and exceptions thrown from a React event handler don't unmount anything — we report them to Sentry and the composer stays open. So after the first Slate error of a session, that editor silently stops normalizing, and invalid structures accumulate instead of being repaired. That is what lets an orphanedlist_itemreach the keyboard handlers.Reproduction
Confirmed against the pinned dependency builds:
and the full chain, starting from a healthy editor:
(Without the leaked flag, the same paste is normalized to
div("stray")and the crash is unreachable.)The fix
base-block-plugins.tsx— wrapEditListPluginso key events fall through to the default handling whenever the current item is not really inside a list (getCurrentItem()finds an item butgetCurrentList()does not). The orphan then behaves like any other block.patch-slate-normalizing.ts(new) — restoretmp.normalizewhen thewithoutNormalizingcallback throws, so one caught Slate error can no longer disable document repair for the rest of the session. This is the enabling condition for this crash and very likely for other structural Slate errors in the same Sentry project.Testing
New spec
app/spec/components/composer-editor/base-block-plugins-spec.tsxcovers both. Every assertion was also run directly against the real pinnedslate/@bengotow/slate-edit-listbuilds:list_itemno longer throw and fall through to the next handler, leaving the document untouched.list_itemthat is the document's only node no longer throws (it hit a different Slate assertion before).withoutNormalizingrestores the flag after a throw, including when nested; the non-throwing path is unchanged.Generated by Claude Code