SSRガードの追加と `with-*` 属性によるスロット検出の改善

shoelace-style/webawesome

Lit SSRを利用する際にブラウザ専用APIへのアクセスでクラッシュしていた複数のコンポーネントに対し、isServer ガードと with-* 属性パターンを体系的に適用し、SSR互換性を大幅に向上させた。

背景

Web AwesomeではLit SSRによるサーバーサイドレンダリングをサポートしているが、複数のコンポーネントがSSR環境で動作しない問題が報告されていた。具体的には以下の3件のIssueが起点となっている。

  • #1858: wa-pagewa-buttonwa-drawer がSSRで動作しない
  • #2174: wa-scrollerResizeObserver is not defined エラーでクラッシュする
  • #2178: HasSlotControllerquerySelector の非存在により undefined !== null となり、サーバーとクライアントで描画結果が異なるハイドレーションミスマッチが発生する

Lit SSRは constructor()connectedCallback() を呼び出すが、firstUpdated()updated()・イベントハンドラは呼び出さない。そのため、connectedCallback() やクラスフィールドの初期化子で ResizeObserverMutationObserverdocument.* などのブラウザ専用APIを直接使用しているコンポーネントがすべてSSRで問題を引き起こしていた。

技術的な変更

本PRの変更は大きく2種類に分類される。1つ目は isServer ガードによるブラウザ専用APIの保護、2つ目は hasUpdated ternaryパターンによるスロット検出のSSRフォールバックだ。

isServer ガードによるブラウザ専用APIの保護

ResizeObserverMutationObserverdocument.createComment など、ブラウザ環境にしか存在しないAPIを使用している箇所に isServer ガードが追加された。影響を受けたコンポーネントは以下の通り。

  • wa-scroller: クラスフィールド初期化子での ResizeObserver インスタンス化を connectedCallback() 内に移動し、isServer ガードで保護
  • wa-resize-observer: connectedCallback() 内の ResizeObserver 生成を isServer ガードで保護
  • wa-split-panel: connectedCallback() 内の ResizeObserver 関連コードを isServer ガードで保護
  • wa-tab-group: connectedCallback() の先頭に if (isServer) return; を追加
  • wa-tree: MutationObserver の生成と setAttribute 呼び出しを isServer ガードで保護
  • wa-carousel: setAttribute 呼び出しを isServer ガードで保護
  • wa-zoomable-frame: クラスフィールドの MutationObserver インスタンス化を !isServer ? new MutationObserver(...) : null の条件式で保護
  • WebAwesomeElement(基底クラス): document.createComment によるコメントノード挿入を isServer ガードで保護

wa-scroller での変更を例に示す。

変更前:

private resizeObserver = new ResizeObserver(() => this.updateScroll());

connectedCallback() {
  super.connectedCallback();
  this.resizeObserver.observe(this);
}

disconnectedCallback() {
  super.disconnectedCallback();
  this.resizeObserver.disconnect();
}

変更後:

private resizeObserver: ResizeObserver | null = null;

connectedCallback() {
  super.connectedCallback();

  // SSR guard: ResizeObserver is not available during server-side rendering
  if (!isServer) {
    this.resizeObserver = new ResizeObserver(() => this.updateScroll());
    this.resizeObserver.observe(this);
  }
}

disconnectedCallback() {
  super.disconnectedCallback();
  this.resizeObserver?.disconnect();
}

フィールドの型を ResizeObserver | null に変更し、disconnectedCallback() でのアクセスをオプショナルチェーン(?.)に統一している。

hasUpdated ternaryパターンによるスロット検出のSSRフォールバック

