From f791536007d6ab9442838a6dca06113a9a7010b0 Mon Sep 17 00:00:00 2001 From: David Montero Crespo Date: Sun, 9 Aug 2026 08:32:20 +0200 Subject: [PATCH] fix(wires): close both halves of the load-time endpoint race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- frontend/src/components/DynamicComponent.tsx | 63 +++++++++---------- .../components/simulator/SimulatorCanvas.tsx | 15 +++++ 2 files changed, 46 insertions(+), 32 deletions(-) diff --git a/frontend/src/components/DynamicComponent.tsx b/frontend/src/components/DynamicComponent.tsx index f9dc311c..29f54320 100644 --- a/frontend/src/components/DynamicComponent.tsx +++ b/frontend/src/components/DynamicComponent.tsx @@ -446,42 +446,41 @@ export const DynamicComponent: React.FC = ({ } 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); }; diff --git a/frontend/src/components/simulator/SimulatorCanvas.tsx b/frontend/src/components/simulator/SimulatorCanvas.tsx index 378b6ef5..d7a017ad 100644 --- a/frontend/src/components/simulator/SimulatorCanvas.tsx +++ b/frontend/src/components/simulator/SimulatorCanvas.tsx @@ -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