From 9140b8d3793e89e274fd489f42af2ad8ae35e06c Mon Sep 17 00:00:00 2001 From: Priyanshubhartistm Date: Thu, 13 Aug 2026 00:50:51 +0530 Subject: [PATCH 1/2] fix: restore trace-dash highlighting during component drag Signed-off-by: Priyanshubhartistm --- ...ChangeSchematicTracesForMovedComponents.ts | 94 +++++--------- lib/utils/get-trace-ids-to-dash.ts | 78 ++++++++++++ tests/get-trace-ids-to-dash.test.ts | 116 ++++++++++++++++++ 3 files changed, 227 insertions(+), 61 deletions(-) create mode 100644 lib/utils/get-trace-ids-to-dash.ts create mode 100644 tests/get-trace-ids-to-dash.test.ts diff --git a/lib/hooks/useChangeSchematicTracesForMovedComponents.ts b/lib/hooks/useChangeSchematicTracesForMovedComponents.ts index 453377c5..227cbd2f 100644 --- a/lib/hooks/useChangeSchematicTracesForMovedComponents.ts +++ b/lib/hooks/useChangeSchematicTracesForMovedComponents.ts @@ -1,7 +1,7 @@ import { useEffect, useRef } from "react" -import { su } from "@tscircuit/soup-util" import type { ManualEditEvent } from "../types/edit-events" import type { CircuitJson } from "circuit-json" +import { getTraceIdsToDash } from "../utils/get-trace-ids-to-dash" /** * This hook makes traces dashed when their connected components are being moved @@ -36,69 +36,41 @@ export const useChangeSchematicTracesForMovedComponents = ({ ;(trace as any).style.animation = "" } - // If there's an active edit event, make connected traces dashed - for (const editEvent of [ - ...editEvents, - ...(activeEditEvent ? [activeEditEvent] : []), - ]) { - if ( - "schematic_component_id" in editEvent && - editEvent.edit_event_type === "edit_schematic_component_location" - ) { - const sch_component = su(circuitJson).schematic_component.get( - editEvent.schematic_component_id, - ) - if (!sch_component) return + // Make traces connected to a moved (or moving) component dashed + const traceIdsToDash = getTraceIdsToDash({ + circuitJson, + editEvents, + activeEditEvent, + }) - const src_ports = su(circuitJson).source_port.list({ - source_component_id: sch_component.source_component_id, - }) - const src_port_ids = new Set(src_ports.map((sp) => sp.source_port_id)) - const src_traces = su(circuitJson) - .source_trace.list() - .filter((st) => - st.connected_source_port_ids?.some((spi: string) => - src_port_ids.has(spi), - ), - ) - const src_trace_ids = new Set( - src_traces.map((st) => st.source_trace_id), - ) - const schematic_traces = su(circuitJson) - .schematic_trace.list() - .filter((st) => src_trace_ids.has(st.source_trace_id!)) + for (const schematicTraceId of traceIdsToDash) { + const traceElements = svg.querySelectorAll( + `[data-schematic-trace-id="${schematicTraceId}"] path`, + ) + for (const traceElement of Array.from(traceElements)) { + if (traceElement.getAttribute("class")?.includes("invisible")) + continue + traceElement.setAttribute("stroke-dasharray", "20,20") + ;(traceElement as any).style.animation = + "dash-animation 350ms linear infinite, pulse-animation 900ms linear infinite" - // Make the connected traces dashed - schematic_traces.forEach((trace) => { - const traceElements = svg.querySelectorAll( - `[data-schematic-trace-id="${trace.schematic_trace_id}"] path`, - ) - for (const traceElement of Array.from(traceElements)) { - if (traceElement.getAttribute("class")?.includes("invisible")) - continue - traceElement.setAttribute("stroke-dasharray", "20,20") - ;(traceElement as any).style.animation = - "dash-animation 350ms linear infinite, pulse-animation 900ms linear infinite" - - if (!svg.querySelector("style#dash-animation")) { - const style = document.createElement("style") - style.id = "dash-animation" - style.textContent = ` - @keyframes dash-animation { - to { - stroke-dashoffset: -40; - } - } - @keyframes pulse-animation { - 0% { opacity: 0.6; } - 50% { opacity: 0.2; } - 100% { opacity: 0.6; } - } - ` - svg.appendChild(style) + if (!svg.querySelector("style#dash-animation")) { + const style = document.createElement("style") + style.id = "dash-animation" + style.textContent = ` + @keyframes dash-animation { + to { + stroke-dashoffset: -40; + } + } + @keyframes pulse-animation { + 0% { opacity: 0.6; } + 50% { opacity: 0.2; } + 100% { opacity: 0.6; } } - } - }) + ` + svg.appendChild(style) + } } } } diff --git a/lib/utils/get-trace-ids-to-dash.ts b/lib/utils/get-trace-ids-to-dash.ts new file mode 100644 index 00000000..06647a30 --- /dev/null +++ b/lib/utils/get-trace-ids-to-dash.ts @@ -0,0 +1,78 @@ +import { su } from "@tscircuit/soup-util" +import type { CircuitJson } from "circuit-json" +import type { ManualEditEvent } from "../types/edit-events" + +/** + * Returns the schematic_trace_ids that should render dashed because they're + * connected to a component currently being moved (an active drag, or a + * queued edit event). + * + * A stale edit event -- one whose schematic_component_id no longer resolves + * against circuitJson, e.g. because the component was removed -- is skipped + * rather than aborting, so it doesn't prevent later, still-valid edit events + * from being processed. + */ +export const getTraceIdsToDash = ({ + circuitJson, + editEvents, + activeEditEvent, +}: { + circuitJson: CircuitJson + editEvents: ManualEditEvent[] + activeEditEvent: ManualEditEvent | null +}): Set => { + const traceIds = new Set() + + for (const editEvent of [ + ...editEvents, + ...(activeEditEvent ? [activeEditEvent] : []), + ]) { + if ( + !("schematic_component_id" in editEvent) || + editEvent.edit_event_type !== "edit_schematic_component_location" + ) { + continue + } + + const sch_component = su(circuitJson).schematic_component.get( + editEvent.schematic_component_id, + ) + if (!sch_component) continue + + const src_ports = su(circuitJson).source_port.list({ + source_component_id: sch_component.source_component_id, + }) + const src_port_ids = new Set(src_ports.map((sp) => sp.source_port_id)) + + // schematic_trace only reliably links back to source_trace via + // subcircuit_connectivity_map_key -- source_trace_id on schematic_trace + // is a display-style label (e.g. "R1.2-C1.1"), not a real + // source_trace_id, so it can't be used to join the two. This is the same + // key useSchematicNetHover uses to relate traces to nets. + const connectivityKeys = new Set( + su(circuitJson) + .source_trace.list() + .filter((st) => + st.connected_source_port_ids?.some((spi: string) => + src_port_ids.has(spi), + ), + ) + .map((st) => st.subcircuit_connectivity_map_key) + .filter((key): key is string => Boolean(key)), + ) + + const schematic_traces = su(circuitJson) + .schematic_trace.list() + .filter( + (st) => + st.subcircuit_connectivity_map_key && + connectivityKeys.has(st.subcircuit_connectivity_map_key), + ) + + for (const trace of schematic_traces) { + traceIds.add(trace.schematic_trace_id!) + } + } + + return traceIds +} diff --git a/tests/get-trace-ids-to-dash.test.ts b/tests/get-trace-ids-to-dash.test.ts new file mode 100644 index 00000000..972698df --- /dev/null +++ b/tests/get-trace-ids-to-dash.test.ts @@ -0,0 +1,116 @@ +import { expect, test } from "bun:test" +import type { CircuitJson } from "circuit-json" +import { getTraceIdsToDash } from "../lib/utils/get-trace-ids-to-dash" + +const CONNECTIVITY_KEY = "unnamedsubcircuit_source_group_0_connectivity_net0" + +// Mirrors real circuit-json output: schematic_trace.source_trace_id is a +// display-style label (e.g. "R1.2-C1.1"), not the actual source_trace_id, so +// it deliberately does NOT match source_trace_0's id below. The only +// reliable link between the two is subcircuit_connectivity_map_key. +const makeCircuitJson = (): CircuitJson => + [ + { + type: "schematic_component", + schematic_component_id: "schematic_component_0", + source_component_id: "source_component_0", + }, + { + type: "schematic_component", + schematic_component_id: "schematic_component_1", + source_component_id: "source_component_1", + }, + { + type: "source_port", + source_port_id: "source_port_0", + source_component_id: "source_component_0", + }, + { + type: "source_port", + source_port_id: "source_port_1", + source_component_id: "source_component_1", + }, + { + type: "source_trace", + source_trace_id: "source_trace_0", + connected_source_port_ids: ["source_port_0", "source_port_1"], + subcircuit_connectivity_map_key: CONNECTIVITY_KEY, + }, + { + type: "schematic_trace", + schematic_trace_id: "schematic_trace_0", + source_trace_id: "R1.2-C1.1", + subcircuit_connectivity_map_key: CONNECTIVITY_KEY, + }, + ] as unknown as CircuitJson + +const editEventFor = (schematic_component_id: string, suffix = "") => ({ + edit_event_id: `edit_event_${schematic_component_id}${suffix}`, + edit_event_type: "edit_schematic_component_location" as const, + schematic_component_id, + original_center: { x: 0, y: 0 }, + new_center: { x: 1, y: 1 }, + in_progress: false, + created_at: 0, +}) + +test("returns no trace ids when there are no edit events", () => { + const traceIds = getTraceIdsToDash({ + circuitJson: makeCircuitJson(), + editEvents: [], + activeEditEvent: null, + }) + + expect(traceIds.size).toBe(0) +}) + +test("returns the trace ids connected to an actively-edited component", () => { + const traceIds = getTraceIdsToDash({ + circuitJson: makeCircuitJson(), + editEvents: [], + activeEditEvent: editEventFor("schematic_component_0"), + }) + + expect(traceIds).toEqual(new Set(["schematic_trace_0"])) +}) + +test("skips a stale edit event (removed component) instead of aborting", () => { + // Regression test: circuit-viewer once used `return` instead of `continue` + // when a queued edit event referenced a schematic_component_id no longer + // present in circuitJson, which silently stopped every edit event *after* + // it in the list from being processed -- including the component actively + // being dragged. + const circuitJson = makeCircuitJson().filter( + (elm: any) => elm.schematic_component_id !== "schematic_component_0", + ) as CircuitJson + + const traceIds = getTraceIdsToDash({ + circuitJson, + // Stale: schematic_component_0 has been removed from circuitJson. + editEvents: [editEventFor("schematic_component_0")], + // Still valid, and comes after the stale event in processing order. + activeEditEvent: editEventFor("schematic_component_1"), + }) + + expect(traceIds).toEqual(new Set(["schematic_trace_0"])) +}) + +test("ignores edit events that aren't schematic component location edits", () => { + const traceIds = getTraceIdsToDash({ + circuitJson: makeCircuitJson(), + editEvents: [ + { + edit_event_id: "edit_event_other", + edit_event_type: "edit_pcb_component_location" as any, + pcb_component_id: "pcb_component_0", + original_center: { x: 0, y: 0 }, + new_center: { x: 1, y: 1 }, + in_progress: false, + created_at: 0, + } as any, + ], + activeEditEvent: null, + }) + + expect(traceIds.size).toBe(0) +}) From f0b4b4cb76da2d55b05bb2da604099f7c22744fe Mon Sep 17 00:00:00 2001 From: Priyanshubhartistm Date: Thu, 13 Aug 2026 00:51:27 +0530 Subject: [PATCH 2/2] add fixture demonstrating the stale edit event trace-dash fix Signed-off-by: Priyanshubhartistm --- ...32-stale-edit-event-trace-dash.fixture.tsx | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 examples/example32-stale-edit-event-trace-dash.fixture.tsx diff --git a/examples/example32-stale-edit-event-trace-dash.fixture.tsx b/examples/example32-stale-edit-event-trace-dash.fixture.tsx new file mode 100644 index 00000000..61b79250 --- /dev/null +++ b/examples/example32-stale-edit-event-trace-dash.fixture.tsx @@ -0,0 +1,125 @@ +import { useMemo, useState } from "react" +import { su } from "@tscircuit/soup-util" +import { renderToCircuitJson } from "lib/dev/render-to-circuit-json" +import type { ManualEditEvent } from "lib/types/edit-events" +import { SchematicViewer } from "lib/index" +import type { CircuitJson } from "circuit-json" + +const buildCircuit = (includeC2: boolean) => + renderToCircuitJson( + + + + {includeC2 && } + + {includeC2 && } + , + ) as CircuitJson + +const findSchematicComponentIdByName = ( + circuitJson: CircuitJson, + name: string, +) => { + const sourceComponent = su(circuitJson) + .source_component.list() + .find((c) => c.name === name) + if (!sourceComponent) return undefined + return su(circuitJson) + .schematic_component.list() + .find((c) => c.source_component_id === sourceComponent.source_component_id) + ?.schematic_component_id +} + +/** + * Regression fixture for the "stale edit event stops trace dashing" bug: + * once an edit event references a schematic_component_id that no longer + * exists in circuitJson, every edit event *after* it (including the one for + * the component you're actively dragging) used to silently stop getting its + * dashed-trace styling. + */ +export default () => { + const initialCircuitJson = useMemo(() => buildCircuit(true), []) + const [circuitJson, setCircuitJson] = + useState(initialCircuitJson) + const [editEvents, setEditEvents] = useState([]) + + const simulateStaleEditEvent = () => { + const staleComponentId = findSchematicComponentIdByName( + initialCircuitJson, + "C2", + ) + if (!staleComponentId) return + + setEditEvents([ + { + edit_event_id: "stale-c2-edit", + edit_event_type: "edit_schematic_component_location", + schematic_component_id: staleComponentId, + original_center: { x: 4, y: 0 }, + new_center: { x: 5, y: 1 }, + in_progress: false, + created_at: Date.now(), + }, + ]) + // Remove C2 from the rendered circuit so the edit event above is stale -- + // it references a schematic_component_id that no longer exists. + setCircuitJson(buildCircuit(false)) + } + + return ( +
+
+ Regression check: stale edit event trace dashing +
    +
  1. + Click "Simulate stale edit + remove C2" — this queues an edit event + for C2, then removes C2 from circuitJson (mirroring a consumer + swapping in a new circuit while an old edit event is still around). +
  2. +
  3. + Drag R1 or C1. Their connected trace should turn dashed while + dragging. Before the fix, the stale C2 edit event stopped processing + early and no trace would dash. +
  4. +
+ +
+ setEditEvents([...editEvents, event])} + containerStyle={{ height: "100%" }} + debugGrid + editingEnabled + /> +
+ ) +}