ツールバーのaria属性を「値が変わるときだけ」書き換えてキーストローク遅延を削減

basecamp/lexxy

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-controlsaria-activedescendantaria-haspopup にも同じ「読んでから書く」ガードが適用され、同時に #clearSelection が「リストアイテムの選択解除」と「エディタ属性のクリア」を分離する形にリファクタリングされています。

設計判断

「読んでから書く」パターンの一貫適用が選択されました。setAttribute を呼ぶ前に getAttribute で現在値を確認するというシンプルなガードで、ロジックの変更なしに無駄な書き込みを排除しています。PRの記述では「Observable behavior is unchanged」と明示されており、属性の最終的な値は変更前と同一です。

Prompt要素については #setEditorAssociationAttribute という専用ヘルパーメソッドを切り出す判断がなされています。aria-controlsaria-activedescendantaria-haspopup の3属性すべてに同じパターンを適用する際、コードの重複を避けつつ変更の意図を明確にするためのアプローチです。一方、toolbar.jsのガードはインラインに記述されており、ヘルパー化と直書きを用途によって使い分けています。

本変更で解消されるのはあくまでLexxyが「増幅器」として機能していた部分であり、PRの記述によると同条件での <input> 要素(p50 36ms)と比較してもブロードキャスト起因のスタイル再計算自体は残ります。no-op書き込みのゲーティングは、外部のスタイル無効化との組み合わせで生じる最悪ケースのコストを削減するための最小限の介入です。

まとめ

キーストロークごとに発火するLexical更新リスナー内のno-op setAttribute 呼び出しを、値の等価チェックでガードするだけの変更でありながら、外部からグローバルスタイル再計算が誘発される状況下でのキーストロークレイテンシをp50で63%削減するという効果をもたらしました。「書き込む前に読む」という原則の適用が、ブラウザのスタイル計算のバッチ処理を阻害しないために重要であることを示す事例です。

記事メタデータ

Generated by:
Claude Sonnet 4.6 for DiffDaily
LLM Trace:
d06f500a

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

品質レビュー結果

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

Review Criteria:

記事構成 ✓ PASS

Title, Context, Technical Detailの存在と明確さ

リード文(総論)→背景・技術詳細・設計判断(各論)→まとめ(結論)という3部構成が明確で、ガイドラインに完全に準拠しています。

カスタムMarkdown構文 ✓ PASS

シンタックスハイライト・GitHubリンク記法の正確性

ファイル名付きのシンタックスハイライト、PR番号のリンク記法ともに正しく使用されています。

対象読者への適合性 ✓ PASS

エンジニア向けの適切な技術レベルと表現

スタイル再計算やLexicalのリスナーなど、専門知識を持つエンジニアを対象とした適切な技術レベルと表現で書かれています。

パラグラフ・ライティング ✓ PASS

トピックセンテンス・1段落1トピック・段落長

各セクションが総論→各論で構成され、各段落はトピックセンテンスで始まり、1段落1トピックの原則が守られています。可読性が非常に高いです。

Diff内容との照合 ✓ PASS

コードブロックとDiff内容の一致

記事内のすべてのコードブロック(変更前・変更後)は、提供されたDiff情報と完全に一致しています。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

「no-op書き込み」「スタイル再計算」「IDLプロパティ」などの技術用語が正確かつ適切に使用されています。

説明の技術的正確性 ✓ PASS

技術的主張の正確性と論理性

「`setAttribute`が同期的なスタイル再計算を誘発する」メカニズムの説明など、技術的な解説が正確かつ論理的です。

事実の突合 ✓ PASS

PR情報による主張の裏付け(ハルシネーション検出)

記事内のすべての主張(パフォーマンス改善の数値、問題の背景、変更の意図など)が、PRのDescriptionで完全に裏付けられています。

数値・固有名詞の確認 ✓ PASS

PR番号・コミットID・バージョン等の正確性

PR番号(#1001)、パフォーマンス削減率(63%)、要素数(約2,300)などの数値や固有名詞はすべて正確です。

タイトル・説明との一致 ✓ PASS

記事タイトル・説明とPR内容の一致

記事のタイトルは、PRの主題である「no-op書き込みのスキップ」とその効果を的確に表現しており、内容と完全に一致しています。

外部知識の正確性 ✓ PASS

PRに記載のない外部知識(LTS、サポート状況など)の不使用

PR情報に記載のないバージョン情報やサポート状況などの外部知識は含まれておらず、事実に忠実です。

時間表現の正確性 ✓ PASS

時間表現がPR情報と一致しているか

「〜していた問題を修正」など、PRの変更内容を過去の事象として正確に記述しており、時間表現に歪曲はありません。