Skip to content

Commit db826d8

Browse files
authored
Ensure edge/node add/remove events are only emitted once (#58)
1 parent c4f684a commit db826d8

2 files changed

Lines changed: 171 additions & 3 deletions

File tree

src/panel_reactflow/base.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2166,21 +2166,18 @@ def _handle_msg(self, msg: dict[str, Any]) -> None:
21662166
if edge is None:
21672167
return
21682168
self.add_edge(edge)
2169-
self._emit("edge_added", msg)
21702169
case "node_deleted":
21712170
node_ids = msg.get("node_ids") or []
21722171
if msg.get("node_id"):
21732172
node_ids = list(set(node_ids) | {msg.get("node_id")})
21742173
for node_id in node_ids:
21752174
self.remove_node(node_id)
2176-
self._emit("node_deleted", msg)
21772175
case "edge_deleted":
21782176
edge_ids = msg.get("edge_ids") or []
21792177
if msg.get("edge_id"):
21802178
edge_ids = list(set(edge_ids) | {msg.get("edge_id")})
21812179
for edge_id in edge_ids:
21822180
self.remove_edge(edge_id)
2183-
self._emit("edge_deleted", msg)
21842181
case "node_clicked":
21852182
node_id = msg.get("node_id")
21862183
if node_id is None:

tests/test_api.py

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -501,6 +501,177 @@ def test_reactflow_events_and_selection() -> None:
501501
assert flow.edges[0]["data"]["weight"] == 0.25
502502

503503

504+
def test_handle_msg_edge_added_emits_once() -> None:
505+
"""Frontend connect sends edge_added; Python must not double-emit (add_edge already emits)."""
506+
flow = ReactFlow(
507+
nodes=[
508+
{"id": "n1", "position": {"x": 0, "y": 0}, "data": {}},
509+
{"id": "n2", "position": {"x": 1, "y": 1}, "data": {}},
510+
],
511+
edges=[],
512+
)
513+
events: list[dict] = []
514+
flow.on("edge_added", events.append)
515+
flow._handle_msg(
516+
{
517+
"type": "edge_added",
518+
"edge": {"id": "n1->n2", "source": "n1", "target": "n2"},
519+
},
520+
)
521+
assert len(events) == 1
522+
assert events[0]["type"] == "edge_added"
523+
assert events[0]["edge"]["id"] == "n1->n2"
524+
assert len(flow.edges) == 1
525+
526+
527+
def test_handle_msg_node_moved_emits_once() -> None:
528+
flow = ReactFlow(nodes=[{"id": "n1", "position": {"x": 0, "y": 0}, "data": {}}])
529+
events: list[dict] = []
530+
flow.on("node_moved", events.append)
531+
flow._handle_msg({"type": "node_moved", "node_id": "n1", "position": {"x": 10, "y": 20}})
532+
assert len(events) == 1
533+
assert events[0]["type"] == "node_moved"
534+
assert events[0]["node_id"] == "n1"
535+
assert events[0]["position"] == {"x": 10, "y": 20}
536+
assert flow.nodes[0]["position"] == {"x": 10, "y": 20}
537+
538+
539+
def test_handle_msg_selection_changed_emits_once() -> None:
540+
flow = ReactFlow(
541+
nodes=[
542+
{"id": "n1", "position": {"x": 0, "y": 0}, "data": {}},
543+
{"id": "n2", "position": {"x": 1, "y": 1}, "data": {}},
544+
],
545+
edges=[{"id": "e1", "source": "n1", "target": "n2", "data": {}}],
546+
)
547+
events: list[dict] = []
548+
flow.on("selection_changed", events.append)
549+
flow._handle_msg({"type": "selection_changed", "nodes": ["n1"], "edges": ["e1"]})
550+
assert len(events) == 1
551+
assert events[0]["type"] == "selection_changed"
552+
assert flow.selection == {"nodes": ["n1"], "edges": ["e1"]}
553+
554+
555+
def test_handle_msg_node_clicked_emits_once() -> None:
556+
flow = ReactFlow(nodes=[{"id": "n1", "position": {"x": 0, "y": 0}, "data": {}}])
557+
events: list[dict] = []
558+
flow.on("node_clicked", events.append)
559+
flow._handle_msg({"type": "node_clicked", "node_id": "n1", "button": 0})
560+
assert len(events) == 1
561+
assert events[0]["type"] == "node_clicked"
562+
assert events[0]["node_id"] == "n1"
563+
564+
565+
def test_handle_msg_sync_emits_once() -> None:
566+
flow = ReactFlow()
567+
events: list[dict] = []
568+
flow.on("sync", events.append)
569+
msg = {
570+
"type": "sync",
571+
"nodes": [
572+
{
573+
"id": "n1",
574+
"position": {"x": 0, "y": 0},
575+
"type": "panel",
576+
"data": {},
577+
"selected": False,
578+
}
579+
],
580+
"edges": [],
581+
}
582+
flow._handle_msg(msg)
583+
assert len(events) == 1
584+
assert events[0]["type"] == "sync"
585+
assert [n["id"] for n in flow.nodes] == ["n1"]
586+
587+
588+
def test_handle_msg_node_deleted_emits_once_for_single_node() -> None:
589+
"""remove_node already emits; _handle_msg must not emit a duplicate batch message."""
590+
flow = ReactFlow(
591+
nodes=[
592+
{"id": "n1", "position": {"x": 0, "y": 0}, "data": {}},
593+
{"id": "n2", "position": {"x": 1, "y": 1}, "data": {}},
594+
],
595+
edges=[{"id": "e1", "source": "n1", "target": "n2", "data": {}}],
596+
)
597+
events: list[dict] = []
598+
flow.on("node_deleted", events.append)
599+
flow._handle_msg(
600+
{
601+
"type": "node_deleted",
602+
"node_id": "n1",
603+
"node_ids": ["n1"],
604+
"deleted_edges": ["e1"],
605+
},
606+
)
607+
assert len(events) == 1
608+
assert events[0]["type"] == "node_deleted"
609+
assert events[0]["node_id"] == "n1"
610+
assert events[0]["deleted_edges"] == ["e1"]
611+
assert [n["id"] for n in flow.nodes] == ["n2"]
612+
assert flow.edges == []
613+
614+
615+
def test_handle_msg_node_deleted_one_event_per_node_when_batch() -> None:
616+
flow = ReactFlow(
617+
nodes=[
618+
{"id": "n1", "position": {"x": 0, "y": 0}, "data": {}},
619+
{"id": "n2", "position": {"x": 1, "y": 1}, "data": {}},
620+
],
621+
edges=[],
622+
)
623+
events: list[dict] = []
624+
flow.on("node_deleted", events.append)
625+
flow._handle_msg(
626+
{
627+
"type": "node_deleted",
628+
"node_id": None,
629+
"node_ids": ["n1", "n2"],
630+
"deleted_edges": [],
631+
},
632+
)
633+
assert len(events) == 2
634+
assert {e["node_id"] for e in events} == {"n1", "n2"}
635+
assert flow.nodes == []
636+
637+
638+
def test_handle_msg_edge_deleted_emits_once() -> None:
639+
"""remove_edge already emits; _handle_msg must not emit a duplicate batch message."""
640+
flow = ReactFlow(
641+
nodes=[
642+
{"id": "n1", "position": {"x": 0, "y": 0}, "data": {}},
643+
{"id": "n2", "position": {"x": 1, "y": 1}, "data": {}},
644+
],
645+
edges=[{"id": "e1", "source": "n1", "target": "n2", "data": {}}],
646+
)
647+
events: list[dict] = []
648+
flow.on("edge_deleted", events.append)
649+
flow._handle_msg({"type": "edge_deleted", "edge_id": "e1", "edge_ids": ["e1"]})
650+
assert len(events) == 1
651+
assert events[0]["type"] == "edge_deleted"
652+
assert events[0]["edge_id"] == "e1"
653+
assert flow.edges == []
654+
655+
656+
def test_handle_msg_edge_deleted_one_event_per_edge_when_batch() -> None:
657+
flow = ReactFlow(
658+
nodes=[
659+
{"id": "n1", "position": {"x": 0, "y": 0}, "data": {}},
660+
{"id": "n2", "position": {"x": 1, "y": 1}, "data": {}},
661+
],
662+
edges=[
663+
{"id": "e1", "source": "n1", "target": "n2", "data": {}},
664+
{"id": "e2", "source": "n2", "target": "n1", "data": {}},
665+
],
666+
)
667+
events: list[dict] = []
668+
flow.on("edge_deleted", events.append)
669+
flow._handle_msg({"type": "edge_deleted", "edge_id": None, "edge_ids": ["e1", "e2"]})
670+
assert len(events) == 2
671+
assert {e["edge_id"] for e in events} == {"e1", "e2"}
672+
assert flow.edges == []
673+
674+
504675
@nx_available
505676
def test_reactflow_to_networkx() -> None:
506677
flow = ReactFlow()

0 commit comments

Comments
 (0)