fix(breadboard): derive seating at element mount — closes run-before-seating race

A part can land in the store at its FINAL position before its element
mounts: the agent streams add_component and the seating move in one batch,
and updateComponent's reseat then finds no DOM (computeSeating null) and
keeps the empty seating. Nothing re-derived it afterwards — the agent-side
seat correction skips when the position needs no nudge, and 'pininfo-change'
only fires on pin-SET swaps, not on plain init. Meanwhile run_simulation
executes right after the SSE round, before the correction's animation frame.

Net effect, reported by a user as a suspicion that turned out exactly right:
a clock the agent built and ran in one turn showed a dead display, while
reloading the project and running it worked — bb seating wires are persisted,
so on reload they exist before Run is pressed.

DynamicComponent now reseats once the element's pinInfo first becomes
measurable (same polling cadence as the pinInfo-ready effect), which closes
the hole for every path that stores a final position before mount: agent
batches, project load, undo. To keep that free on load,
reseatComponentOnBreadboard skips the store write when there is nothing
seated and nothing to clear — otherwise every off-board part would churn the
wires array identity once per mount.

Verified live end-to-end: agent adds + seats + wires + compiles + RUNS in a
single turn; the seated LED blinks immediately (4 transitions sampled), with
all 4 seated-pin markers present — no reload needed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
David Montero Crespo 2026-07-20 17:39:29 +02:00
parent d612c3bae7
commit 9e5ae8baf6
3 changed files with 100 additions and 0 deletions

View File

@ -258,3 +258,53 @@ describe('resolveSeatPosition', () => {
expect(bbWires.map((w) => w.end.pinName).sort()).toEqual(['11t.a', '5t.a']);
});
});
/**
* The run-before-seating race: a part can land in the store at its final
* position BEFORE its element mounts (the agent streams add_component and the
* seating move in one batch). The reseat inside updateComponent then finds no
* DOM and keeps an empty seating and nothing re-derived it, so a simulation
* started in that window ran against a part with no bb wires, while reloading
* the project (bb wires are persisted) worked. DynamicComponent now reseats
* at mount, as soon as pinInfo is measurable; these tests cover the store
* half of that contract.
*/
describe('reseat after late element mount', () => {
beforeEach(() => {
document.getElementById('res1')?.remove();
const s = useSimulatorStore.getState();
s.setComponents([
{ id: 'bb1', metadataId: 'breadboard', x: 0, y: 0, properties: {} },
{ id: 'res1', metadataId: 'resistor', x: 900, y: 900, properties: {} },
] as never);
s.setWires([]);
});
it('a seating move with NO mounted element creates no bb wires (the race)', () => {
// Element deliberately NOT mounted — this is the agent-batch window.
useSimulatorStore.getState().updateComponent('res1', resistorPosFor('5t.a', 0, 0));
expect(useSimulatorStore.getState().wires.filter((w) => w.bb)).toHaveLength(0);
});
it('reseating once the element mounts creates the missing bb wires', () => {
useSimulatorStore.getState().updateComponent('res1', resistorPosFor('5t.a', 0, 0));
expect(useSimulatorStore.getState().wires.filter((w) => w.bb)).toHaveLength(0);
// Element appears (custom-element upgrade) — the mount-time effect fires:
mountFakeElement('res1', RES_PIN_INFO);
useSimulatorStore.getState().reseatComponentOnBreadboard('res1');
const bbWires = useSimulatorStore.getState().wires.filter((w) => w.bb);
expect(bbWires).toHaveLength(2);
expect(bbWires.map((w) => w.end.pinName).sort()).toEqual(['11t.a', '5t.a']);
});
it('mount-reseat of an off-board part is a no-op that keeps array identity', () => {
// Every component reseats at mount now — a project load must not churn
// the wires array once per off-board part.
mountFakeElement('res1', RES_PIN_INFO); // far from the board at (900,900)
const before = useSimulatorStore.getState().wires;
useSimulatorStore.getState().reseatComponentOnBreadboard('res1');
expect(useSimulatorStore.getState().wires).toBe(before);
});
});

View File

@ -346,6 +346,46 @@ export const DynamicComponent: React.FC<DynamicComponentProps> = ({
return () => el.removeEventListener('pininfo-change', onPinInfoChange);
}, [id, metadata.tagName]);
/**
* Reseat once the element's geometry first becomes measurable.
*
* A part can land in the store at its FINAL position before its element
* mounts the agent streams add_component + a seating move in one batch,
* and `updateComponent`'s reseat then finds no DOM (computeSeating null)
* and keeps the (empty) seating. Nothing re-derived it afterwards: the
* seat-correction skips when the position needs no nudge, and
* 'pininfo-change' only fires on pin-SET swaps, not on plain init. So the
* part had no bb wires until the user dragged it or reloaded a clock
* started by the agent in that window ran against a dead display, while
* reload+run worked (bb wires are persisted). Deriving the seating at
* mount closes that hole for every path (agent, load, undo).
*/
useEffect(() => {
const tryReseat = () => {
try {
const pinInfo = (elementRef.current as any)?.pinInfo;
if (pinInfo && Array.isArray(pinInfo) && pinInfo.length > 0) {
useSimulatorStore.getState().reseatComponentOnBreadboard(id);
return true;
}
} catch {
// element not ready yet / headless tests
}
return false;
};
if (tryReseat()) return;
// Same cadence as the pinInfo-ready poll above: the custom element may
// upgrade a few frames after React commits.
const interval = setInterval(() => {
if (tryReseat()) clearInterval(interval);
}, 100);
const timeout = setTimeout(() => clearInterval(interval), 2000);
return () => {
clearInterval(interval);
clearTimeout(timeout);
};
}, [id, metadata.tagName]);
/**
* Extract pinInfo from web component after it initializes
*/

View File

@ -2515,6 +2515,16 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
// null = geometry unmeasurable (unmounted DOM) — keep whatever
// seating exists rather than tearing out live connections.
if (seating === null) return;
// Nothing seated and nothing to clear: skip the store write. The
// mount-time reseat (DynamicComponent) calls this for EVERY part as
// its element becomes measurable — on a project load that would churn
// the wires array identity once per off-board component for no change.
if (
seating.length === 0 &&
!get().wires.some((w) => w.bb && (w.start.componentId === id || w.end.componentId === id))
) {
return;
}
set((state) => {
const kept = state.wires.filter(
(w) => !(w.bb && (w.start.componentId === id || w.end.componentId === id)),