再接続時に拡張機能のツールバーボタンが重複する問題を修正
エディタが再接続されるたびに拡張機能のツールバーボタンが追加され続けるバグを修正しました。initializeToolbars() にボタンの重複排除ロジックを組み込み、拡張機能ごとの対処なしにフレームワーク側で冪等性を保証します。
背景
Lexxyのエディタは再接続(例: 下書きコメントへの再訪問)のたびに initializeToolbar を各拡張機能に対して呼び出します。この設計の中に重複追加の原因が潜んでいました。
ツールバーのDOMは再接続をまたいで持続しますが、拡張機能のインスタンスは再接続のたびに新規作成されます。外部のボタンセレクタで既存ボタンを参照できる拡張機能とは異なり、voiceNote: true のようなboolean値で設定された拡張機能は自前でボタンを生成します。新規インスタンスは以前に挿入したボタンを記憶していないため、再接続のたびにボタンをDOMに追加し続けました。
この問題への対処として、個々の拡張機能が重複チェックを実装するワークアラウンドも検討されましたが(bc3リポジトリの#10261)、本PRはフレームワーク側での根本解決を採用しています。
技術的な変更
src/editor/extensions.js の initializeToolbars() が、ツールバーボタンのクリーンアップと追加を担う2つのプライベートメソッドに分割されました。
変更前:
initializeToolbars() {
if (this.#lexxyToolbar) {
this.enabledExtensions.forEach(ext => ext.initializeToolbar(this.#lexxyToolbar))
}
}
変更後:
initializeToolbars() {
const toolbar = this.#lexxyToolbar
if (!toolbar) return
this.#clearPreviousExtensionToolbarButtons(toolbar)
this.#addExtensionToolbarButtons(toolbar)
}
#clearPreviousExtensionToolbarButtons(toolbar) {
toolbar.querySelectorAll("[data-lexxy-extension]").forEach(el => el.remove())
}
#addExtensionToolbarButtons(toolbar) {
this.enabledExtensions.forEach(ext => {
const childrenBefore = new Set(toolbar.children)
ext.initializeToolbar(toolbar)
for (const child of toolbar.children) {
if (!childrenBefore.has(child)) {
child.setAttribute("data-lexxy-extension", "")
}
}
})
}
仕組みの核心は data-lexxy-extension 属性による追跡です。#addExtensionToolbarButtons は各拡張機能の initializeToolbar 呼び出し前後でツールバーの子要素をスナップショット(Set)として記録し、新たに追加された要素に data-lexxy-extension 属性を付与します。次回の initializeToolbars() 呼び出し時、#clearPreviousExtensionToolbarButtons がこの属性を持つ要素をすべて削除してから再追加を行います。
ブラウザテストも追加されています。test/browser/fixtures/extension-toolbar-button.html と test/browser/fixtures/extension-toolbar-button.js で ToolbarButtonExtension を定義し、test/browser/tests/editor/reconnect.test.js ではエディタのDOMを親から切り離して再挿入することで再接続を再現し、custom-extension-button の数が常に1であることを検証しています。
設計判断
ツールバーの冪等性をフレームワーク側で保証する方針が採用されました。
各拡張機能に重複チェックのロジックを持たせる方法も選択肢でしたが、それでは拡張機能の実装者が再接続ライフサイクルを意識し続ける必要が生じます。data-lexxy-extension 属性によるマーキングを initializeToolbars() が透過的に行うことで、拡張機能側のAPIである initializeToolbar(toolbar) は変更なしに既存コードとの互換性を保っています。
また、ext.initializeToolbar 呼び出しの前後でツールバーの子要素を Set で差分検出する手法は、拡張機能がどの位置にボタンを挿入してもマーキングが機能する汎用的な実装です。拡張機能が insertAdjacentElement を使うケースや複数要素を追加するケースも自然に処理できます。
まとめ
この変更は、再接続ライフサイクルに起因するDOM重複という問題を、data-lexxy-extension 属性という軽量なマーキング機構でフレームワーク側に封じ込めた例です。個々の拡張機能が防御的なコードを持つ必要がなくなり、拡張機能の実装を単純に保ったままエディタの堅牢性を高められます。