diff --git a/frontend/src/__tests__/circuit-simulation-service.test.ts b/frontend/src/__tests__/circuit-simulation-service.test.ts index 67572f4c..2394b3a6 100644 --- a/frontend/src/__tests__/circuit-simulation-service.test.ts +++ b/frontend/src/__tests__/circuit-simulation-service.test.ts @@ -396,6 +396,63 @@ describe('handleMcuEdge (Phase 1c D1)', () => { expect(fake.calls.alterSource).toEqual([['V_uno_9', 5]]); }); + it('bounds solve rate under a sustained edge storm (multiplexed display)', async () => { + // Regression: a 4-digit 7-segment clock over QEMU keeps ~13 pins hot + // (thousands of GPIO edges/second). Replaying queued edges IMMEDIATELY + // after each solve ran the solver at 100% duty with no idle gap — the + // main thread starved for minutes until the sim WebSocket dropped. + // The drain timer must space solves out: over a ~200 ms storm window + // the solve count stays bounded (~1 per 33 ms gap), nowhere near the + // one-solve-per-edge fire hose. + const fake = new FakeSolverAdapter({ + vectors: { 'v(vcc_rail)': 5 }, + solveDelayMs: 2, + }); + __setSchedulerSolverFactoryForTests(() => fake); + const sim = makeSimStore({ + components: [ + { id: 'rb', metadataId: 'resistor', properties: { value: '1k' } }, + ], + wires: [ + { + id: 'w1', + start: { componentId: 'uno', pinName: '9' }, + end: { componentId: 'rb', pinName: '1' }, + }, + { + id: 'w2', + start: { componentId: 'rb', pinName: '2' }, + end: { componentId: 'uno', pinName: 'GND' }, + }, + ], + boards: [{ id: 'uno', boardKind: 'arduino-uno' }], + }); + const elec = makeElectricalStore(); + const service = new CircuitSimulationService( + sim.port, + elec.port, + getMixedModeScheduler() as unknown as MixedModeSchedulerPort, + { collectBoardPinStates: () => ({ '9': { type: 'digital', v: 0 } }) }, + ); + startTracked(service); + await new Promise((r) => setTimeout(r, 30)); // let the initial solve land + + // Storm: toggle the pin every 2 ms for 200 ms (~100 edges). + let state = false; + for (let i = 0; i < 100; i++) { + state = !state; + void service.handleMcuEdge('uno', '9', state, 5); + await new Promise((r) => setTimeout(r, 2)); + } + await new Promise((r) => setTimeout(r, 80)); // trailing drain + + // 100 edges in ~200 ms with a 33 ms drain gap → ~7 solves + initial. + // Generous ceiling; the pre-fix behaviour was 1 solve per edge (100+). + const total = fake.calls.solve.length; + expect(total).toBeGreaterThanOrEqual(2); // it DID keep solving + expect(total).toBeLessThanOrEqual(30); + }); + it('kicks a full tick when no circuit has been loaded yet', async () => { const fake = new FakeSolverAdapter({ vectors: { 'v(vcc_rail)': 5 } }); __setSchedulerSolverFactoryForTests(() => fake); diff --git a/frontend/src/__tests__/simulation-parts.test.ts b/frontend/src/__tests__/simulation-parts.test.ts index 76bdb5d9..15d9edc8 100644 --- a/frontend/src/__tests__/simulation-parts.test.ts +++ b/frontend/src/__tests__/simulation-parts.test.ts @@ -556,6 +556,33 @@ describe('7segment — attachEvents', () => { cleanup(); expect(unsubMock).toHaveBeenCalledTimes(8); }); + + it('CLN pin drives colon + colonValue (attachEvents and onPinStateChange)', () => { + const logic = PartSimulationRegistry.get('7segment')!; + + // attachEvents path (a clock face wired to a local-sim board). + const el = makeElement({ values: new Array(8).fill(0), colon: false, colonValue: false }); + const sim = makeSimulator(); + let clnCallback!: (pin: number, state: boolean) => void; + sim.pinManager.onPinChange.mockImplementation( + (pin: number, cb: (pin: number, state: boolean) => void) => { + if (pin === 10) clnCallback = cb; + return () => {}; + }, + ); + logic.attachEvents!(el, sim as any, pinMap({ A: 2, CLN: 10 })); + clnCallback(10, true); + expect((el as any).colon).toBe(true); + expect((el as any).colonValue).toBe(true); + clnCallback(10, false); + expect((el as any).colonValue).toBe(false); + + // onPinStateChange path (QEMU-backed boards dispatch by pin name). + const el2 = makeElement({ values: new Array(8).fill(0), colon: false, colonValue: false }); + logic.onPinStateChange!('CLN', true, el2); + expect((el2 as any).colon).toBe(true); + expect((el2 as any).colonValue).toBe(true); + }); }); // ─── RGB LED ────────────────────────────────────────────────────────────────── diff --git a/frontend/src/components/editor/CompilationConsole.tsx b/frontend/src/components/editor/CompilationConsole.tsx index be699a08..0cd2b70e 100644 --- a/frontend/src/components/editor/CompilationConsole.tsx +++ b/frontend/src/components/editor/CompilationConsole.tsx @@ -44,7 +44,10 @@ interface CompilationConsoleProps { onClear: () => void; } -export const CompilationConsole: React.FC = ({ +// Memoized: the console lives inside EditorPage, which re-renders on every +// simulator-store change (component drags, pin updates, etc.). Without memo, +// each of those re-rendered every log line even though `logs` was untouched. +export const CompilationConsole: React.FC = React.memo(({ isOpen, onClose, logs, @@ -216,24 +219,32 @@ export const CompilationConsole: React.FC = ({ ); -}; +}); +CompilationConsole.displayName = 'CompilationConsole'; -const LogLine: React.FC<{ log: CompilationLog }> = ({ log }) => ( +// Shared formatter: `Date.toLocaleTimeString(...)` builds a fresh Intl +// formatter on every call (~0.1-0.3 ms). With hundreds of log lines and the +// console re-rendering on every editor state change, that alone produced +// ~150 ms render tasks — a major slice of the frozen-browser-after-Run bug. +const TIME_FORMAT = new Intl.DateTimeFormat('en-US', { + hour12: false, + hour: '2-digit', + minute: '2-digit', + second: '2-digit', +}); + +// Memoized: a log entry is immutable once appended, so a line never needs to +// re-render — appends only mount NEW lines instead of re-rendering all. +const LogLine = React.memo<{ log: CompilationLog }>(({ log }) => (
- - {log.timestamp.toLocaleTimeString('en-US', { - hour12: false, - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - })} - + {TIME_FORMAT.format(log.timestamp)} {log.type === 'core-install' && CORE } {log.message}
-); +)); +LogLine.displayName = 'LogLine'; function statusColor(status: 'error' | 'success' | 'running'): string { return status === 'error' ? '#ef5350' : status === 'success' ? '#66bb6a' : '#9aa0a6'; diff --git a/frontend/src/components/simulator/SimulatorCanvas.tsx b/frontend/src/components/simulator/SimulatorCanvas.tsx index a69c718e..026da3f3 100644 --- a/frontend/src/components/simulator/SimulatorCanvas.tsx +++ b/frontend/src/components/simulator/SimulatorCanvas.tsx @@ -29,6 +29,7 @@ import { PinOverlay } from './PinOverlay'; import { SeatedPinMarkers } from './SeatedPinMarkers'; import { calculatePinPosition } from '../../utils/pinPositionCalculator'; import { isBoardComponent, boardPinToNumber } from '../../utils/boardPinMapping'; +import { isBreadboard } from '../../utils/breadboardNets'; import { autoWireColor, WIRE_KEY_COLORS, expandOrthogonalPoints } from '../../utils/wireUtils'; import { isAutoVerticalPart, @@ -1112,7 +1113,13 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => { // current and future SPICE mapper immune to that feedback loop. const logic = PartSimulationRegistry.get(component.metadataId); const spiceOwned = isSpiceMapped(component.metadataId); - const hasSelfManagedVisuals = !!(logic && logic.attachEvents) || spiceOwned; + // Breadboards have no visual on/off state, but they ARE direct-wired to + // many board pins (one wire per strip → GPIO). Writing properties.state + // per edge minted a new components array thousands of times per second + // on multiplexed sketches — pure churn that re-rendered the whole + // editor. Treat them as self-managed: skip the generic state echo. + const hasSelfManagedVisuals = + !!(logic && logic.attachEvents) || spiceOwned || isBreadboard(component.metadataId); // Generic GND check: for wire-connected output components that don't manage // their own state, require at least one GND wire before activating. diff --git a/frontend/src/simulation/PinManager.ts b/frontend/src/simulation/PinManager.ts index 445e72fb..3d55fabe 100644 --- a/frontend/src/simulation/PinManager.ts +++ b/frontend/src/simulation/PinManager.ts @@ -146,9 +146,19 @@ export class PinManager { * Used by RP2040Simulator which has individual GPIO listeners instead of PORT registers. */ triggerPinChange(pin: number, state: boolean, source: 'mcu' | 'external' = 'external'): void { + // A full-netlist re-solve is only needed when this edge RE-CLASSIFIES the + // pin (first MCU write → the netlist must grow a V-source for it). Once + // the pin is a known output, per-edge voltage updates flow through + // connectMcuEdgesToService (per-pin coalesced alterSource — no rebuild). + // Requesting a full tick on EVERY edge froze the browser on multiplexed + // circuits: a 7-segment clock over QEMU emits thousands of GPIO edges per + // second, and back-to-back rebuild+solve+publish cycles starved the main + // thread until the sim WebSocket timed out. + const newlyClassified = source === 'mcu' && !this.outputPins.has(pin); const current = this.pinStates.get(pin); if (current === state) { if (source === 'mcu') this.outputPins.add(pin); + if (newlyClassified) requestElectricalResolve(); return; } this.pinStates.set(pin, state); @@ -157,16 +167,12 @@ export class PinManager { if (callbacks) { callbacks.forEach((cb) => cb(pin, state)); } - // An MCU output edge changes the circuit: request a SPICE re-solve so the - // analog parts on this net (LED brightness, etc.) update. WS-backed boards - // (ESP32 / STM32 / Raspberry Pi) reach the electrical sim ONLY through here - // — previously they never triggered a re-solve, so a resistor-less LED - // stayed at its first solved brightness until unrelated activity (e.g. - // serial output) forced a solve. AVR / RP2040 already resolve at their own - // toggle sites. Gated to 'mcu' so the solver's own input feedback - // (triggerPinChange with the default 'external' source) can't create a - // solve loop; the hook coalesces overlapping ticks so per-edge is cheap. - if (source === 'mcu') requestElectricalResolve(); + // WS-backed boards (ESP32 / STM32 / Raspberry Pi) reach the electrical sim + // ONLY through here; the first write per pin triggers the rebuild that + // emits its V-source, after which connectMcuEdgesToService owns updates. + // Gated to 'mcu' so the solver's own input feedback (source 'external') + // can't create a solve loop. + if (newlyClassified) requestElectricalResolve(); } /** Pins the MCU has actively driven this session. */ diff --git a/frontend/src/simulation/parts/ChipParts.ts b/frontend/src/simulation/parts/ChipParts.ts index 175af838..42ec58a4 100644 --- a/frontend/src/simulation/parts/ChipParts.ts +++ b/frontend/src/simulation/parts/ChipParts.ts @@ -79,6 +79,8 @@ interface SevenSegState { segments: number[]; // length 8 digitValues: number[][]; // [digit][seg]; flattened into element.values digitEnabled: boolean[]; // length = digits + lastFlushMs: number; // wall-clock of the last element.values write + flushTimer: ReturnType | null; // trailing write when throttled } const sevenSegState = new WeakMap(); @@ -99,19 +101,45 @@ function get7SegState(element: HTMLElement): SevenSegState { segments: [0, 0, 0, 0, 0, 0, 0, 0], digitValues: Array.from({ length: digits }, () => [0, 0, 0, 0, 0, 0, 0, 0]), digitEnabled: Array(digits).fill(false), + lastFlushMs: 0, + flushTimer: null, }; sevenSegState.set(element, s); } return s; } -function flush7SegValues(element: HTMLElement) { - const s = get7SegState(element); +/** Minimum gap between element.values writes. Each write re-renders the + * wokwi SVG (lit template with up to 32 segment shapes); a multiplexed + * clock over QEMU produces 1500-3000 segment edges per second, and writing + * per edge saturated the main thread for minutes after Run. 8 ms (~125 Hz) + * is far above both the display refresh a human can perceive and the 60 Hz + * the canvas paints at; the trailing timer guarantees the final state is + * never dropped. */ +const SEVEN_SEG_FLUSH_GAP_MS = 8; + +function write7SegValues(element: HTMLElement, s: SevenSegState) { const flat: number[] = []; for (let d = 0; d < s.digits; d++) flat.push(...s.digitValues[d]); (element as unknown as { values: number[] }).values = flat; } +function flush7SegValues(element: HTMLElement) { + const s = get7SegState(element); + const now = Date.now(); + if (now - s.lastFlushMs >= SEVEN_SEG_FLUSH_GAP_MS) { + s.lastFlushMs = now; + write7SegValues(element, s); + return; + } + if (s.flushTimer !== null) return; // trailing write already scheduled + s.flushTimer = setTimeout(() => { + s.flushTimer = null; + s.lastFlushMs = Date.now(); + write7SegValues(element, s); + }, SEVEN_SEG_FLUSH_GAP_MS - (now - s.lastFlushMs)); +} + function handle7SegSegment(element: HTMLElement, segIdx: number, state: boolean) { const s = get7SegState(element); s.segments[segIdx] = state ? 1 : 0; @@ -396,6 +424,32 @@ PartSimulationRegistry.register('7segment', { } } + // CLN (colon, clock-style displays): drives the element's colonValue. + // Also flip `colon` on so the two dots render at all — wokwi-7segment + // hides them unless clock mode is enabled, and a wired CLN pin is the + // clearest signal the user wants a clock face. + { + const setColon = (state: boolean) => { + const el = element as unknown as { colon: boolean; colonValue: boolean }; + el.colon = true; + el.colonValue = state; + }; + if (useResolver) { + const resolver = getPinResolver!('CLN'); + if (resolver) { + setColon(resolver.getCurrentState() === 'HIGH'); + unsubscribers.push(resolver.onChange((state) => setColon(state === 'HIGH'))); + } + } else { + const arduinoPin = getArduinoPinHelper('CLN'); + if (arduinoPin !== null) { + unsubscribers.push( + pinManager.onPinChange(arduinoPin, (_: number, state: boolean) => setColon(state)), + ); + } + } + } + return () => unsubscribers.forEach((u) => u()); }, // Called by SimulatorCanvas for boards without a local simulator (e.g. @@ -415,6 +469,12 @@ PartSimulationRegistry.register('7segment', { handle7SegDigit(element, 0, state); return; } + if (upper === 'CLN') { + const el = element as unknown as { colon: boolean; colonValue: boolean }; + el.colon = true; + el.colonValue = state; + return; + } const dm = upper.match(/^DIG(\d+)$/); if (dm) { handle7SegDigit(element, parseInt(dm[1], 10) - 1, state); diff --git a/frontend/src/simulation/spice/CircuitSimulationService.ts b/frontend/src/simulation/spice/CircuitSimulationService.ts index 3988e51b..b155cfc7 100644 --- a/frontend/src/simulation/spice/CircuitSimulationService.ts +++ b/frontend/src/simulation/spice/CircuitSimulationService.ts @@ -141,6 +141,35 @@ export class CircuitSimulationService { * keep re-scheduling solves against a disposed scheduler. */ private stopped = false; + /** Trailing timer for draining `pendingMcuEdges`. Replaying queued + * edges IMMEDIATELY after a solve creates a back-to-back solve loop + * under sustained toggling (a multiplexed display keeps 8-13 pins + * hot, so the queue never empties) — the solver runs at 100% duty + * and the UI starves. One drain per gap keeps last-state-wins per + * pin while bounding total solve rate. */ + private drainTimer: ReturnType | null = null; + private static readonly EDGE_DRAIN_GAP_MS = 33; + + /** Drain pendingMcuEdges after a short gap (coalescing: one timer). */ + private scheduleEdgeDrain(): void { + if (this.stopped || this.drainTimer !== null) return; + this.drainTimer = setTimeout(() => { + this.drainTimer = null; + if (this.stopped) return; + const edges = Array.from(this.pendingMcuEdges.values()); + this.pendingMcuEdges.clear(); + const ctx = this.loadedContext; + for (const edge of edges) { + const expected = `v_${sanitizeSpiceId(edge.boardId)}_${sanitizeSpiceId(edge.pinName)}`.toLowerCase(); + const hasSource = ctx?.voltageSources.some( + (vs) => vs.toLowerCase() === expected, + ); + if (!hasSource) continue; + void this.handleMcuEdge(edge.boardId, edge.pinName, edge.state, edge.vcc); + } + }, CircuitSimulationService.EDGE_DRAIN_GAP_MS); + } + constructor( private readonly simStore: SimulatorStorePort, private readonly electricalStore: ElectricalStorePort, @@ -169,6 +198,10 @@ export class CircuitSimulationService { this.stopped = true; this.pending = false; this.pendingMcuEdges.clear(); + if (this.drainTimer !== null) { + clearTimeout(this.drainTimer); + this.drainTimer = null; + } } /** Run one solve cycle, coalescing concurrent triggers. */ @@ -200,23 +233,11 @@ export class CircuitSimulationService { this.pending = false; void this.tick(); } else if (this.pendingMcuEdges.size > 0) { - const edges = Array.from(this.pendingMcuEdges.values()); - this.pendingMcuEdges.clear(); - const ctx = this.loadedContext; - for (const edge of edges) { - // If the rebuild we just completed still didn't emit a - // V-source for this pin (e.g. the pin isn't wired into - // any net), replaying via handleMcuEdge would self-heal - // again → re-tick → loop forever. Drop the edge instead; - // a future canvas change (e.g. user adds the wire) will - // pick it up via the normal subscription tick. - const expected = `v_${sanitizeSpiceId(edge.boardId)}_${sanitizeSpiceId(edge.pinName)}`.toLowerCase(); - const hasSource = ctx?.voltageSources.some( - (vs) => vs.toLowerCase() === expected, - ); - if (!hasSource) continue; - void this.handleMcuEdge(edge.boardId, edge.pinName, edge.state, edge.vcc); - } + // Deferred drain (not an immediate replay): the drain itself + // re-checks each pin against the freshly-rebuilt V-source list, + // dropping edges for pins that still aren't wired into any net — + // replaying those would self-heal again → re-tick → loop forever. + this.scheduleEdgeDrain(); } } } @@ -236,7 +257,12 @@ export class CircuitSimulationService { async handleMcuEdge(boardId: string, pinName: string, state: boolean, vcc: number): Promise { if (this.stopped) return; const pinKey = `${boardId}|${pinName}`; - if (this.inFlight) { + // Queue while a solve is in flight OR while the drain gap timer is + // armed. Without the second condition, every edge landing in the gap + // between solves would start an immediate solve of its own and the + // gap would only apply to the queued leftovers — under a sustained + // storm that's still ~1 solve per 2 edges instead of 1 per gap. + if (this.inFlight || this.drainTimer !== null) { this.pendingMcuEdges.set(pinKey, { boardId, pinName, state, vcc }); return; } @@ -275,11 +301,7 @@ export class CircuitSimulationService { this.pending = false; void this.tick(); } else if (this.pendingMcuEdges.size > 0) { - const edges = Array.from(this.pendingMcuEdges.values()); - this.pendingMcuEdges.clear(); - for (const edge of edges) { - void this.handleMcuEdge(edge.boardId, edge.pinName, edge.state, edge.vcc); - } + this.scheduleEdgeDrain(); } } } diff --git a/frontend/src/simulation/spice/connectMcuEdgesToService.ts b/frontend/src/simulation/spice/connectMcuEdgesToService.ts index d7fbaa3f..6a6cc721 100644 --- a/frontend/src/simulation/spice/connectMcuEdgesToService.ts +++ b/frontend/src/simulation/spice/connectMcuEdgesToService.ts @@ -27,6 +27,8 @@ import { useSimulatorStore, getBoardPinManager, } from '../../store/useSimulatorStore'; +import { stm32LinearToPinName } from '../Stm32Bridge'; +import { isStm32BoardKind, isPiBoardKind } from '../../types/board'; import { useElectricalStore } from '../../store/useElectricalStore'; import { BOARD_PIN_GROUPS } from './boardPinGroups'; import type { CircuitSimulationService } from './CircuitSimulationService'; @@ -84,6 +86,19 @@ export function connectMcuEdgesToService(service: CircuitSimulationService): () if (boardKind.startsWith('esp32')) { return `GPIO${arduinoPin}`; } + // STM32 wires reference port-style names (PA0 / PC13); its PinManager is + // keyed on the linear pin index. Without this reverse mapping the MCU-edge + // listener never attaches ("13" ≠ "PC13") — previously masked because + // PinManager requested a full re-solve on EVERY mcu edge; now that the + // full tick only fires on first classification, this fine-grained path + // must actually cover STM32. + if (isStm32BoardKind(boardKind)) { + return stm32LinearToPinName(arduinoPin); + } + // Raspberry Pi (Linux boards) wires use GPIO-style names like ESP32. + if (isPiBoardKind(boardKind)) { + return `GPIO${arduinoPin}`; + } // ATtiny85 wires reference port-style names (PB0..PB5), matching the // netlist pin names from collectPinStates. Without this, the reverse // mapping returns "1" instead of "PB1", so the MCU-edge listener is diff --git a/frontend/src/simulation/spice/start.ts b/frontend/src/simulation/spice/start.ts index f583bc55..6a4665a4 100644 --- a/frontend/src/simulation/spice/start.ts +++ b/frontend/src/simulation/spice/start.ts @@ -88,12 +88,30 @@ export function startSimulation(): () => void { const unsubChipIn = connectChipInputsToSolve(); const unsubEdges = connectMcuEdgesToService(service); - // Let custom chips request a re-solve when they toggle an output pin, so - // their SPICE voltage sources (emitted by the custom-chip mapper) are - // refreshed and LEDs / analog parts on the chip's nets update. The service - // coalesces overlapping ticks, so frequent chip toggles are cheap. + // Let custom chips / WS boards request a re-solve when they toggle an + // output pin. Trailing-throttled: callers of this hook are PER-EDGE sites + // (PinManager, RP2040Simulator, ChipRuntime) that can fire thousands of + // times per second under fast toggling (multiplexed displays, bit-banged + // protocols). The service's own inFlight coalescing only merges OVERLAPPING + // ticks — under a sustained edge stream it still runs back-to-back full + // rebuild+solve cycles with no idle gap, which starves the main thread. + // One trailing tick per window keeps the last state without the storm. + const RESOLVE_THROTTLE_MS = 33; + let lastResolveAt = 0; + let trailingResolve: ReturnType | null = null; setElectricalResolveHook(() => { - void service.tick(); + const now = Date.now(); + if (now - lastResolveAt >= RESOLVE_THROTTLE_MS) { + lastResolveAt = now; + void service.tick(); + return; + } + if (trailingResolve !== null) return; + trailingResolve = setTimeout(() => { + trailingResolve = null; + lastResolveAt = Date.now(); + void service.tick(); + }, RESOLVE_THROTTLE_MS - (now - lastResolveAt)); }); // Phase 1d #16 — debug helper. Call `__spiceDebug()` from DevTools @@ -139,6 +157,10 @@ export function startSimulation(): () => void { return () => { setElectricalResolveHook(null); + if (trailingResolve !== null) { + clearTimeout(trailingResolve); + trailingResolve = null; + } unsubService(); unsubAdc(); unsubDigitalIn(); diff --git a/frontend/src/store/useSimulatorStore.ts b/frontend/src/store/useSimulatorStore.ts index 81d4df1f..703014de 100644 --- a/frontend/src/store/useSimulatorStore.ts +++ b/frontend/src/store/useSimulatorStore.ts @@ -2547,11 +2547,22 @@ export const useSimulatorStore = create((set, get) => { }, updateComponentState: (id, state) => { - set((prevState) => ({ - components: prevState.components.map((c) => - c.id === id ? { ...c, properties: { ...c.properties, state, value: state } } : c, - ), - })); + set((prevState) => { + // No-op guard: this runs per GPIO edge for wire-connected components. + // Unconditionally minting a new components array re-rendered every + // subscriber (canvas, editor page, console) thousands of times per + // second on a fast-toggling sketch — the main cause of the frozen + // browser on the ESP32 multiplexed-clock projects. + const comp = prevState.components.find((c) => c.id === id); + if (!comp || (comp.properties.state === state && comp.properties.value === state)) { + return prevState; + } + return { + components: prevState.components.map((c) => + c.id === id ? { ...c, properties: { ...c.properties, state, value: state } } : c, + ), + }; + }); }, handleComponentEvent: (_componentId, _eventName, _data) => {},