fix(perf): un-freeze the editor during fast-toggling simulations (ESP32 clock)

Running a multiplexed 4-digit 7-segment clock on ESP32/QEMU froze the
browser for minutes after Run — evaluate probes waited 40-90 s, and before
the first fixes the sim WebSocket eventually died (code 1006) with the page
never recovering. CPU-profiled on staging; four compounding per-GPIO-edge
costs, in profile order:

updateComponentState minted a new components array per edge
------------------------------------------------------------
The store setter rebuilt `components` (and one properties object) on EVERY
edge even when the state didn't change. The breadboard is direct-wired to
13 board pins, so segment toggles produced thousands of store sets per
second; every subscriber re-rendered each time, and the canvas subscription
effect (deps: [components, ...]) re-subscribed all pin listeners in a loop.
Now a no-op guard returns prevState unchanged, and breadboards are treated
as self-managed (they have no visual on/off state to echo).

CompilationConsole re-rendered every log line per editor render
----------------------------------------------------------------
The post-compile console holds hundreds of lines; each render called
Date.toLocaleTimeString per line (~0.2 ms each — it builds a fresh Intl
formatter every call). Profile: 162 s of self time in LogLine over a 337 s
window, in ~150 ms tasks. LogLine is now memoized (entries are immutable),
timestamps go through one shared Intl.DateTimeFormat, and the console
itself is React.memo'd against parent re-renders.

Per-edge full SPICE re-solves
------------------------------
PinManager requested a FULL netlist rebuild+solve on every 'mcu' edge.
Now only the edge that newly classifies a pin as MCU-output triggers the
rebuild (that's what emits the pin's V-source); steady-state updates flow
through connectMcuEdgesToService's per-pin coalesced alterSource path.
The start.ts resolve hook is trailing-throttled (33 ms) for the other
per-edge callers (RP2040, custom chips), the service's pending-edge queue
drains on a 33 ms gap timer instead of replaying back-to-back, and new
edges arriving inside the gap queue instead of soloing a solve.
STM32 / Pi reverse pin-name mappings added to connectMcuEdgesToService so
those boards keep fine-grained updates now that the full-tick storm is
gone (PA0/PC13-style and GPIO-style names never matched before).

wokwi-7segment re-rendered per segment write
---------------------------------------------
element.values now flushes at most every 8 ms per display (trailing write
guaranteed), instead of re-rendering the 32-shape SVG per edge.

Also: CLN (colon) pin support for 7-segment clock faces — wired CLN now
drives colon/colonValue in both the attachEvents path and the QEMU
onPinStateChange path; it was silently ignored, so clock colons never lit.

Verified on staging with the failing project: main-thread probes drop from
40-90 s waits (324 long tasks, 52.6 s blocked in 150 s) to 5-11 ms
(2 long tasks, 179 ms), display shows 12:00 with the colon blinking at
1 Hz from the first seconds after Run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
David Montero Crespo 2026-07-21 01:15:59 +02:00
parent a000fba154
commit 701042fa22
10 changed files with 296 additions and 58 deletions

View File

@ -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);

View File

@ -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 ──────────────────────────────────────────────────────────────────

View File

@ -44,7 +44,10 @@ interface CompilationConsoleProps {
onClear: () => void;
}
export const CompilationConsole: React.FC<CompilationConsoleProps> = ({
// 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<CompilationConsoleProps> = React.memo(({
isOpen,
onClose,
logs,
@ -216,24 +219,32 @@ export const CompilationConsole: React.FC<CompilationConsoleProps> = ({
</div>
</div>
);
};
});
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 }) => (
<div style={styles.logLine}>
<span style={styles.timestamp}>
{log.timestamp.toLocaleTimeString('en-US', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})}
</span>
<span style={styles.timestamp}>{TIME_FORMAT.format(log.timestamp)}</span>
<span style={{ ...styles.logMessage, color: logColor(log.type) }}>
{log.type === 'core-install' && <span style={styles.coreTag}>CORE </span>}
{log.message}
</span>
</div>
);
));
LogLine.displayName = 'LogLine';
function statusColor(status: 'error' | 'success' | 'running'): string {
return status === 'error' ? '#ef5350' : status === 'success' ? '#66bb6a' : '#9aa0a6';

View File

@ -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.

View File

@ -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. */

View File

@ -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<typeof setTimeout> | null; // trailing write when throttled
}
const sevenSegState = new WeakMap<HTMLElement, SevenSegState>();
@ -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);

View File

@ -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<typeof setTimeout> | 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<void> {
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();
}
}
}

View File

@ -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

View File

@ -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<typeof setTimeout> | 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();

View File

@ -2547,11 +2547,22 @@ export const useSimulatorStore = create<SimulatorState>((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) => {},