初期化時のスタイル再計算を4つのアプローチで削減

basecamp/lexxy

重いDOMを持つページでの初期化時に発生するブラウザのスタイル再計算(UpdateLayoutTree)を、互いに独立した4つの変更で大幅に削減します。Basecampのチャットページ(DOM要素数 約17,000)での計測では、初期化中に49回発生していたUpdateLayoutTreeイベント(合計約1,168ms)が主なターゲットです。

背景

前回のPRではlexxy-editorホスト要素にcontainを適用し、エディタ内部のミューテーションがホスト境界を越えないようにしていました。しかしそれでも、document.bodyへの一時ノードのアタッチと、冗長なリコンサイラサイクルが非エディタツリーを撹乱し続けていました。

ブラウザのスタイル再計算コストは、DOMツリーのどこかで要素が追加・削除されるたびに、:has()や子孫コンビネータ、ユニバーサル兄弟ルールといった祖先依存セレクタの一致評価がドキュメント全体に波及することで生じます。Basecampのような大規模なCSSを持つページでは、たとえ小さなDOM操作であっても1回あたり13,000以上の要素を走査するスタイル再計算が発生していました。

このPRは残存する4つの発生源をそれぞれ独立したコミットで封じることで、問題を体系的に解消します。

技術的な変更

1. 厳密に隔離されたスタイルリゾルバルート(415b8518

computeStyleValuesStyleCanonicalizer用)とeditor.js#resolveColorsが計算スタイル読み取りのために一時的なspan要素をdocument.bodyやエディタホストに直接アタッチしていた問題を、新しいヘルパー styleResolverRoot() で解決しました。

新たに追加された src/helpers/style_resolver_root.js は、contain: strict を持つ <div> を一度だけ作成してdocument.bodyに追加する共有ルートを提供します。

let resolverRoot = null

export function styleResolverRoot() {
  if (resolverRoot && resolverRoot.isConnected) return resolverRoot

  resolverRoot = document.createElement("div")
  resolverRoot.setAttribute("aria-hidden", "true")
  resolverRoot.setAttribute("data-lexxy-style-resolver", "")
  resolverRoot.style.cssText = "contain: strict; position: fixed; top: 0; left: 0; visibility: hidden; pointer-events: none; width: 0; height: 0;"
  document.body.appendChild(resolverRoot)
  return resolverRoot
}

contain: strict は size・layout・paint・style の全隔離を意味します。この要素自体は visibility: hidden でペイントされず、position: fixed と明示的なゼロサイズにより幾何的影響もゼロです。computeStyleValues の呼び出し先が document.body.appendChild(fragment) から styleResolverRoot().appendChild(fragment) に変わることで、以降の子要素ミューテーションは隔離されたサブツリー内に閉じ込められます。

