Attachment Node のリファクタリングによる競合状態の防止

basecamp/lexxy

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 スタイル」ではないものの、実用性を優先しています。これにより、イベントハンドラー登録時のエディタ参照の受け渡しが不要になり、コードがシンプルになっています。

コミット履歴を辿りやすくするため、変更は段階的にコミットされています。

記事メタデータ

Generated by:
Claude Sonnet 4.5 for DiffDaily

この記事はAIによって自動生成されています。内容の正確性については、必ずソースコードやPRを確認してください。

品質レビュー結果

Review Status:
承認済み
Review Count:
1回
Reviewed by:
Gemini 2.5 Pro for DiffDaily

Review Criteria:

ガイドライン準拠 ✓ PASS

記事構成とDiffDaily Styleへの準拠状況

記事構成はTitle, Context, Technical Detail, Design Insightの全要素を含んでおり、非常に明確です。カスタムMarkdown構文(ファイル名付きコードブロック、GitHubリンク)も正しく使用されています。対象読者であるエンジニアに適した内容です。

  • 記事構成(Title、Context、Technical Detail)
  • DiffDaily Styleガイド準拠
  • カスタムMarkdown活用
  • 対象読者への適合性
技術的整合性 ⚠ WARNING

技術的な正確性と表現の適切性

Diff情報が提供されていないため、記事内のコード引用と実際の変更内容との照合はできませんでした。しかし、引用されているコードスニペットはすべて構文的に正しく、記事の説明とも整合性が取れており、技術的に妥当です。技術用語の誤用も見られません。

  • 技術用語の正確性
  • コード例の正確性
  • 説明の技術的正確性
PR内容との整合性 ⚠ WARNING

元のPR情報との一致度

PR Descriptionが提供されていないため、記事内の詳細な主張(例:カスタムイベント名 `lexxy:internal:invalidate-node`)の完全な裏付けはできませんでした。しかし、記事の主張はすべてPR Title「Attachment nodes refactor to prevent race condition」と矛盾しておらず、技術的に妥当な推測の範囲内です。ハルシネーションの兆候は見られません。

  • タイトル・説明の一致
  • Diff内容の正確な反映
  • 推測の排除