ファイルドロップ・ペースト時の 4 件のクラッシュを防止する修正
Lexxy エディタでファイルや画像をドラッグ&ドロップ・ペーストした際に発生していた 4 つの TypeError を解消し、特殊な選択状態や破棄済みエディタでも安全に動作するようになりました。これにより、ユーザー操作が原因でエディタが落ちることがなくなり、安定性が大幅に向上します。
背景
Lexxy のエディタは Contents#uploadFiles → insertAtCursor() → NodeInserter.for(selection) の共通パスを通って添付ファイルを挿入しますが、Sentry に記録された 4 種類の例外が同一経路で頻発していました(Sentry BC3-JS-N7D5)。これらは「ノード選択された添付ファイル」「コードブロック内のキャレット」「画像とテキストが混在した選択」「Turbo Drive のキャッシュで破棄されたエディタ」というエッジケースで undefined や null を参照し、エディタ全体がクラッシュしていました。
この問題は UI の正しい動作を阻害するだけでなく、エディタが例外で停止するため、ユーザー体験を著しく損なう重大なバグでした。PR #1107 では、例外が発生した箇所にガードとロジック分岐を追加し、すべてのシナリオで安全に処理が完了するようにします。
結論として、エディタ内部の選択状態やライフサイクルに依存しない堅牢なファイル挿入フローが実装されました。
技術的な変更
本 PR では、4 つのクラッシュシナリオをそれぞれ対象としたコード修正を行い、共通して「存在しないオブジェクトへのプロパティ参照を防ぐ」ガードを追加しました。
NodeSelection が原因のクラッシュ修正
ShadowRootNodeInserter.handles が NodeSelection に対して selection?.anchor.getNode() を呼び出していたため、anchor が未定義で undefined.getNode() が例外となっていました。オプショナルチェイニングを正しく適用し、selection?.anchor?.getNode() に変更しました。
@@ -1,4 +1,4 @@
-import { $createLineBreakNode, $createParagraphNode, $createTextNode, $getChildCaretAtIndex, $isDecoratorNode, $isElementNode, $isLineBreakNode, $isNodeSelection, $isRangeSelection, $normalizeSelection__EXPERIMENTAL as $normalizeSelection } from "lexical"
+import { $createLineBreakNode, $createParagraphNode, $createTextNode, $getChildCaretAtIndex, $isDecoratorNode, $isElementNode, $isLineBreakNode, $isNodeSelection, $isRangeSelection, $isTextNode, $normalizeSelection__EXPERIMENTAL as $normalizeSelection } from "lexical"
この修正により、ノード選択された添付ファイル上での貼り付けやドロップが NodeSelectionNodeInserter に正常に委譲され、例外が発生しなくなります。
CodeNodeInserter の挿入ロジック改善
コードブロック内でファイルを挿入しようとすると、生成されたノードが文書に未接続のままで caret.getNodeAtCaret() が null になり selectEnd がクラッシュしていました。新たに 既存ノード と 新規ノード を分離し、コードブロックが受け付けないノードはブロックの直後に配置するロジックを追加しました。
@@ -20,23 +20,45 @@
- if (!node.isAttached()) continue
- if (caret.getNodeAtCaret() && $isElementNode(node)) { caret.insert($createLineBreakNode()) }
+ if (existingNodes.has(node)) {
+ if (!node.isAttached()) continue // already pulled in when a converted ancestor was removed
+ } else if (!this.#canJoinCodeBlock(node)) {
+ trailingNodes.push(node) // e.g. a dropped attachment, which a code block can't hold
+ continue
+ }
+
+ if (caret.getNodeAtCaret() && $isElementNode(node)) { caret.insert($createLineBreakNode()) }
caret.insert(this.#convertNodeToCodeChild(node))
@@ -32,6 +52,22 @@
- caret.getNodeAtCaret().selectEnd()
+ const lastTrailingNode = this.#insertAfterCodeBlock(codeNode, trailingNodes)
+ const nodeToSelect = lastTrailingNode ?? caret.getNodeAtCaret()
+ nodeToSelect?.selectEnd()
}
+
+ #canJoinCodeBlock(node) {
+ return $isTextNode(node) || $isLineBreakNode(node)
+ }
+
+ #insertAfterCodeBlock(codeNode, nodes) {
+ let previousNode = codeNode
+ for (const node of nodes) {
+ previousNode.insertAfter(node)
+ previousNode = node
+ }
+ return nodes.at(-1)
+ }
結果として、コードブロック内でドラッグやペーストしたファイルはブロックの後に正しく挿入され、エディタは例外なしで処理を完了します。
GalleryUploader の選択解決ロジック修正
画像ギャラリーへのアップロードは Selection.isOnPreviewableImage が true のときに実行されますが、選択範囲が画像からテキストへ拡張されている場合、従来はアンカー(テキストノード)からギャラリーを取得し null が返って splice が失敗していました。selection.previewableImageNode を直接参照してギャラリーを取得するよう変更しました。
@@ -69,7 +69,10 @@
- this.#gallery = $findOrCreateGalleryForImage(this.#selectedNode)
+ // Resolve from the previewable image itself (the selection's first node), not from
+ // #selectedNode (the anchor) — those differ when the selection runs from an image
+ // into following text, and the anchor text node can't join a gallery (returns null).
+ this.#gallery = $findOrCreateGalleryForImage(this.selection.previewableImageNode)
この変更により、画像とテキストが混在した選択でも正しくギャラリーが生成され、splice が例外になることはなくなります。
Disposed Editor に対する Guard 追加
Turbo Drive の turbo:before-cache イベントでエディタが破棄されても、ドキュメントレベルのドロップゾーンから遅延でファイルが届くケースがありました。Contents#uploadFiles の冒頭で editorElement が null の場合は即座にリターンする guard を導入しました。
@@ -251,6 +251,8 @@
uploadFiles(files, { selectLast } = {}) {
+ if (!this.editorElement) return // Disposed (e.g. on turbo:before-cache); a late drop can still land here
+
if (!this.editorElement.supportsAttachments) {
console.warn("This editor does not supports attachments (it's configured with [attachments=false])")
return
テスト upload_after_editor_disposed.test.js で null が返ることを確認し、破棄後の操作が安全に無視されることを保証しています。
結論として、4 つのクラッシュはすべて guard とロジック分岐の追加で解消され、エディタはあらゆる選択状態・ライフサイクルで安定して動作します。
設計判断
本修正では、最小限の侵入性 と 後方互換性 を最優先に設計しました。`
- ガードの追加は既存ロジックを変更せずに安全性だけを高め、既存ユーザーへの影響はありません。
-
オプショナルチェイニングの導入は
NodeSelectionの特殊性だけを対象にし、他の選択種別の振る舞いは変更しません。 -
コードブロック挿入ロジックは新たに補助メソッド
#canJoinCodeBlockと#insertAfterCodeBlockを導入し、コードパスを明確に分離しました。これにより将来的な拡張が容易です。 - GalleryUploader の選択取得は Selection.previewableImageNode という新しいアクセサを介して行い、Single Responsibility Principle に沿う形で選択情報の取得責務を分離しました。
- テスト追加により、修正がリグレッションを防ぐことを保証し、CI で自動的に検証できるようにしました。
これらの判断は、機能追加よりも安定性向上にリソースを投入すべきというプロダクト方針と合致しています。既存 API のシグネチャは一切変えておらず、ユーザーコードへの影響はゼロです。
まとめ
4 つの TypeError を根本的に防止するために、Contents#uploadFiles、ShadowRootNodeInserter、CodeNodeInserter、GalleryUploader、Selection に対し guard とロジック分岐を追加しました。これにより、ノード選択、コードブロック、画像・テキスト混合選択、Turbo Drive によるエディタ破棄といったエッジケースでもエディタがクラッシュせず、ユーザーエクスペリエンスが大幅に向上します。