2. set value のrAFワークアラウンドを初回ロード時のみに限定(5794c731

set value 内の空の editor.update(() => {}) は、初期化直後のエディタでコンテンツ設定後に状態が不整合になるLexicalのバグを回避するためのものでした。しかし2回目以降のset value呼び出しではこの状態は発生しないため、余分なリコンサイラサイクルが無駄に発生していました。

変更前:

set value(html) {
  this.editor.update(() => {
    // ...
  })
  // rAFワークアラウンドが毎回実行される
}

変更後:

set value(html) {
  const wasEmpty = !this.#initialValueLoaded

  this.editor.update(() => {
    // ...
  })
  // wasEmpty が true のときだけワークアラウンドを実行
}

新設した #initialValueLoaded フラグにより、ワークアラウンドは初回ロード時のみ発動します。ページロードあたりエディタ1つにつき1回のリコンサイラサイクルを削減します。

3. contenteditable ルートへのCSS containment 追加(bd3e8413

.lexxy-editor__content(LexicalのリコンサイラがDOMミューテーションをコミットするcontenteditable要素)に contain: layout style を追加しました。

/* Isolate the contenteditable root's layout and style. Lexical's reconciler
   commits mutations inside this element (nodes appended, text inserted,
   class flipped) on every update; containment keeps those mutations from
   invalidating ancestor-dependent selectors and sibling layout elsewhere
   in the editor. */
contain: layout style;

外側の lexxy-editor はすでにcontainmentを持ちますが、タイピング・選択変更・初期状態ロード時に細粒度のDOMミューテーションが集中するcontenteditable要素自体をさらに隔離することで、ツールバーや添付ファイルプレビュー、プロンプトメニューといった兄弟要素のレイアウト再評価を防ぎます。

4. フォーカス済み時の editor.focus() をスキップ(a2773bfc

editor.focus() はカーソル位置決定のためにリコンサイラの更新をコミットします。contenteditable がすでにフォーカスを持っている場合、この呼び出しは論理的には何もしませんが、フルのリコンサイラサイクルとスタイル再計算を引き起こしていました。

focus() {
  // `editor.focus()` commits a reconciler update to position the cursor.
  // Skip if the contenteditable already owns focus — the update would be a
  // no-op but still triggers a full style/layout pass on pages with large DOMs.
  if (this.#isContentFocused) return

  this.editor.focus(() => this.#onFocus())
}

get #isContentFocused() {
  return !!this.editorContentElement && this.editorContentElement.contains(document.activeElement)
}

#isContentFocused ゲッターが editorContentElement.contains(document.activeElement) で判定することで、繰り返しフォーカス呼び出しによる無駄なリコンサイラサイクルを排除します。

設計判断

4つの変更を独立したコミットとして管理する方針が採られています。それぞれがレビュー・リバートを単独で行えるよう、変更の粒度が意図的に小さく保たれています。

各変更の性質は一様ではありません。変更1と3はCSSのcontainプロパティによるブラウザへの宣言的なヒントであり、変更2と4はJavaScriptレベルでの条件ガードです。この二層のアプローチにより、DOM隔離とロジック最適化の両面からスタイル再計算の発生源を封じています。

styleResolverRoot() の設計では、resolverRoot.isConnected チェックにより要素がDOMから切り離された場合でも再生成できる防御的な実装になっています。aria-hidden="true"pointer-events: none を付与することで、スクリーンリーダーやマウスインタラクションへの影響も排除されています。

まとめ

本PRは、スタイル再計算の発生源を「DOMへの直接アタッチ」「冗長なリコンサイラサイクル」「不十分なcontainment境界」の3種類に分類し、それぞれに適した手法で個別に封じる構成になっています。contain: strict による隔離ルートの共有化という手法は、計算スタイル読み取りが必要な箇所を今後拡張する際のパターンとしても活用できる設計です。

記事メタデータ

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

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

品質レビュー結果

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

Review Criteria:

記事構成 ✓ PASS

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

リード文(総論)→背景・技術詳細・設計判断(各論)→まとめ(結論)という「総論→各論→結論」の構成が記事全体とセクション単位で適用されており、非常に明快です。

カスタムMarkdown構文 ⚠ WARNING

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

PR番号のリンクやファイル名付きコードブロックは正しく記述されています。しかし、本文中で言及されているコミットIDがリンク化されていません。

対象読者への適合性 ✓ PASS

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

「スタイル再計算」「DOM」「リコンサイラサイクル」などの専門用語が適切に使用されており、専門知識を持つエンジニアという対象読者に適合しています。

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

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

各セクションが総論→各論の構造を持ち、各パラグラフがトピックセンテンスで始まるなど、パラグラフ・ライティングの原則が守られており、高い可読性を実現しています。

Diff内容との照合 ⚠ WARNING

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

引用されているコードは概ねDiffと一致しています。ただし、「set value」の変更前コードはDiffに存在しないものを説明のために記述しています。これは変更の理解を助ける有益な工夫ですが、厳密にはDiff外のコードです。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

PRで使われている `UpdateLayoutTree`, `contain: strict`, `reconciler` などの技術用語を正確に使用しており、技術的な信頼性が高いです。

説明の技術的正確性 ✓ PASS

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

CSSの `contain` プロパティの役割や、各変更がなぜスタイル再計算を削減するのかについての説明は、技術的に正確かつ論理的です。

事実の突合 ✓ PASS

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

記事内の主張はすべてPRのDescriptionで裏付けられています。特に、パフォーマンス改善に関する具体的な数値(DOM要素数、イベント回数、処理時間)がPR情報と一致しており、ハルシネーションは見られません。

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

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

PR番号(#984)、コミットID(415b8518など)、パフォーマンス計測に関する数値(17,000, 49, 1,168msなど)はすべて正確です。

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

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

記事タイトル「初期化時のスタイル再計算を4つのアプローチで削減」は、PRの主題を的確に要約しており、内容との整合性も取れています。

外部知識の正確性 ✓ PASS

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

記事の内容はPR情報とDiffに限定されており、バージョンサポート情報やリリース予定など、PR外の外部知識の追記はありません。

時間表現の正確性 ✓ PASS

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

「発生していた」「解消します」といった時間表現は、問題が過去に存在し、このPRによって解決されるという文脈と一致しており、正確です。