Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 26 additions & 3 deletions src/panel_reactflow/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -1407,6 +1407,7 @@ class ReactFlow(ReactComponent):
_edge_editors = param.Dict(default={}, doc="Per-edge editors.", precedence=-1)
_edge_editor_views = Children(default=[], doc="Edge editor views (one per edge, same order).")
_views = Children(default=[], doc="Panel viewables rendered inside nodes via view_idx.")
_node_update_count = param.Integer(default=0, doc="Monotonic counter for normalized node updates.")

_bundle = DIST_PATH / "panel-reactflow.bundle.js"
_esm = Path(__file__).parent / "models" / "reactflow.jsx"
Expand All @@ -1423,6 +1424,7 @@ def __init__(self, **params: Any):
self._attached_edge_instances: dict[int, Edge] = {}
self._node_data_param_watchers: dict[str, tuple[Node, list[Any]]] = {}
self._edge_data_param_watchers: dict[str, tuple[Edge, list[Any]]] = {}
self._node_view_cache: dict[str, tuple[int, Any]] = {}
# Normalize type specs before parent init so the frontend receives
# JSON-serializable descriptors from the start.
if "node_types" in params:
Expand Down Expand Up @@ -1450,6 +1452,7 @@ def __init__(self, **params: Any):
self._update_edge_editors,
["edges", "selection", "edge_editors", "default_edge_editor"],
)
self.param.watch(self._update_views, ["nodes"])
self._sync_instance_flow_refs()
self._update_node_editors()
self._update_edge_editors()
Expand Down Expand Up @@ -1546,10 +1549,18 @@ def _node_data(node: dict[str, Any] | Node) -> dict[str, Any]:
return dict(node.data or {})
return dict(node.get("data", {}))

@staticmethod
def _node_view(node: dict[str, Any] | Node) -> Any | None:
def _node_view(self, node: dict[str, Any] | Node) -> Any | None:
if isinstance(node, Node):
return node.__panel__()
node_id = self._node_id(node)
node_ref = id(node)
if node_id is not None:
cached = self._node_view_cache.get(node_id)
if cached is not None and cached[0] == node_ref:
return cached[1]
view = node.__panel__()
if node_id is not None and view is not None:
self._node_view_cache[node_id] = (node_ref, view)
return view
return node.get("view", None)

@staticmethod
Expand Down Expand Up @@ -1958,6 +1969,18 @@ def _patch_views(self, view_models: list[UIElement]) -> None:
if BK_FIGURE_CSS not in fig.stylesheets:
fig.stylesheets = fig.stylesheets + [BK_FIGURE_CSS]

def _update_views(self, *events: tuple[param.parameterized.Event]) -> None:
event = events[0] if events else None
nodes = event.new if event is not None else self.nodes
normalized = [self._coerce_node(node) for node in nodes]
node_ids = {self._node_id(node) for node in normalized}
self._node_view_cache = {node_id: cached for node_id, cached in self._node_view_cache.items() if node_id in node_ids}
is_normalized = not any(n1 is not n2 for n1, n2 in zip(normalized, nodes, strict=False))
if not is_normalized:
return
self.param.trigger("_views")
self._node_update_count += 1

