April 24, 2026 โ€ข Version: 2026.2.26

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 timeout errors
  • 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:

  1. User clicks empty input field โ€” The browser fires a native @input event with the current (empty) value
  2. onFormChange executes โ€” Handler receives {name: ""}
  3. Spread operator creates new object โ€” {...e.cronForm, ...{name: ""}} produces a new object reference even though the value is identical
  4. Lit detects property change โ€” The @state() or @property() decorated cronForm setter triggers a re-render
  5. Re-render reads input value โ€” The component's render() or template accessor reads the DOM input's current value ("")
  6. DOM event fires again โ€” Reading the input value in some implementations re-fires the @input handler
  7. 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

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

  1. Locate the file: src/components/cron-form.js or src/components/cron-jobs/cron-form.ts
  2. Find the onFormChange method in the CronForm class
  3. Replace the handler with the fixed version above
  4. Add a utility function for reuse if multiple forms have the same pattern
  5. 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

  1. 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}))),
      // ...
    }
    
  2. 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);
      // ...
    }
    
  3. 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: 0 should not be treated as "no value"
  • Checkbox/boolean fields: false to false should not trigger updates
  • Array fields: If form contains arrays, use appropriate comparison (shallow for most cases)

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/

Contextually Connected Issues

  • WEB_SOCKET_HANDSHAKE_TIMEOUT
    WebSocket connections timing out due to browser tab becoming unresponsive. The gateway remains healthy but the client cannot process incoming messages.
  • CONTENT_PROCESS_HIGH_CPU
    Firefox-specific error indicating excessive CPU consumption in content processes, often symptomatic of JavaScript infinite loops.
  • LIT_UPDATE_CYCLE_EXCEEDED
    Lit framework warning when too many render cycles occur in a single task. May appear in console before tab freezes.
  • FORM_VALIDATION_STALE
    Related 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 properties
    Framework-level documentation of the same pattern affecting other Lit-based applications.
  • React useState equality comparison
    Similar 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

Evidence & Sources

This troubleshooting guide was automatically synthesized by the FixClaw Intelligence Pipeline from community discussions.