fix(wires): close both halves of the load-time endpoint race

The whenDefined recalc was necessary but not sufficient — measured on
staging, the example route loses BOTH ways:

- the element side: every settle timer can fire before the part is
  findable in the DOM. The pinInfo-ready poll in DynamicComponent now
  also re-derives wire endpoints the first time the part's pinInfo is
  actually readable, and waits up to 10s (the 2s cap was the corner-
  wire bug again on slow connections).
- the wire side: loadExample stores wires with zeroed endpoints BEHIND
  awaits (library installs), potentially after every recalc has come
  and gone. A canvas effect now spots endpoints still at (0,0) and
  runs one deferred recalc; real coordinates land, the condition
  clears, no loop.
This commit is contained in:
David Montero Crespo 2026-08-09 08:32:20 +02:00
parent 4b9fa3f0d0
commit f791536007
2 changed files with 46 additions and 32 deletions

View File

@ -446,42 +446,41 @@ export const DynamicComponent: React.FC<DynamicComponentProps> = ({
}
return false;
};
// Wires resolve through the same pinInfo. The canvas re-derives wire
// endpoints on load-settle timers (100/300/500ms), but an overlay-defined
// custom element can upgrade LATER than the last timer — its chunk is
// large — and then every wire to it stays snapped to the part's corner
// until something moves. Recalculate when the tag actually defines,
// however late that is (resolves immediately when already defined).
let cancelled = false;
customElements
.whenDefined(metadata.tagName)
.then(() => {
requestAnimationFrame(() => {
if (cancelled) return;
try {
useSimulatorStore.getState().recalculateAllWirePositions();
} catch {
// headless tests
}
});
})
.catch(() => {
// invalid tag name: nothing will ever mount, nothing to recalc
});
// Wires resolve through the same pinInfo, and the canvas' load-settle
// timers (100/300/500ms) can ALL fire before this element is findable —
// an overlay-defined custom element upgrades when its (large) chunk
// lands, and on the example route the wires themselves are stored after
// further awaits. So: the first time THIS part's pinInfo is actually
// readable from the DOM, re-derive every wire endpoint. Measured on
// staging: without this, all four wires to the part sat on its corner
// until the user nudged something.
const tryWires = () => {
try {
const el = document.getElementById(id) as (HTMLElement & { pinInfo?: unknown[] }) | null;
if (el && Array.isArray(el.pinInfo) && el.pinInfo.length > 0) {
useSimulatorStore.getState().recalculateAllWirePositions();
return true;
}
} catch {
// headless tests
}
return false;
};
if (tryReseat()) {
return () => {
cancelled = true;
};
}
// Same cadence as the pinInfo-ready poll above: the custom element may
// upgrade a few frames after React commits.
const reseated = tryReseat();
const wired = tryWires();
if (reseated && wired) return;
// Poll until both settle. 10s, not 2s: the overlay chunk that defines
// the element can take that long on a slow connection, and giving up
// early is exactly the corner-wire bug again.
let done = { reseat: reseated, wires: wired };
const interval = setInterval(() => {
if (tryReseat()) clearInterval(interval);
if (!done.reseat) done.reseat = tryReseat();
if (!done.wires) done.wires = tryWires();
if (done.reseat && done.wires) clearInterval(interval);
}, 100);
const timeout = setTimeout(() => clearInterval(interval), 2000);
const timeout = setTimeout(() => clearInterval(interval), 10000);
return () => {
cancelled = true;
clearInterval(interval);
clearTimeout(timeout);
};

View File

@ -2287,6 +2287,21 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
return () => timers.forEach((t) => clearTimeout(t));
}, [components, recalculateAllWirePositions]);
// A wire endpoint still at (0,0) was STORED after every timer above had
// already fired — the example loader writes wires with zeroed endpoints
// behind several awaits (library installs), so on that route the timers
// race and lose. One deferred recalc converges it; recalc writes real
// coordinates (a pin position, or the part-corner fallback), so this
// effect self-quiesces instead of looping.
useEffect(() => {
const unresolved = wires.some(
(w) => (w.start.x === 0 && w.start.y === 0) || (w.end.x === 0 && w.end.y === 0),
);
if (!unresolved) return;
const t = setTimeout(() => recalculateAllWirePositions(), 150);
return () => clearTimeout(t);
}, [wires, recalculateAllWirePositions]);
// Auto-pan/zoom to keep the board and all components visible after a project
// import/load. We track the previous component count and only re-center when
// the count jumps (indicating the user loaded a new circuit, not just added