チェックボックス・入力コントロールの属性/プロパティ分離とSSR対応の修正
WebAwesomeのフォーム関連コンポーネント群で、属性(attribute)とプロパティ(property)の分離が不完全だった問題と、サーバーサイドレンダリング(SSR)環境でのグローバルAPI呼び出しによるエラーを一括修正したPRです。
背景
今回の修正は、複数の独立したバグが共通する設計上の根本原因を持っていたことで、一括対応が可能でした。問題は大きく「属性とプロパティの分離」と「SSR非対応API呼び出し」の2カテゴリに分類されます。
属性とプロパティの分離については、HTMLの仕様上、checked 属性はデフォルト値(defaultChecked)を表し、プロパティがユーザーの現在の状態を保持します。しかし <wa-checkbox> や <wa-switch> では checked プロパティが this.hasAttribute('checked') で初期化されており、属性とプロパティが混同されていました。この設計の結果、ReactやVue 3のフレームワークからプロパティバインディング(:checked)で値を渡しても初回レンダリング時に反映されないという問題が発生していました(#1878、#1230)。
SSR非対応API呼び出しについては、<wa-drawer> と <wa-dialog> のモジュールトップレベルで document.addEventListener を呼び出していたため、document オブジェクトが存在しないサーバー環境でインポートするだけでエラーになっていました(#1860)。同様に HasSlotController が childNodes や querySelector などのクライアント専用APIをSSR環境でも呼び出していました(#1861)。
技術的な変更
checked プロパティのget/setアクセサへの置き換え
<wa-checkbox> と <wa-switch> の checked プロパティが、単純なフィールドからget/setアクセサに置き換えられました。これがフォームコントロールの属性/プロパティ分離における中心的な変更です。
変更前(checkbox.ts):
@property({ type: Boolean, attribute: false }) checked: boolean = this.hasAttribute('checked');
変更後(checkbox.ts):
_checked: boolean | null = null;
get checked() {
if (this.valueHasChanged) {
return Boolean(this._checked);
}
return this._checked ?? this.defaultChecked;
}
@property({ type: Boolean, attribute: false })
set checked(val: boolean) {
this._checked = Boolean(val);
this.valueHasChanged = true;
}
_checked を null で初期化し、valueHasChanged フラグで「プロパティが明示的にセットされたか」を追跡する設計です。プロパティが未設定の場合は defaultChecked(=checked 属性の値)にフォールバックし、一度でもプロパティがセットされれば _checked の値を優先します。同様のパターンが <wa-switch> にも適用されています。
また、defaultChecked の変更を監視していた @watch('defaultChecked') デコレータが削除され、代わりに connectedCallback での初期化と @watch(['checked', 'defaultChecked']) での一元的な変更監視に変更されました。
フォームリセット時のシャドウDOM内inputの明示的なリセット
<wa-input> と <wa-textarea> のフォームリセット処理が修正されました。
変更前(input.ts):
formResetCallback() {
this.value = this.defaultValue;
super.formResetCallback();
}
変更後(input.ts):
formResetCallback() {
this.value = null;
if (this.input) {
// @ts-expect-error
this.input.value = this.value;
}
super.formResetCallback();
}
従来の実装では this.value = this.defaultValue とした際、LitElementのリアクティブプロパティはシャドウDOM内のネイティブ <input> 要素のライブ値(.value プロパティ)を更新しないケースがありました。明示的に this.input.value を書き換えることで、リセット後に内部inputの実際の値が確実にクリアされます(#1640)。
さらに updated() メソッドで defaultValue の変更時にも updateValidity() を呼び出すよう修正し、required な <wa-input> に対してプロパティで値をセットした際にバリデーション状態が即座に更新されるようになりました(#1205)。
radio-group と slider の defaultValue 変更への対応
<wa-radio-group> の updated() フックで defaultValue の変更も syncRadioElements() のトリガーに追加されました。これにより、属性(value attribute)が動的に変更された際に defaultValue 経由でラジオボタンの選択状態が同期されるようになります(#1273)。
<wa-slider> では _value の型が number から number | null に変更され、valueHasChanged フラグで属性由来のデフォルト値とプロパティ由来の値を区別するようになりました。value ゲッターでは clamp() を呼び出す場所を値の返却時に統合し、フォームリセット時に _value = null と valueHasChanged = false をセットすることで、リセット後は属性値(defaultValue)にフォールバックする動作が実現されています。
drawer と dialog のトップレベルAPI呼び出しを isServer でガード
<wa-drawer> と <wa-dialog> で、クラス定義の外部(モジュールトップレベル)に置かれていた document.addEventListener 呼び出しが、既存の isServer フラグによる条件分岐の内側に移動されました。
変更前(drawer.ts):
// モジュールトップレベルに配置
document.addEventListener('click', (event: MouseEvent) => {
// data-drawer="open ..." の処理
});
if (!isServer) {
// Safari の light dismiss 対応
document.body.addEventListener('pointerdown', ...);
}
変更後(drawer.ts):
if (!isServer) {
// data-drawer="open ..." の処理もサーバーでは実行しない
document.addEventListener('click', (event: MouseEvent) => {
// ...
});
// Safari の light dismiss 対応
document.body.addEventListener('pointerdown', ...);
}
これでモジュールをインポートするだけでSSRがクラッシュしていた問題が解消されます。
HasSlotController のSSR対応
HasSlotController では3箇所にSSR対応の防御的コードが追加されました。
-
hasDefaultSlot():this.host.childNodesが存在しない場合はfalseを返す早期リターンを追加 -
hasNamedSlot():querySelectorをオプショナルチェーン(querySelector?.)で呼び出すように変更 -
hostConnected()/hostDisconnected():shadowRoot?.addEventListener?.とshadowRoot?.removeEventListener?.でオプショナルチェーンを使用
これらの変更により、HasSlotController を使用するすべてのコンポーネント(<wa-drawer>、<wa-button> など)がSSR環境の render() メソッド内で安全に動作するようになります。
設計判断
valueHasChanged フラグによる属性/プロパティ分離が、複数コンポーネントにわたる一貫したパターンとして採用されました。
HTMLネイティブの <input> 要素では defaultValue 属性と value プロパティが独立して管理され、フォームリセット時はプロパティが defaultValue に戻ります。このPRではカスタム要素でも同等のセマンティクスを実現するため、「プロパティが一度でも明示的にセットされたか」を valueHasChanged で追跡するアプローチを統一的に適用しています。_checked = null という初期値は「まだプロパティがセットされていない」状態を表す番兵値として機能しており、null チェックにより属性由来の defaultChecked へのフォールバックを自然に実現しています。
SSRの修正では、既存の isServer ユーティリティを最大限に活用する方針が一貫しています。HasSlotController でのオプショナルチェーン採用は、isServer による明示的な条件分岐を避けながら、サーバー環境での null / undefined なオブジェクトへのアクセスを安全に無効化する簡潔な手法です。
まとめ
本PRは、HTMLの属性/プロパティ分離のセマンティクスをWebAwesomeのカスタム要素に正確に実装し直すと同時に、SSR環境でのグローバルAPI依存を排除した修正です。valueHasChanged フラグを軸とした設計パターンが複数コンポーネントに統一的に適用されており、ReactやVue 3などのフレームワーク連携でのバインディング問題、フォームリセット後のバリデーション不整合、SSRクラッシュといった複数のバグが共通の根本原因から解消されたことがわかります。