!candidateReason(connection) : undefined}
nodesDraggable={editable}
nodesConnectable={editable && enableConnect}
elementsSelectable={editable}
@@ -1074,6 +1242,12 @@ function FlowInner({
{showMinimap ? : null}
+ {drag && hasConnectionValidators && (!validationResults || validationResults.error) ? (
+
+ {validationResults?.error || "Checking connections..."}
+
+ ) : null}
+
);
}
@@ -1095,6 +1269,8 @@ export function render({ model, view }) {
const [hoverDelay] = model.useState("popup_hover_delay");
const [errorRecovery] = model.useState("error_recovery");
const [enableConnect] = model.useState("enable_connect");
+ const [connectionValidation] = model.useState("connection_validation");
+ const [hasConnectionValidators] = model.useState("has_connection_validators");
const [enableDelete] = model.useState("enable_delete");
const [enableMultiselect] = model.useState("enable_multiselect");
const [maxZoom] = model.useState("max_zoom");
@@ -1397,10 +1573,13 @@ export function render({ model, view }) {
defaultEdgeOptions={defaultEdgeOptions}
colorMode={colorMode}
nodeTypes={hydratedNodeTypes}
+ nodeTypeSpecs={allNodeTypes}
edgeTypes={hydratedEdgeTypes}
nodeEditors={nodeEditors}
editable={editable}
enableConnect={enableConnect}
+ connectionValidation={connectionValidation}
+ hasConnectionValidators={hasConnectionValidators}
enableDelete={enableDelete}
enableMultiselect={enableMultiselect}
maxZoom={maxZoom}
diff --git a/tests/test_api.py b/tests/test_api.py
index 2ddfe6c..b9a54cc 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -463,6 +463,185 @@ def _two_node_flow(**params) -> ReactFlow:
)
+def test_connection_validator_registration_and_policy_sync() -> None:
+ """Registration controls the synced hook flag; policies remain independently configurable."""
+ policy = {"direction": True, "cycles": True, "duplicates": False, "types": True, "capacity": True}
+ flow = ReactFlow(connection_validation=policy)
+ first = lambda payload: None
+ second = lambda payload, flow: None
+
+ assert flow.has_connection_validators is False
+ assert flow._process_param_change({"connection_validation": flow.connection_validation})["connection_validation"] == policy
+ assert flow._process_param_change({"has_connection_validators": False})["has_connection_validators"] is False
+ flow.add_connection_validator(first)
+ flow.add_connection_validator(second)
+ assert flow.has_connection_validators is True
+ flow.remove_connection_validator(first)
+ assert flow.has_connection_validators is True
+ flow.remove_connection_validator(second)
+ assert flow.has_connection_validators is False
+ with pytest.raises(TypeError, match="callable"):
+ flow.add_connection_validator(None)
+
+
+def test_connection_validator_flag_syncs_to_model(document, comm) -> None:
+ """The browser receives both the registration flag and opt-in policy updates."""
+ flow = ReactFlow()
+ model = flow.get_root(document, comm=comm)
+ assert model.data.has_connection_validators is False
+ assert model.data.connection_validation == {}
+
+ def validate(payload):
+ return None
+
+ flow.add_connection_validator(validate)
+ flow.connection_validation = {"cycles": True}
+ assert model.data.has_connection_validators is True
+ assert model.data.connection_validation == {"cycles": True}
+ flow.remove_connection_validator(validate)
+ assert model.data.has_connection_validators is False
+
+
+def test_connection_validation_source_drag_includes_all_target_handles() -> None:
+ """A source drag checks target ports on every node, including itself and defaults."""
+ flow = ReactFlow(
+ nodes=[
+ Node(id="start", type="typed"),
+ {"id": "plain", "type": "panel"},
+ {"id": "empty", "type": "empty"},
+ ],
+ node_types={
+ "typed": NodeType(type="typed", inputs=["in", {"id": "other", "maxConnections": 2}]),
+ "panel": {"inputs": None, "outputs": None},
+ "empty": NodeType(type="empty", inputs=[]),
+ },
+ )
+ msgs = _capture_msgs(flow)
+ flow._handle_msg({"type": "connection_validation_requested", "request_id": 12, "node_id": "start", "handle_id": "out", "handle_type": "source"})
+
+ assert msgs == [
+ {
+ "type": "connection_validation_result",
+ "request_id": 12,
+ "results": [
+ {"node_id": "start", "handle_id": "in", "handle_type": "target", "reason": None},
+ {"node_id": "start", "handle_id": "other", "handle_type": "target", "reason": None},
+ {"node_id": "plain", "handle_id": None, "handle_type": "target", "reason": None},
+ ],
+ }
+ ]
+
+
+def test_connection_validation_reverse_drag_passes_oriented_payload() -> None:
+ """A target drag builds canonical source-to-target payloads for candidate outputs."""
+ flow = ReactFlow(
+ nodes=[{"id": "start", "type": "typed"}, {"id": "plain"}, {"id": "empty", "type": "empty"}],
+ node_types={"typed": NodeType(type="typed", outputs=["out", {"id": "other"}]), "empty": NodeType(type="empty", outputs=[])},
+ )
+ seen = []
+
+ def validate(payload, graph):
+ assert graph is flow
+ seen.append(payload.copy())
+ return None
+
+ flow.add_connection_validator(validate)
+ msgs = _capture_msgs(flow)
+ flow._handle_msg({"type": "connection_validation_requested", "request_id": 13, "node_id": "start", "handle_id": "in", "handle_type": "target"})
+
+ assert seen == [
+ {"source": "start", "target": "start", "sourceHandle": "out", "targetHandle": "in"},
+ {"source": "start", "target": "start", "sourceHandle": "other", "targetHandle": "in"},
+ {"source": "plain", "target": "start", "sourceHandle": None, "targetHandle": "in"},
+ ]
+ assert msgs == [
+ {
+ "type": "connection_validation_result",
+ "request_id": 13,
+ "results": [
+ {"node_id": "start", "handle_id": "out", "handle_type": "source", "reason": None},
+ {"node_id": "start", "handle_id": "other", "handle_type": "source", "reason": None},
+ {"node_id": "plain", "handle_id": None, "handle_type": "source", "reason": None},
+ ],
+ }
+ ]
+
+
+def test_connection_validation_rejection_and_callback_order() -> None:
+ """The first rejecting hook supplies the reason; later hooks skip only that candidate."""
+ flow = _two_node_flow()
+ seen = []
+
+ def reject_self(payload):
+ seen.append(("first", payload["target"]))
+ return "No loops" if payload["source"] == payload["target"] else None
+
+ def reject_other(payload, graph):
+ assert graph is flow
+ seen.append(("second", payload["target"]))
+ return "No n2" if payload["target"] == "n2" else None
+
+ flow.add_connection_validator(reject_self)
+ flow.add_connection_validator(reject_other)
+ msgs = _capture_msgs(flow)
+ flow._handle_msg({"type": "connection_validation_requested", "request_id": 14, "node_id": "n1", "handle_id": None, "handle_type": "source"})
+
+ assert seen == [("first", "n1"), ("first", "n2"), ("second", "n2")]
+ assert [result["reason"] for result in msgs[0]["results"]] == ["No loops", "No n2"]
+
+
+def test_empty_validator_reason_still_rejects() -> None:
+ flow = _two_node_flow()
+ flow.add_connection_validator(lambda payload: "")
+ msgs = _capture_msgs(flow)
+
+ flow._handle_msg({"type": "connection_validation_requested", "request_id": 18, "node_id": "n1", "handle_id": None, "handle_type": "source"})
+
+ assert all(result["reason"] == "Connection rejected." for result in msgs[0]["results"])
+
+
+def test_connection_validators_only_run_on_drag_requests() -> None:
+ """Normal edge additions do not call connection validators or emit validation replies."""
+ flow = _two_node_flow()
+ seen = []
+
+ def validate(payload):
+ seen.append(payload)
+ return "blocked"
+
+ flow.add_connection_validator(validate)
+ msgs = _capture_msgs(flow)
+ flow._handle_msg({"type": "edge_added", "edge": {"id": "e1", "source": "n1", "target": "n2"}})
+
+ assert len(flow.edges) == 1
+ assert seen == []
+ assert msgs == []
+
+
+@pytest.mark.parametrize("error", [ValueError("broken"), ValueError("")])
+def test_connection_validation_hook_errors_fail_closed(error, caplog) -> None:
+ """An exception rejects a candidate and still produces a response for every handle."""
+ flow = _two_node_flow()
+
+ def raises(payload):
+ raise error
+
+ flow.add_connection_validator(raises)
+ msgs = _capture_msgs(flow)
+ flow._handle_msg({"type": "connection_validation_requested", "request_id": 15, "node_id": "n1", "handle_id": None, "handle_type": "source"})
+
+ assert [result["reason"] for result in msgs[0]["results"]] == [str(error) or "Connection validator failed."] * 2
+ assert "Connection validator failed" in caplog.text
+
+
+def test_connection_validation_no_candidates_still_replies() -> None:
+ """Empty port lists produce an empty correlated response without invoking hooks."""
+ flow = ReactFlow(nodes=[{"id": "n1", "type": "empty"}], node_types={"empty": NodeType(type="empty", inputs=[])})
+ msgs = _capture_msgs(flow)
+ flow._handle_msg({"type": "connection_validation_requested", "request_id": 16, "node_id": "n1", "handle_id": None, "handle_type": "source"})
+ assert msgs == [{"type": "connection_validation_result", "request_id": 16, "results": []}]
+
+
def test_node_base_param_change_patches_props() -> None:
node = Node(id="n1", position={"x": 0, "y": 0}, label="A")
flow = ReactFlow(nodes=[node])
diff --git a/tests/ui/test_ui.py b/tests/ui/test_ui.py
index 6beb28a..8df8f30 100644
--- a/tests/ui/test_ui.py
+++ b/tests/ui/test_ui.py
@@ -1,6 +1,10 @@
"""UI tests for ReactFlow using Playwright."""
import re
+import runpy
+import threading
+import time
+from pathlib import Path
import panel as pn
import panel.models.jsoneditor # noqa
@@ -167,6 +171,7 @@ def _viewport_updated():
def test_connecting_edge_updates_python(page):
+ """Repeated drags without duplicate validation keep browser and Python edges in sync."""
flow = _make_flow(include_edge=False)
serve_component(page, flow)
@@ -181,6 +186,238 @@ def _edge_added():
wait_until(_edge_added, timeout=8000)
expect(page.locator(".react-flow__edge")).to_have_count(1)
+ source_handle.drag_to(target_handle)
+ page.wait_for_timeout(300)
+ expect(page.locator(".react-flow__edge")).to_have_count(1)
+ assert len(flow.edges) == 1
+
+
+def test_frontend_connection_validation_filters_targets(page):
+ flow = ReactFlow(
+ nodes=[
+ NodeSpec(id="source", type="producer", label="Source", position={"x": 0, "y": 0}).to_dict(),
+ NodeSpec(id="wrong", type="wrong", label="Wrong", position={"x": 260, "y": 0}).to_dict(),
+ NodeSpec(id="right", type="right", label="Right", position={"x": 260, "y": 180}).to_dict(),
+ ],
+ node_types={
+ "producer": NodeType(type="producer", outputs=[{"id": "out", "type": "Text"}]),
+ "wrong": NodeType(type="wrong", inputs=[{"id": "in", "type": "Number"}]),
+ "right": NodeType(type="right", inputs=[{"id": "in", "type": "text", "maxConnections": 1}]),
+ },
+ connection_validation={"direction": True, "types": True, "capacity": True},
+ width=750,
+ height=500,
+ )
+ serve_component(page, flow)
+ source = _node_locator(page, "Source").locator(".react-flow__handle-right")
+ wrong = _node_locator(page, "Wrong").locator(".react-flow__handle-left")
+ right = _node_locator(page, "Right").locator(".react-flow__handle-left")
+
+ source.drag_to(wrong)
+ expect(page.locator(".react-flow__edge")).to_have_count(0)
+ assert flow.edges == []
+
+ source.drag_to(right)
+ wait_until(lambda: len(flow.edges) == 1, timeout=8000)
+ source.drag_to(right)
+ expect(page.locator(".react-flow__edge")).to_have_count(1)
+ assert len(flow.edges) == 1
+
+
+def test_frontend_connection_validation_blocks_duplicates_and_cycles(page):
+ flow = ReactFlow(
+ nodes=[
+ NodeSpec(id="a", type="step", label="A", position={"x": 0, "y": 0}).to_dict(),
+ NodeSpec(id="b", type="step", label="B", position={"x": 260, "y": 0}).to_dict(),
+ ],
+ node_types={"step": NodeType(type="step", inputs=["in"], outputs=["out"])},
+ connection_validation={"direction": True, "cycles": True, "duplicates": True},
+ width=650,
+ height=350,
+ )
+ serve_component(page, flow)
+ output_a = _node_locator(page, "A").locator(".react-flow__handle-right")
+ input_a = _node_locator(page, "A").locator(".react-flow__handle-left")
+ output_b = _node_locator(page, "B").locator(".react-flow__handle-right")
+ input_b = _node_locator(page, "B").locator(".react-flow__handle-left")
+
+ output_a.drag_to(input_b)
+ wait_until(lambda: len(flow.edges) == 1, timeout=8000)
+ output_a.drag_to(input_b)
+ output_b.drag_to(input_a)
+ expect(page.locator(".react-flow__edge")).to_have_count(1)
+ assert len(flow.edges) == 1
+
+
+def test_frontend_validation_supports_multiple_ports_between_nodes(page):
+ flow = ReactFlow(
+ nodes=[
+ NodeSpec(id="source", type="producer", label="Source", position={"x": 0, "y": 0}).to_dict(),
+ NodeSpec(id="target", type="consumer", label="Target", position={"x": 260, "y": 0}).to_dict(),
+ ],
+ node_types={
+ "producer": NodeType(type="producer", outputs=["a", "b"]),
+ "consumer": NodeType(type="consumer", inputs=["a", "b"]),
+ },
+ connection_validation={"direction": True, "duplicates": True},
+ width=650,
+ height=350,
+ )
+ serve_component(page, flow)
+ source = _node_locator(page, "Source").locator(".react-flow__handle-right")
+ target = _node_locator(page, "Target").locator(".react-flow__handle-left")
+
+ source.nth(0).drag_to(target.nth(0))
+ wait_until(lambda: len(flow.edges) == 1, timeout=8000)
+ source.nth(1).drag_to(target.nth(1))
+ wait_until(lambda: len(flow.edges) == 2, timeout=8000)
+ assert {edge["id"] for edge in flow.edges} == {"source->target", "source->target:1"}
+
+
+def test_disabled_handle_end_is_not_highlighted_as_valid(page):
+ flow = ReactFlow(
+ nodes=[
+ NodeSpec(id="source", type="producer", label="Source", position={"x": 0, "y": 0}).to_dict(),
+ NodeSpec(id="sink", type="consumer", label="Sink", position={"x": 260, "y": 0}).to_dict(),
+ ],
+ node_types={
+ "producer": NodeType(type="producer", outputs=["out"], output_connectable_end=False),
+ "consumer": NodeType(type="consumer", inputs=["in"], input_connectable_end=False),
+ },
+ connection_validation={"direction": True},
+ width=650,
+ height=350,
+ )
+ serve_component(page, flow)
+ source = _node_locator(page, "Source").locator(".react-flow__handle-right")
+ sink = _node_locator(page, "Sink").locator(".react-flow__handle-left")
+
+ for origin, destination in ((source, sink), (sink, source)):
+ box = origin.bounding_box()
+ page.mouse.move(box["x"] + box["width"] / 2, box["y"] + box["height"] / 2)
+ page.mouse.down()
+ page.mouse.move(box["x"] + box["width"] / 2 + 25, box["y"] + box["height"] / 2)
+ expect(destination).to_have_class(re.compile("rf-handle-invalid"))
+ hint = destination.get_attribute("title") or destination.get_attribute("data-tooltip")
+ assert hint == "Handle cannot accept connections"
+ page.mouse.up()
+
+
+def test_python_connection_validation_filters_targets(page):
+ flow = ReactFlow(
+ nodes=[
+ NodeSpec(id="source", type="producer", label="Source", position={"x": 0, "y": 0}).to_dict(),
+ NodeSpec(id="wrong", type="consumer", label="Wrong", position={"x": 260, "y": 0}).to_dict(),
+ NodeSpec(id="right", type="consumer", label="Right", position={"x": 260, "y": 180}).to_dict(),
+ ],
+ node_types={
+ "producer": NodeType(type="producer", outputs=["out"]),
+ "consumer": NodeType(type="consumer", inputs=["in"]),
+ },
+ width=750,
+ height=500,
+ )
+ flow.add_connection_validator(lambda payload, _flow: "Blocked by Python" if payload["target"] == "wrong" else None)
+ serve_component(page, flow)
+ source = _node_locator(page, "Source").locator(".react-flow__handle-right")
+ wrong = _node_locator(page, "Wrong").locator(".react-flow__handle-left")
+ right = _node_locator(page, "Right").locator(".react-flow__handle-left")
+
+ box = source.bounding_box()
+ page.mouse.move(box["x"] + box["width"] / 2, box["y"] + box["height"] / 2)
+ page.mouse.down()
+ page.mouse.move(box["x"] + box["width"] / 2 + 25, box["y"] + box["height"] / 2)
+ expect(wrong).to_have_class(re.compile("rf-handle-invalid"))
+ expect(right).to_have_class(re.compile("rf-handle-valid"))
+ expect(wrong).to_have_attribute("data-tooltip", "Blocked by Python")
+ page.mouse.up()
+
+ source.drag_to(wrong)
+ expect(page.locator(".react-flow__edge")).to_have_count(0)
+ box = right.bounding_box()
+ destination = source.bounding_box()
+ page.mouse.move(box["x"] + box["width"] / 2, box["y"] + box["height"] / 2)
+ page.mouse.down()
+ page.mouse.move(box["x"] + box["width"] / 2 - 25, box["y"] + box["height"] / 2)
+ expect(source).to_have_class(re.compile("rf-handle-valid"))
+ page.mouse.move(destination["x"] + destination["width"] / 2, destination["y"] + destination["height"] / 2)
+ page.mouse.up()
+ wait_until(lambda: len(flow.edges) == 1, timeout=8000)
+ assert flow.edges[0]["source"] == "source" and flow.edges[0]["target"] == "right"
+
+
+def test_late_python_validation_reply_does_not_reopen_drag(page):
+ flow = ReactFlow(
+ nodes=[
+ NodeSpec(id="source", label="Source", position={"x": 0, "y": 0}).to_dict(),
+ NodeSpec(id="target", label="Target", position={"x": 260, "y": 0}).to_dict(),
+ ],
+ width=650,
+ height=350,
+ )
+
+ completed = threading.Event()
+
+ def validate(payload):
+ if payload["target"] == "target":
+ time.sleep(3.5)
+ completed.set()
+
+ flow.add_connection_validator(validate)
+ serve_component(page, flow)
+ source = _node_locator(page, "Source").locator(".react-flow__handle-right")
+ target = _node_locator(page, "Target").locator(".react-flow__handle-left")
+ origin = source.bounding_box()
+ page.mouse.move(origin["x"] + origin["width"] / 2, origin["y"] + origin["height"] / 2)
+ page.mouse.down()
+ page.mouse.move(origin["x"] + origin["width"] / 2 + 25, origin["y"] + origin["height"] / 2)
+ expect(page.locator(".rf-validation-status")).to_have_text("Connection validation timed out")
+ wait_until(completed.is_set, timeout=8000)
+ page.wait_for_timeout(300)
+ expect(target).to_have_attribute("title", "Connection validation timed out", timeout=8000)
+ destination = target.bounding_box()
+ page.mouse.move(destination["x"] + destination["width"] / 2, destination["y"] + destination["height"] / 2)
+ page.mouse.up()
+ expect(page.locator(".react-flow__edge")).to_have_count(0)
+ assert flow.edges == []
+
+
+def test_connection_validation_demo(page):
+ demo_path = Path(__file__).resolve().parents[2] / "examples" / "connection_validation.py"
+ demo = runpy.run_path(str(demo_path))["demo"]
+ serve_component(page, demo)
+
+ source = _node_locator(page, "Source").locator(".react-flow__handle-right")
+ number = _node_locator(page, "Number").locator(".react-flow__handle-right")
+ transform = _node_locator(page, "Transform").locator(".react-flow__handle-left")
+ publish = _node_locator(page, "Publish").locator(".react-flow__handle-left")
+
+ def start_drag(handle):
+ box = handle.bounding_box()
+ page.mouse.move(box["x"] + box["width"] / 2, box["y"] + box["height"] / 2)
+ page.mouse.down()
+ page.mouse.move(box["x"] + box["width"] / 2 + 25, box["y"] + box["height"] / 2)
+
+ start_drag(number)
+ expect(transform).to_have_class(re.compile("rf-handle-invalid"))
+ assert "Type mismatch" in transform.get_attribute("data-tooltip")
+ page.mouse.up()
+
+ start_drag(source)
+ expect(publish).to_have_attribute("data-tooltip", "Publish requires text from Transform, not Source.")
+ box = publish.bounding_box()
+ page.mouse.move(box["x"] + box["width"] / 2, box["y"] + box["height"] / 2)
+ page.mouse.up()
+ assert demo._flow.edges == []
+
+ start_drag(source)
+ expect(transform).to_have_class(re.compile("rf-handle-valid"))
+ box = transform.bounding_box()
+ page.mouse.move(box["x"] + box["width"] / 2, box["y"] + box["height"] / 2)
+ page.mouse.up()
+ wait_until(lambda: len(demo._flow.edges) == 1, timeout=8000)
+ expect(page.get_by_text("Connected: source.text to transform.text")).to_be_visible()
+
def test_selection_syncs_between_ui_and_python(page):
flow = _make_flow()
diff --git a/zensical.toml b/zensical.toml
index 3b01cfa..ea9d9d8 100644
--- a/zensical.toml
+++ b/zensical.toml
@@ -18,6 +18,7 @@ nav = [
{"Define Editors" = "how-to/define-editors.md"},
{"Embed Views in Nodes" = "how-to/embed-views-in-nodes.md"},
{"Style Nodes & Edges" = "how-to/style-nodes-edges.md"},
+ {"Validate Connections" = "how-to/validate-connections.md"},
{"React to Events" = "how-to/react-to-events.md"},
{"Recover from Rendering Errors" = "how-to/recover-from-errors.md"}
]},