Control UI Infinite Render Loop on Empty Cron Job Name Field
Clicking the Name input field in the Cron Jobs tab triggers an infinite reactive loop due to object reference churn in the onFormChange handler, causing 99.6% CPU usage and dashboard freeze.
๐ Symptoms
Technical Manifestations
The issue manifests as a complete client-side freeze when interacting with a specific input field:
- CPU Spike: Firefox content process consumes
99.6%of available CPU resources indefinitely - WebSocket Degradation: Gateway connections begin timing out with
40s+ handshake timeouterrors - Tab Unresponsiveness: The dashboard becomes completely frozen; no interactions respond
- Gateway Integrity: The OpenClaw gateway itself remains healthy and responsive to other clients
Reproduction Sequence
1. Navigate to: http://localhost:3000/__openclaw__/control/
2. Click the "Cron Jobs" tab
3. Click the "Name" input field (without typing)
4. Dashboard freezes immediately
Browser Console Indicators
[Firefox DevTools Console]
โ This page appears to have a render loop that has been running for
longer than expected. Consider optimizing the component code.
Source: openclaw-control-ui/cron-form.js
Process Behavior
# Observed via browser task manager:
Dashboard Tab PID 12345
- CPU: 99.6% (continuously climbing)
- Memory: 847MB โ 1.2GB (leaking during loop)
- State: Running (unresponsive)
๐ง Root Cause
The Reactive Loop Mechanism
The infinite render loop is caused by a circular dependency between Lit’s reactive property system and native DOM input events. The problematic code exists in the Cron Jobs form component:
// Problematic source: src/components/cron-form.js (approx. line 47)
onFormChange: p => {
e.cronForm = _o({...e.cronForm, ...p}),
e.cronFieldErrors = Qn(e.cronForm)
}
Failure Sequence Analysis
The reactive loop follows this precise execution path:
- User clicks empty input field โ The browser fires a native
@inputevent with the current (empty) value - onFormChange executes โ Handler receives
{name: ""} - Spread operator creates new object โ
{...e.cronForm, ...{name: ""}}produces a new object reference even though the value is identical - Lit detects property change โ The
@state()or@property()decoratedcronFormsetter triggers a re-render - Re-render reads input value โ The component's
render()or template accessor reads the DOM input's current value ("") - DOM event fires again โ Reading the input value in some implementations re-fires the
@inputhandler - Loop repeats โ Steps 2-6 execute infinitely
Object Reference Churn Diagram
cronForm (initial): {name: "", schedule: "0 * * * *"}
โ @input event fires with {name: ""}
cronForm (reassigned): {name: "", schedule: "0 * * * *"} โ NEW OBJECT REFERENCE
โ Lit detects change via !== comparison
Re-render triggered
โ Template reads input.value
@input fires again: {name: ""}
โ Same structure, NEW REFERENCE
cronForm (reassigned): {name: "", schedule: "0 * * * *"} โ LOOP CONTINUES
Why Lit’s Standard Equality Check Fails
Lit uses !== for property change detection:
// Lit's internal property setter (simplified)
set value(newVal) {
if (this._value !== newVal) { // Object !== Object (different references)
this._value = newVal;
this.requestUpdate(); // ALWAYS triggers update
}
}
The spread operator guarantees a new object reference, bypassing any content-equality optimization.
๐ ๏ธ Step-by-Step Fix
Recommended Solution: Shallow Equality Check
Modify the onFormChange handler to compare values before reassignment:
// BEFORE (problematic)
onFormChange: p => {
e.cronForm = _o({...e.cronForm, ...p}),
e.cronFieldErrors = Qn(e.cronForm)
}
// AFTER (fixed)
onFormChange: p => {
const merged = { ...e.cronForm, ...p };
// Shallow equality check - only update if values actually changed
const hasChanged = Object.keys(p).some(
key => e.cronForm[key] !== merged[key]
);
if (hasChanged) {
e.cronForm = _o(merged);
e.cronFieldErrors = Qn(e.cronForm);
}
}
Alternative Solution: Deep Comparison with JSON
For nested form structures, a JSON serialization comparison provides thorough checking:
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);
}
}
Implementation Steps
- Locate the file:
src/components/cron-form.jsorsrc/components/cron-jobs/cron-form.ts - Find the onFormChange method in the CronForm class
- Replace the handler with the fixed version above
- Add a utility function for reuse if multiple forms have the same pattern
- Verify the fix using the verification steps below
Preventive Fix: Wrapper Utility
Create a reusable safeUpdateForm utility to apply across all form components:
// src/utils/form-helpers.js
/**
* Safely updates a reactive form object, preventing unnecessary
* re-renders when values haven't actually changed.
*
* @param {Object} currentForm - The current form state
* @param {Object} updates - The partial updates to apply
* @param {Function} validator - Optional validation function
* @returns {Object|null} - New form state or null if unchanged
*/
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;
}
// Usage in component:
onFormChange: p => {
const newForm = safeUpdateForm(e.cronForm, p, _o);
if (newForm) {
e.cronForm = newForm;
e.cronFieldErrors = Qn(e.cronForm);
}
}
๐งช Verification
Verification Commands and Expected Output
After applying the fix, perform the following verification steps:
1. Functional Interaction Test
# Click the Name field and verify no CPU spike
1. Open browser DevTools (F12)
2. Navigate to the Cron Jobs tab
3. Click the Performance Monitor tab in DevTools
4. Observe: CPU should remain below 5% when interacting with empty fields
2. Automated Test Case
Create a test to verify the fix:
// 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"]');
// Focus without typing
nameInput.focus();
// Wait for potential loop to manifest
await new Promise(r => setTimeout(r, 500));
const updatesAfter = el.updateCount || 0;
// Should not have excessive updates
expect(updatesAfter - updatesBefore).to.be.lessThan(5);
});
});
3. Manual Verification Checklist
- CPU Monitoring: Browser task manager shows <5% CPU during idle and normal interaction
- Input Focus: Clicking empty Name field focuses without side effects
- Form Submission: Cron job form submits correctly with valid data
- Validation: Error messages display correctly for invalid inputs
- WebSocket Health: Gateway connections remain stable (no 40s+ timeouts)
4. Regression Test
# Verify form changes still propagate correctly
1. Fill in Name: "my-cron-job"
2. Fill in Schedule: "0 * * * *"
3. Verify cronFieldErrors updates appropriately
4. Submit form and confirm job creation
โ ๏ธ Common Pitfalls
Environment-Specific Traps
- Development vs Production Builds: Hot Module Replacement (HMR) in development may mask the issue by resetting state more frequently. Test on production builds to confirm the fix.
- Browser Differences: Firefox ESR exhibits this behavior more prominently due to its event handling. Chrome and Safari may hide the symptom through their internal optimizations but the underlying loop still exists.
- Docker Container Limits: When running the dashboard in Docker, container CPU limits may cause different failure modes (process killing vs. infinite loop).
Implementation Mistakes to Avoid
- Using deepClone instead of comparison:
// WRONG - Expensive and doesn't solve the root cause onFormChange: p => { e.cronForm = _o(JSON.parse(JSON.stringify({...e.cronForm, ...p}))), // ... } - Skipping validation recalculation:
// WRONG - Breaks validation when values change legitimately onFormChange: p => { const merged = { ...e.cronForm, ...p }; // Always update validation, even if object reference differs e.cronFieldErrors = Qn(merged); // ... } - Comparing serialized strings without debouncing:
// RISKY - May cause issues with rapid successive inputs onFormChange: p => { // If typing fast, this may skip valid updates if (JSON.stringify(e.cronForm) !== JSON.stringify({...e.cronForm, ...p})) { // ... } }
Edge Cases
- Null/undefined values: Ensure the equality check handles
null,undefined, and empty string consistently - Numeric zero vs empty:
0should not be treated as "no value" - Checkbox/boolean fields:
falsetofalseshould not trigger updates - Array fields: If form contains arrays, use appropriate comparison (shallow for most cases)
Related Component Patterns
If other form components in the codebase use the same pattern, audit and fix them:
# Grep for the problematic pattern across the codebase
grep -r "cronForm = _o({...e.cronForm" src/
grep -r "onFormChange.*spread" src/
grep -r "@state()\s*\n.*Form" src/
๐ Related Errors
Contextually Connected Issues
WEB_SOCKET_HANDSHAKE_TIMEOUTWebSocket connections timing out due to browser tab becoming unresponsive. The gateway remains healthy but the client cannot process incoming messages.CONTENT_PROCESS_HIGH_CPUFirefox-specific error indicating excessive CPU consumption in content processes, often symptomatic of JavaScript infinite loops.LIT_UPDATE_CYCLE_EXCEEDEDLit framework warning when too many render cycles occur in a single task. May appear in console before tab freezes.FORM_VALIDATION_STALERelated validation inconsistency errors when form state updates faster than validation can process.
Similar Historical Issues
- Lit Issue #1427: Infinite loop with object spread in reactive propertiesFramework-level documentation of the same pattern affecting other Lit-based applications.
- React useState equality comparisonSimilar anti-pattern exists in React where
setState({...state, value})causes infinite re-renders without proper memoization.
Preventative Measures for Future Development
- Linting Rule: Add ESLint rule to detect object spread patterns in Lit property setters
- Component Testing: Include render cycle counting in component test suites
- Performance Budgets: Set alerts for excessive re-renders in CI/CD pipeline