def _process_param_change(self, params):
params = super()._process_param_change(params)
if "nodes" in params:
Expand Down
120 changes: 61 additions & 59 deletions src/panel_reactflow/models/reactflow.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,7 @@ function FlowInner({
model,
hydratedNodes,
pyNodes,
nodeUpdateCount,
hydratedEdges,
selectionSetter,
currentSelection,
Expand All @@ -242,7 +243,9 @@ function FlowInner({
const [edges, setEdges, onEdgesChange] = useEdgesState(hydratedEdges);
const nodesRef = useRef(nodes);
const edgesRef = useRef(edges);
const lastHydrated = useRef({ nodesSig: null, viewsRef: null, editorsRef: null, edgesSig: null, edgeEditorsSig: null });
const hydrationFrameRef = useRef(null);
const edgeHydrationFrameRef = useRef(null);
const lastHydrated = useRef({ nodeRevision: null, nodesSig: null, edgesSig: null, edgeEditorsSig: null });
const lastViewportSig = useRef(null);
const { setViewport: setRfViewport } = useReactFlow();

Expand Down Expand Up @@ -291,45 +294,67 @@ function FlowInner({
}, [edges]);

useEffect(() => {
const readyByNodeId = new Map(
(hydratedNodes || []).map((node) => [node?.id, Boolean(node?.data?._viewReady)]),
);
const pyNodesWithReady = (pyNodes || []).map((node) => ({
...node,
_viewReady: readyByNodeId.get(node?.id) ?? true,
}));
const nodesSig = signature(pyNodesWithReady);
const viewsSig = signature((views || []).map((view) => view?.props?.id ?? null));
const editorsSig = signature((nodeEditors || []).map((editor) => editor?.props?.id ?? null));
if (nodesSig === lastHydrated.current.nodesSig && viewsSig === lastHydrated.current.viewsRef && editorsSig === lastHydrated.current.editorsRef) {
return () => {
if (hydrationFrameRef.current !== null) {
cancelAnimationFrame(hydrationFrameRef.current);
hydrationFrameRef.current = null;
}
if (edgeHydrationFrameRef.current !== null) {
cancelAnimationFrame(edgeHydrationFrameRef.current);
edgeHydrationFrameRef.current = null;
}
};
}, []);

useEffect(() => {
const nodesSig = signature(hydratedNodes);
if (
nodeUpdateCount === lastHydrated.current.nodeRevision &&
nodesSig === lastHydrated.current.nodesSig
) {
return;
}
lastHydrated.current.nodesSig = nodesSig;
lastHydrated.current.viewsRef = viewsSig;
lastHydrated.current.editorsRef = editorsSig;

setNodes((curr) => {
const currById = new Map(curr.map((n) => [n.id, n]));
const merged = hydratedNodes.map((n) => {
const prev = currById.get(n.id);
if (!prev) return n;
return {
...n,
selected: prev.selected,
dragging: prev.dragging,
};

if (hydrationFrameRef.current !== null) {
cancelAnimationFrame(hydrationFrameRef.current);
}
hydrationFrameRef.current = requestAnimationFrame(() => {
setNodes((curr) => {
const currById = new Map(curr.map((n) => [n.id, n]));
const merged = hydratedNodes.map((n) => {
const prev = currById.get(n.id);
if (!prev) return n;
const next = {
...n,
selected: prev.selected,
dragging: prev.dragging,
};
return areEqual(prev, next) ? prev : next;
});
if (merged.length === curr.length && merged.every((node, index) => node === curr[index])) {
return curr;
}
return merged;
});
return merged;
lastHydrated.current.nodeRevision = nodeUpdateCount;
lastHydrated.current.nodesSig = nodesSig;
hydrationFrameRef.current = null;
});
}, [hydratedNodes, pyNodes, setNodes, views, nodeEditors]);
}, [hydratedNodes, pyNodes, setNodes, views, nodeEditors, nodeUpdateCount]);

useEffect(() => {
const edgesSig = signature(hydratedEdges);
const editorsSig = signature((edgeEditors || []).map((editor) => editor?.props?.id ?? null));
if (edgesSig !== lastHydrated.current.edgesSig || editorsSig !== lastHydrated.current.edgeEditorsSig) {
lastHydrated.current.edgesSig = edgesSig;
lastHydrated.current.edgeEditorsSig = editorsSig;
setEdges(hydratedEdges);
if (edgeHydrationFrameRef.current !== null) {
cancelAnimationFrame(edgeHydrationFrameRef.current);
}
edgeHydrationFrameRef.current = requestAnimationFrame(() => {
setEdges((curr) => (areEqual(curr, hydratedEdges) ? curr : hydratedEdges));
edgeHydrationFrameRef.current = null;
});
}
}, [hydratedEdges, setEdges, edgeEditors]);

Expand Down Expand Up @@ -409,32 +434,6 @@ function FlowInner({
const onNodesDelete = useCallback(
(deletedNodes) => {
const deletedIds = deletedNodes.map((node) => node.id);
const deletedViewIdx = deletedNodes
.map((node) => node?.data?.view_idx)
.filter((value) => Number.isFinite(value))
.sort((a, b) => a - b);
if (deletedViewIdx.length) {
const deletedSet = new Set(deletedIds);
setNodes((current) =>
current.map((node) => {
if (deletedSet.has(node.id)) {
return node;
}
const viewIdx = node?.data?.view_idx;
if (!Number.isFinite(viewIdx)) {
return node;
}
const shift = deletedViewIdx.filter((idx) => idx < viewIdx).length;
if (!shift) {
return node;
}
return {
...node,
data: { ...node.data, view_idx: viewIdx - shift },
};
}),
);
}
const deletedEdges = edgesRef.current.filter((edge) => deletedIds.includes(edge.source) || deletedIds.includes(edge.target));
schedulePatch({
type: "node_deleted",
Expand All @@ -443,7 +442,7 @@ function FlowInner({
deleted_edges: deletedEdges.map((edge) => edge.id),
});
},
[schedulePatch, setNodes],
[schedulePatch],
);

const onEdgesDelete = useCallback(
Expand Down Expand Up @@ -500,6 +499,7 @@ export function render({ model, view }) {
const [readyViewMap, setReadyViewMap] = useState(() => new Map());
const readyCheckTimeoutsRef = useRef(new Map());
const [pyNodes] = model.useState("nodes");
const [nodeUpdateCount] = model.useState("_node_update_count");
const [pyEdges] = model.useState("edges");
const [pyNodeTypes] = model.useState("node_types");
const [defaultEdgeOptions] = model.useState("default_edge_options");
Expand Down Expand Up @@ -614,18 +614,19 @@ export function render({ model, view }) {
return (pyNodes || []).map((node, idx) => {
const data = node.data || {};
const viewIndex = data.view_idx;
const { view_idx, ...dataWithoutViewIdx } = data;
const baseView = views[viewIndex];
const baseViewId = baseView?.key;
const isViewReady = baseViewId ? Boolean(readyViewMap.get(baseViewId)) : true;
const editorView = nodeEditors[idx];
const typeSpec = allNodeTypes[node.type] || {};
const realKeys = Object.keys(data).filter((k) => k !== "view_idx");
const realKeys = Object.keys(dataWithoutViewIdx);
const hasEditor = realKeys.length > 0 || !!typeSpec.schema;
return {
...node,
className: (node.type === "panel" || model.stylesheets.length > 7) ? "" : "react-flow__node-default",
data: {
...data,
...dataWithoutViewIdx,
view: baseView,
editor: editorView,
_viewReady: isViewReady,
Expand All @@ -634,7 +635,7 @@ export function render({ model, view }) {
},
};
});
}, [pyNodes, nodeEditors, views, editorMode, allNodeTypes, readyViewMap]);
}, [pyNodes, nodeEditors, views, editorMode, allNodeTypes]);

const hydratedEdges = useMemo(() => {
return (pyEdges || []).map((edge) => {
Expand Down Expand Up @@ -662,6 +663,7 @@ export function render({ model, view }) {
model={model}
hydratedNodes={hydratedNodes}
pyNodes={pyNodes || []}
nodeUpdateCount={nodeUpdateCount}
hydratedEdges={hydratedEdges}
selectionSetter={setSelection}
currentSelection={selection}
Expand Down
35 changes: 35 additions & 0 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,13 @@ class _ParameterizedNode(Node):
hidden = param.String(default="secret", precedence=-1)


class _PanelNode(Node):
text = param.String(default="", precedence=0)

def __panel__(self):
return pn.pane.Markdown(self.text)


def test_reactflow_accepts_node_instance() -> None:
flow = ReactFlow()
node = Node(id="n1", position={"x": 0, "y": 0}, label="Node object", data={"status": "idle"})
Expand Down Expand Up @@ -218,6 +225,34 @@ def test_node_flow_ref_updates_on_nodes_assignment() -> None:
assert node.flow is None


def test_views_triggered_on_nodes_reassignment_with_panel_nodes() -> None:
flow = ReactFlow(nodes=[_PanelNode(id="n1", position={"x": 0, "y": 0}, text="one")])
updates = []
watcher = flow.param.watch(lambda event: updates.append(event.name), "_views")
try:
flow.nodes = [_PanelNode(id="n2", position={"x": 20, "y": 10}, text="two")]
finally:
flow.param.unwatch(watcher)
assert "_views" in updates


def test_views_triggered_on_remove_node_with_panel_nodes() -> None:
flow = ReactFlow(
nodes=[
_PanelNode(id="n1", position={"x": 0, "y": 0}, text="one"),
_PanelNode(id="n2", position={"x": 20, "y": 10}, text="two"),
],
edges=[{"id": "e1", "source": "n1", "target": "n2", "data": {}}],
)
updates = []
watcher = flow.param.watch(lambda event: updates.append(event.name), "_views")
try:
flow.remove_node("n1")
finally:
flow.param.unwatch(watcher)
assert "_views" in updates


def test_edge_spec_roundtrip() -> None:
edge = EdgeSpec(id="e1", source="n1", target="n2", data={"weight": 0.5})
payload = edge.to_dict()
Expand Down
51 changes: 50 additions & 1 deletion tests/ui/test_ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@

import panel as pn
import panel.models.jsoneditor # noqa
import param
import pytest
from panel.custom import Child, ReactComponent
from panel.tests.util import serve_component, wait_until

from panel_reactflow import EdgeSpec, JsonEditor, NodeSpec, NodeType, ReactFlow
from panel_reactflow import EdgeSpec, JsonEditor, Node, NodeSpec, NodeType, ReactFlow

pytest.importorskip("playwright")

Expand Down Expand Up @@ -79,6 +81,24 @@ def _pane_locator(page):
return page.locator(".react-flow__pane")


class ReactChild(ReactComponent):
child = Child()
render_count = param.Integer(default=0)

_esm = """
export function render({ model }) {
model.render_count += 1
return <button>{model.get_child('child')}</button>
}"""


class CountingViewNode(Node):
view_component = param.Parameter(default=None, precedence=-1)

def __panel__(self):
return self.view_component


def test_render_nodes_edges_labels_views_and_panels(page):
flow = _make_flow(editor_mode="toolbar", include_edge=True)
serve_component(page, flow)
Expand Down Expand Up @@ -304,3 +324,32 @@ def test_editor_renders_in_side_mode(page):

_node_locator(page, "Start").click()
expect(page.locator(".jsoneditor").nth(0)).to_be_visible()


def test_delete_node_does_not_rerender_surviving_node_views(page):
view_a = ReactChild(child=pn.pane.Markdown("View A"))
view_b = ReactChild(child=pn.pane.Markdown("View B"))
view_c = ReactChild(child=pn.pane.Markdown("View C"))
flow = ReactFlow(
nodes=[
CountingViewNode(id="n1", position={"x": 0, "y": 0}, label="Node A", view_component=view_a),
CountingViewNode(id="n2", position={"x": 260, "y": 60}, label="Node B", view_component=view_b),
CountingViewNode(id="n3", position={"x": 520, "y": 120}, label="Node C", view_component=view_c),
],
width=900,
height=600,
)
serve_component(page, flow)

wait_until(lambda: view_a.render_count > 0 and view_b.render_count > 0 and view_c.render_count > 0, timeout=8000)
b_count_before = view_b.render_count
c_count_before = view_c.render_count

_node_locator(page, "Node A").click(force=True)
page.keyboard.press("Backspace")
wait_until(lambda: all(node.id != "n1" for node in flow.nodes), timeout=8000)

# Let any queued rerenders settle; surviving nodes should not rerender.
page.wait_for_timeout(300)
assert view_b.render_count == b_count_before
assert view_c.render_count == c_count_before
Loading