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
+
+
+ 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).
+
+
+ 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.
+
+
+
+ Simulate stale edit + remove C2
+
+
+
setEditEvents([...editEvents, event])}
+ containerStyle={{ height: "100%" }}
+ debugGrid
+ editingEnabled
+ />
+
+ )
+}
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)
+})