SSR環境では HasSlotController が内部で呼ぶ querySelector が存在しないため、undefined !== nulltrue になるというバグ(#2178)が存在した。これに対して、render() メソッド内のスロット検出に hasUpdated ternaryパターンが導入された。

// SSR時(hasUpdated === false)はwith-*プロパティを使用
// ハイドレーション後(hasUpdated === true)はHasSlotControllerに委譲
const hasFooter = this.hasUpdated ? this.hasSlotController.test('footer') : this.withFooter;

このパターンに対応するため、wa-dialogwa-drawerwa-buttonwa-slider などに新しい with-* プロパティが追加された。例えば wa-button では withStartwithEnd が追加されている。

@property({ attribute: 'with-start', type: Boolean }) withStart = false;
@property({ attribute: 'with-end', type: Boolean }) withEnd = false;

// render() 内
'has-start': this.hasUpdated ? this.hasSlotController.test('start') : this.withStart,
'has-end': this.hasUpdated ? this.hasSlotController.test('end') : this.withEnd,

既存コンポーネント(wa-inputwa-textareawa-selectwa-radio-group 等)の with-labelwith-hint プロパティについては、JSDocコメントを「SSR専用である」ことが明確になるよう統一的な文言に改訂した。

wa-textarea のパフォーマンス改善

wa-textarea では、connectedCallback() で無条件に ResizeObserver を生成していたコードを削除し、代わりに updateResizeObserver() メソッドを導入して必要な場合のみ生成する遅延初期化に変更した。disconnectedCallback() でのクリーンアップも unobserve() から disconnect() + undefined への代入に整理されている。

設計判断

isServer ガードを各コンポーネントに直接記述する方式が採用された。globalThis へのポリフィルシムを提供する回避策は、コントリビューティングガイドで明示的に禁止されている。これはポリフィルが隠れた依存関係を生み出し、将来的な保守コストを高めるためだと読み取れる。

hasUpdated ternaryパターンは、HasSlotController 自体を改修してSSR対応させるのではなく、render() メソッドの呼び出し側で分岐する設計だ。PRの説明では HasSlotController の変更は「より議論を要する可能性がある」として後続PRに委ねると明記されており、本PRでは安全で影響範囲の小さい方法が優先された。

with-* 属性のデフォルト値を false にしていることも重要な判断だ。開発者はSSRを使用する場合にのみこれらの属性を明示的に指定する必要があり、クライアントサイドのみのアプリケーションに影響しない。ハイドレーション後は hasUpdatedtrue になることで HasSlotController が自動的に引き継ぐため、with-* 属性を指定したまま残しても二重描画などの問題は発生しない。

まとめ

本PRは、個別のバグ修正を積み重ねるのではなく、isServer ガードと hasUpdated ternaryパターンという2つの共通パターンをコントリビューティングガイドとともに確立した点に意義がある。今後コンポーネントを追加・修正する際の規範が明文化されたことで、SSR互換性の維持がより体系的に行えるようになる。

記事メタデータ

Generated by:
Claude Sonnet 4.6 for DiffDaily
LLM Trace:
4f557316

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

品質レビュー結果

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

Review Criteria:

記事構成 ✓ PASS

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

「総論(リード文)→各論(背景、技術的な変更、設計判断)→結論(まとめ)」の構成が明確です。必須要素がすべて含まれており、論理的な流れが構築されています。

カスタムMarkdown構文 ✓ PASS

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

ファイル名付きシンタックスハイライト(```言語:ファイルパス)や、PR・Issue番号のリンク記法([#123](URL))が正しく使用されています。

対象読者への適合性 ✓ PASS

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

Lit SSR、Web Componentsなどの専門知識を持つエンジニアを対象としており、冗長な説明がなく、適切な技術レベルで書かれています。

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

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

各セクションが「総論→各論」で構成され、ほとんどの段落がトピックセンテンスで始まっています。1段落1トピックの原則が守られ、段落長も適切です。

Diff内容との照合 ✓ PASS

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

記事内で引用されているコードブロック(wa-scroller, wa-buttonなど)は、提供されたDiffの内容と正確に一致しています。ファイルパスも正確です。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

「Lit SSR」「isServer」「HasSlotController」「ハイドレーションミスマッチ」など、技術用語が文脈に応じて正確に使用されています。

説明の技術的正確性 ✓ PASS

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

「isServer」ガードの目的や「hasUpdated」ternaryパターンの仕組みに関する説明は、PRの変更内容やLitのSSRの仕様と整合しており、技術的に正確です。

事実の突合 ✓ PASS

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

記事内の主張はすべてPRのDescriptionやDiff内のコード変更、コメントから裏付けられています。特に「設計判断」セクションは、PR Descriptionの記述を根拠としており、ハルシネーションは見られません。

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

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

PR番号(#2237)や修正対象のIssue番号(#1858, #2174, #2178)が正確に記載されています。

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

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

記事のタイトル「SSRガードの追加と `with-*` 属性によるスロット検出の改善」は、PRのタイトル「Add SSR guards」の内容を包含しつつ、より具体的に変更点を要約しており、PR内容と完全に一致しています。

外部知識の正確性 ✓ PASS

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

記事の内容はPR情報に限定されており、サポート状況やリリース日程といったPR外の知識の追記はありません。

時間表現の正確性 ✓ PASS

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

時間表現に関する歪曲はなく、PRの変更内容を正確な時制で記述しています。