ツールバーのaria属性を「値が変わるときだけ」書き換えてキーストローク遅延を削減
Lexicalのエディタ更新リスナーがキーストロークごとにツールバーボタンのaria属性を無条件で上書きしていた問題を修正し、実際に値が変化する場合のみ setAttribute を呼ぶようガードを追加しました。これにより、ページ全体のスタイル再計算が走る状況下でのキーストロークあたりのブロッキング時間をp50で約63%削減しています。
背景
Lexxyのツールバーは registerUpdateListener を使ってLexicalのエディタ更新イベントをサブスクライブしており、このリスナーはキーストロークのたびに発火します。リスナー内では約16個のツールバーボタンへの aria-pressed、undo/redoボタンへの aria-disabled、ハイライトドロップダウン内のカラーボタンへの aria-pressed が毎回書き換えられていました。
これらの書き換えのほとんどは値的には変化なし("false" → "false")のno-op書き込みです。しかしChromeは setAttribute が呼ばれるたびに要素スタイルをinvalidateするため、同一フレーム内でページ全体のグローバルスタイル再計算が発生していると、no-opの setAttribute がペンディング中の再計算をsynchronousにフラッシュする引き金になります。PRの記述によると、TurboのブロードキャストリフレッシュによってHTMLルート要素の aria-busy が書き換わるようなケースでは、1回のキーストロークあたり最大約20回の書き込みと約2,300要素の再スタイルが重なり、数百msのブロッキングが発生していました。
no-op書き込みをゲーティングすることで、このスタイル再計算の増幅を排除するのが本PRの目的です。
技術的な変更
変更の核心は「書き込む前に現在値を読み、変化がなければスキップする」という単純なガードパターンの適用です。対象は src/elements/toolbar.js の2メソッドと src/elements/dropdown/highlight.js のカラーボタン更新処理の計3箇所です。
src/elements/toolbar.js — #setButtonPressed:
変更前:
#setButtonPressed(name, isPressed) {
const button = this.querySelector(`[name="${name}"]`)
if (button) {
button.setAttribute("aria-pressed", isPressed.toString())
}
}
変更後:
#setButtonPressed(name, isPressed) {
const button = this.querySelector(`[name="${name}"]`)
if (button) {
const next = isPressed.toString()
if (button.getAttribute("aria-pressed") !== next) {
button.setAttribute("aria-pressed", next)
}
}
}
src/elements/toolbar.js — #setButtonDisabled:
変更前:
#setButtonDisabled(name, isDisabled) {
const button = this.querySelector(`[name="${name}"]`)
if (button) {
button.disabled = isDisabled
button.setAttribute("aria-disabled", isDisabled.toString())
}
}
変更後:
#setButtonDisabled(name, isDisabled) {
const button = this.querySelector(`[name="${name}"]`)
if (button) {
if (button.disabled !== isDisabled) {
button.disabled = isDisabled
}
const next = isDisabled.toString()
if (button.getAttribute("aria-disabled") !== next) {
button.setAttribute("aria-disabled", next)
}
}
}
button.disabled プロパティへの代入も同様にガードされており、setAttribute だけでなくIDLプロパティの変更もスキップ対象になっています。
src/elements/dropdown/highlight.js — カラーボタン:
変更前:
this.#colorButtons.forEach(button => {
const matchesSelection = button.dataset.value === textColor || button.dataset.value === backgroundColor
button.setAttribute("aria-pressed", matchesSelection)
})
変更後:
this.#colorButtons.forEach(button => {
const matchesSelection = button.dataset.value === textColor || button.dataset.value === backgroundColor
const next = matchesSelection.toString()
if (button.getAttribute("aria-pressed") !== next) {
button.setAttribute("aria-pressed", next)
}
})
なお、src/elements/prompt.js にも #setEditorAssociationAttribute ヘルパーの追加と #clearSelection / #clearListItemSelection のリファクタリングが含まれています。プロンプト要素の aria-controls・aria-activedescendant・aria-haspopup にも同じ「読んでから書く」ガードが適用され、同時に #clearSelection が「リストアイテムの選択解除」と「エディタ属性のクリア」を分離する形にリファクタリングされています。
設計判断
「読んでから書く」パターンの一貫適用が選択されました。setAttribute を呼ぶ前に getAttribute で現在値を確認するというシンプルなガードで、ロジックの変更なしに無駄な書き込みを排除しています。PRの記述では「Observable behavior is unchanged」と明示されており、属性の最終的な値は変更前と同一です。
Prompt要素については #setEditorAssociationAttribute という専用ヘルパーメソッドを切り出す判断がなされています。aria-controls・aria-activedescendant・aria-haspopup の3属性すべてに同じパターンを適用する際、コードの重複を避けつつ変更の意図を明確にするためのアプローチです。一方、toolbar.jsのガードはインラインに記述されており、ヘルパー化と直書きを用途によって使い分けています。
本変更で解消されるのはあくまでLexxyが「増幅器」として機能していた部分であり、PRの記述によると同条件での <input> 要素(p50 36ms)と比較してもブロードキャスト起因のスタイル再計算自体は残ります。no-op書き込みのゲーティングは、外部のスタイル無効化との組み合わせで生じる最悪ケースのコストを削減するための最小限の介入です。
まとめ
キーストロークごとに発火するLexical更新リスナー内のno-op setAttribute 呼び出しを、値の等価チェックでガードするだけの変更でありながら、外部からグローバルスタイル再計算が誘発される状況下でのキーストロークレイテンシをp50で63%削減するという効果をもたらしました。「書き込む前に読む」という原則の適用が、ブラウザのスタイル計算のバッチ処理を阻害しないために重要であることを示す事例です。