Attachment Node のリファクタリングによる競合状態の防止
Lexicalエディタを使用したWYSIWYGエディタ「lexxy」で、添付ファイルノードの設計を刷新し、複数アップロードが発生する競合状態を解消しました。
背景
従来の実装では、兄弟ノード間で相互に状態を更新し合う設計により、カスケード的な競合状態が発生していました。#690では、更新処理をノード自身が管理する「より Lexical らしい」OO設計に変更することで、カスタムイベントのディスパッチ/ハンドリングロジックを削除しています。
具体的には、以下のような問題がありました:
- ノードの更新時に
lexxy:internal:invalidate-nodeカスタムイベントを発火 - 別のリスナーがこのイベントを捕捉し、ノードを外部から更新
- この更新が連鎖的にトリガーされ、アップロードが多重実行される
技術的な変更
1. エディタ参照の内部管理
ノードのコンストラクタで$getEditor()を使用し、エディタへの参照を保持するようにしました。
constructor(node, key) {
// ...
this.editor = $getEditor();
}
これにより、イベントハンドラー登録時にエディタ参照を渡し回す必要がなくなりました。
2. updateDOMの実装変更
従来はupdateDOM()が常にtrueを返し、DOM全体を再作成していましたが、必要な部分のみを更新するように変更:
変更前:
updateDOM() {
return true
}
変更後:
updateDOM(_prevNode, dom) {
const caption = dom.querySelector("figcaption textarea");
if (caption && this.caption) {
caption.value = this.caption;
}
return false
}
falseを返すことで、Lexicalに対してDOM要素を再利用可能であることを示します。
3. カスタムイベントシステムの削除
エディタ要素からlexxy:internal:invalidate-nodeイベントリスナーを削除:
// 削除されたコード
#listenForInvalidatedNodes() {
this.editor.getRootElement().addEventListener("lexxy:internal:invalidate-node", (event) => {
const { key, values } = event.detail
this.editor.update(() => {
const node = $getNodeByKey(key)
if (node instanceof ActionTextAttachmentNode) {
const updatedNode = node.getWritable()
Object.assign(updatedNode, values)
}
})
})
}
4. アップロード処理のノード内包化
ActionTextAttachmentUploadNode内でアップロード処理を完結させ、外部イベントに依存しない設計に:
createDOM() {
if (this.uploadError) return this.#createDOMForError()
// DOM作成時に1回だけアップロードを開始
this.#startUploadIfNeeded()
const figure = this.createAttachmentFigure()
// ...
}
#startUploadIfNeeded() {
if (this.progress !== null) return // 既にアップロード中または完了
if (this.uploadStarted) return // 多重実行防止
this.uploadStarted = true
this.#updateProgress(0)
this.#executeUpload()
}
アップロード完了時は、ノード自身をActionTextAttachmentNodeに置き換えます:
#handleSuccessfulUpload(attachment) {
this.editor.update(() => {
const attachmentNode = new ActionTextAttachmentNode({
sgid: attachment.attachable_sgid,
// ...
})
this.replace(attachmentNode)
}, { tag: SILENT_UPDATE_TAGS[0] })
}
5. 状態更新の最適化
アップロード進捗の更新時、SILENT_UPDATE_TAGSを使用して不要な履歴記録やスクロール処理を抑制:
export const SILENT_UPDATE_TAGS = [
HISTORY_MERGE_TAG,
SKIP_DOM_SELECTION_TAG,
SKIP_SCROLL_INTO_VIEW_TAG
]
#updateProgress(progress) {
this.editor.update(() => {
const self = this.getWritable()
self.progress = progress
}, { tag: SILENT_UPDATE_TAGS[0] })
}
設計判断
PRでは、画像のローカルプレビュー読み込み処理が削除されています。これは垂直方向のコンテンツフラッシュを防ぐためのものでしたが、ノードが別のタイプに置き換わる際にDOM全体が置換されるため、実際には効果がありませんでした。
一方で、this.editor参照の保持は「真の Lexical スタイル」ではないものの、実用性を優先しています。これにより、イベントハンドラー登録時のエディタ参照の受け渡しが不要になり、コードがシンプルになっています。
コミット履歴を辿りやすくするため、変更は段階的にコミットされています。