[Cronジョブ名フィールド空白時のUI無限レンダーループ] - Control UI Infinite Render Loop on Empty Cron Job Name Field
Cron Jobsタブの名前入力フィールドをクリックすると、onFormChangeハンドラでのオブジェクト参照変動により無限リアクティブループが発生し、CPU使用率99.6%とダッシュボードのフリーズを引き起こします。
🔍 症状
技術的症状
この問題は、特定の入力フィールドとやり取りする際にクライアント側で完全にフリーズします:
- CPU使用率の急上昇: Firefoxコンテンツプロセスが利用可能なCPUリソースの
99.6%を無期限に消費します - WebSocketの劣化: ゲートウェイ接続が
40s+ ハンドシェイクタイムアウトエラーでタイムアウトし始めます - タブの無応答: ダッシュボードが完全にフリーズし、すべての操作が応答しなくなります
- ゲートウェイの整合性: OpenClawゲートウェイ自体は正常に動作し、他のクライアントには応答します
再現手順
1. 移動先:http://localhost:3000/__openclaw__/control/
2. 「Cron Jobs」タブをクリック
3. 「Name」入力フィールドをクリック(入力なし)
4. ダッシュボードが即座にフリーズ
ブラウザコンソールのインジケーター
[Firefox DevTools Console]
⚠ このページは、期待以上に長く実行されているレンダーループを
持っているようです。コンポーネントのコードを最適化することを検討してください。
Source: openclaw-control-ui/cron-form.js
プロセスの動作
# ブラウザタスクマネージャーでの観測:
ダッシュボードタブ PID 12345
- CPU: 99.6%(継続的に上昇中)
- メモリ: 847MB → 1.2GB(ループ中にリーク)
- 状態: 実行中(応答なし)
🧠 原因
リアクティブループの仕組み
無限レンダーループは、LitのリアクティブプロパティシステムとネイティブDOM入力イベントの間の循環依存関係によって発生します。問題のあるコードは、Cron Jobsフォームコンポーネントに存在します:
// 問題のあるソース: src/components/cron-form.js (約47行目)
onFormChange: p => {
e.cronForm = _o({...e.cronForm, ...p}),
e.cronFieldErrors = Qn(e.cronForm)
}
失敗シーケンスの分析
リアクティブループは次の正確な実行パスをたどります:
- ユーザーが空の入力フィールドをクリック — ブラウザは現在の(空の)値でネイティブ
@inputイベントを発生させます - onFormChangeが実行 — ハンドラーが
{name: ""}を受け取る - スプレッド演算子が新しいオブジェクトを作成 —
{...e.cronForm, ...{name: ""}}は、値が同一であっても新しいオブジェクト参照を生成します - Litがプロパティ変更を検出 —
@state()または@property()デコレータ付きcronFormセッターが再レンダーをトリガーします - 再レンダーが入力値を読み取り — コンポーネントの
render()またはテンプレートアクセッサがDOM入力の現在の値("")を読み取ります - DOMイベントが再度発生 — 一部の実装では、入力値を読み取ると
@inputハンドラーが再発生します - ループが繰り返す — ステップ2〜6が無限に実行されます
オブジェクト参照の回転図
cronForm (初期): {name: "", schedule: "0 * * * *"}
↓ @input イベントが {name: ""} で発生
cronForm (再代入): {name: "", schedule: "0 * * * *"} ← 新しいオブジェクト参照
↓ Lit が !== 比較で変更を検出
再レンダーがトリガー
↓ テンプレートが input.value を読み取り
@input が再度発生: {name: ""}
↓ 同じ構造、新しい参照
cronForm (再代入): {name: "", schedule: "0 * * * *"} ← ループが続く
Litの標準的な等価性チェックが失敗する理由
Litはプロパティ変更検出に!==を使用します:
// Litの内部プロパティセッター(簡略化)
set value(newVal) {
if (this._value !== newVal) { // Object !== Object (異なる参照)
this._value = newVal;
this.requestUpdate(); // 常に出力をリクエスト
}
}
スプレッド演算子は新しいオブジェクト参照を保証するため、コンテンツ等価性最適化をバイパスします。
🛠️ 解決手順
推奨ソリューション:浅い等価性チェック
onFormChangeハンドラーを変更して、再代入前に値を比較します:
// 修正前(問題あり)
onFormChange: p => {
e.cronForm = _o({...e.cronForm, ...p}),
e.cronFieldErrors = Qn(e.cronForm)
}
// 修正後(修正済み)
onFormChange: p => {
const merged = { ...e.cronForm, ...p };
// 浅い等価性チェック - 値が実際に変わった場合のみ更新
const hasChanged = Object.keys(p).some(
key => e.cronForm[key] !== merged[key]
);
if (hasChanged) {
e.cronForm = _o(merged);
e.cronFieldErrors = Qn(e.cronForm);
}
}
代替ソリューション:JSONによる深い比較
ネストされたフォーム構造には、JSONシリアライズ比較が完全なチェックを提供します:
onFormChange: p => {
const merged = { ...e.cronForm, ...p };
const serialized = JSON.stringify(merged);
const currentSerialized = JSON.stringify(e.cronForm);
if (serialized !== currentSerialized) {
e.cronForm = _o(merged);
e.cronFieldErrors = Qn(e.cronForm);
}
}
実装手順
- ファイルの特定:
src/components/cron-form.jsまたはsrc/components/cron-jobs/cron-form.ts - onFormChangeメソッドを見つける — CronFormクラス内
- 上記のパッチ適用版に置き換える
- 複数のフォームで同じパターンがある場合は、再利用可能なユーティリティ関数を追加
- 以下の検証手順を使用して修正を確認する
予防的修正:ラッパーユーティリティ
すべてのフォームコンポーネントに適用するための再利用可能なsafeUpdateFormユーティリティを作成します:
// src/utils/form-helpers.js
/**
* リアクティブフォームオブジェクトを安全に更新し、
* 値が実際に変わっていない場合に不必要な再レンダーを防止します。
*
* @param {Object} currentForm - 現在のフォーム状態
* @param {Object} updates - 適用する部分的な更新
* @param {Function} validator - オプションのバリデーション関数
* @returns {Object|null} - 新しいフォーム状態または未変更の場合はnull
*/
export function safeUpdateForm(currentForm, updates, validator) {
const merged = { ...currentForm, ...updates };
const hasChanged = Object.keys(updates).some(
key => currentForm[key] !== merged[key]
);
if (!hasChanged) {
return null;
}
return validator ? validator(merged) : merged;
}
// コンポーネントでの使用:
onFormChange: p => {
const newForm = safeUpdateForm(e.cronForm, p, _o);
if (newForm) {
e.cronForm = newForm;
e.cronFieldErrors = Qn(e.cronForm);
}
}
🧪 検証
検証コマンドと期待される出力
修正を適用した後、以下の検証手順を実行します:
1. 機能的相互作用テスト
# Nameフィールドをクリックし、CPU使用率の急上昇がないことを確認
1. ブラウザDevToolsを開く(F12)
2. Cron Jobsタブに移動
3. DevToolsでパフォーマンスモニタタブを開く
4. 観察:空のフィールドを操作する際、CPUは5%以下に維持される
2. 自動テストケース
修正を確認するためのテストを作成します:
// tests/cron-form.test.js
import { fixture, expect } from '@open-wc/testing';
import { sendKeys } from '@web/test-runner-commands';
describe('cron-form', () => {
it('should not trigger infinite render on empty input focus', async () => {
const el = await fixture(' ');
const updatesBefore = el.updateCount || 0;
const nameInput = el.shadowRoot.querySelector('input[name="name"]');
// 入力せずにフォーカス
nameInput.focus();
// ループの可能性がある程度待つ
await new Promise(r => setTimeout(r, 500));
const updatesAfter = el.updateCount || 0;
// 過度な更新がないはず
expect(updatesAfter - updatesBefore).to.be.lessThan(5);
});
});
3. 手動検証チェックリスト
- CPU監視: ブラウザタスクマネージャーでアイドル時および通常の操作中のCPUが5%未満
- 入力フォーカス: 空のNameフィールドをクリックしても副作用なし
- フォーム送信: Cronジョブフォームが有効なデータで正しく送信
- バリデーション: 無効な入力に対してエラーメッセージが正しく表示
- WebSocketの健全性: ゲートウェイ接続が安定(40s以上のタイムアウトなし)
4. 回帰テスト
# フォームの変更が正しく伝わることを確認
1. Nameに「my-cron-job」を入力
2. Scheduleに「0 * * * *」を入力
3. cronFieldErrorsが適切に更新されることを確認
4. フォームを送信し、ジョブ作成を確認
⚠️ よくある落とし穴
環境固有のトラップ
- 開発版と本番ビルド: 開発環境でのホットモジュール交換(HMR)は、より頻繁に状態をリセットすることで問題を隠す可能性があります。修正を確認するには本番ビルドでテストしてください。
- ブラウザの違い: Firefox ESRはイベント処理のため、この動作をより顕著に示します。ChromeとSafariは内部最適化で症状を隠すかもしれませんが、根底のループはまだ存在します。
- Dockerコンテナの制限: ダッシュボードをDockerで実行している場合、コンテナのCPU制限により異なる失敗モード(プロセスキル対無限ループ)が発生する可能性があります。
避けるべき実装ミス
- 比較の代わりにdeepCloneを使用する:
// 間違い - コストがかかり根本原因を解決しない onFormChange: p => { e.cronForm = _o(JSON.parse(JSON.stringify({...e.cronForm, ...p}))), // ... } - バリデーションの再計算をスキップする:
// 間違い - 値が合法的に変わったときにバリデーションが壊れる onFormChange: p => { const merged = { ...e.cronForm, ...p }; // オブジェクト参照が異なっても常にバリデーションを更新 e.cronFieldErrors = Qn(merged); // ... } - デバウンスなしでシリアライズ文字列を比較する:
// リスクあり - 連続した高速入力で問題が発生する可能性 onFormChange: p => { // 高速で入力すると、有効な更新をスキップする可能性がある if (JSON.stringify(e.cronForm) !== JSON.stringify({...e.cronForm, ...p})) { // ... } }
エッジケース
- null/undefined値: 等価性チェックが
null、undefined、空文字列を一貫して処理することを確認 - 数値のゼロ対空:
0は「値なし」として扱われるべきではない - チェックボックス/ブール値フィールド:
falseからfalseへの更新はトリガーしない - 配列フィールド: フォームに配列が含まれている場合、適切な比較を使用(多くの場合浅い比較で十分)
関連するコンポーネントパターン
コードベースの他のフォームコンポーネントが同じパターンを使用している場合、監査して修正してください:
# コードベース全体で問題のあるパターンを検索
grep -r "cronForm = _o({...e.cronForm" src/
grep -r "onFormChange.*spread" src/
grep -r "@state()\s*\n.*Form" src/
🔗 関連するエラー
文脈的に関連する問題
WEB_SOCKET_HANDSHAKE_TIMEOUTブラウザタブが応答しなくなる导致的WebSocket接続のタイムアウト。ゲートウェイは正常だが、クライアントは受信メッセージを処理できない。CONTENT_PROCESS_HIGH_CPUFirefox固有のエラーで、コンテンツプロセスでの過度なCPU消費を示すことが多く、JavaScriptの無限ループの兆候。LIT_UPDATE_CYCLE_EXCEEDED単一タスクで太多のレンダリングサイクルが発生した場合のLitフレームワーク警告。タブがフリーズする前にコンソールに表示される可能性がある。FORM_VALIDATION_STALEフォーム状態がバリデーション処理より速く更新されるときの関連するバリデーション不整合エラー。
類似の過去の問題
- Lit Issue #1427: リアクティブプロパティでのオブジェクトスプレッドによる無限ループ他のLitベースのアプリケーションに影響する同じパターンのフレームワークレベルでの文書化。
- React useStateの等価性比較適切なメモ化なしの
setState({...state, value})が無限再レンダーを引き起こす同様の антиパターン(反パターン)。
将来の開発ための予防策
- リンティングルールの追加: Litプロパティセッターでのオブジェクトスプレッドパターンを検出するESLintルールを追加
- コンポーネントテスト: コンポーネントテストスイートにレンダリングサイクルカウントを含める
- パフォーマンス予算: CI/CDパイプラインでの過度な再レンダーに対するアラートを設定