From 8c10dfff0d721ec6922127c8b171cfb0cbd451d4 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Thu, 26 Mar 2026 13:51:49 +0100 Subject: [PATCH 1/3] Ensure stale node views are updated and deletion does not cause flicker --- src/panel_reactflow/base.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/panel_reactflow/base.py b/src/panel_reactflow/base.py index 7147f2d..43a2d1b 100644 --- a/src/panel_reactflow/base.py +++ b/src/panel_reactflow/base.py @@ -1450,6 +1450,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() @@ -1958,6 +1959,9 @@ 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: + self.param.trigger("_views") + def _process_param_change(self, params): params = super()._process_param_change(params) if "nodes" in params: @@ -2222,10 +2226,11 @@ def remove_node(self, node_id: str) -> None: if (edge.source if isinstance(edge, Edge) else edge.get("source")) == node_id or (edge.target if isinstance(edge, Edge) else edge.get("target")) == node_id ] - self.nodes = nodes - if removed_edges: - remaining_edges = [edge for edge in self.edges if edge not in removed_edges] - self.edges = remaining_edges + with pn.io.hold(): + self.nodes = nodes + if removed_edges: + remaining_edges = [edge for edge in self.edges if edge not in removed_edges] + self.edges = remaining_edges self._emit( "node_deleted", { From 86fa839149b80880968be90d6575ddb4cbf898bb Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Thu, 26 Mar 2026 14:39:04 +0100 Subject: [PATCH 2/3] Avoid node view render churn --- src/panel_reactflow/base.py | 34 ++++-- src/panel_reactflow/models/reactflow.jsx | 126 ++++++++++++----------- tests/test_api.py | 35 +++++++ tests/ui/test_ui.py | 51 ++++++++- 4 files changed, 178 insertions(+), 68 deletions(-) diff --git a/src/panel_reactflow/base.py b/src/panel_reactflow/base.py index 43a2d1b..c55e67c 100644 --- a/src/panel_reactflow/base.py +++ b/src/panel_reactflow/base.py @@ -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" @@ -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: @@ -1547,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 @@ -1960,7 +1970,16 @@ def _patch_views(self, view_models: list[UIElement]) -> None: 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 + node_ids = {self._node_id(node) for node in nodes} + self._node_view_cache = {node_id: cached for node_id, cached in self._node_view_cache.items() if node_id in node_ids} + normalized = [self._coerce_node(node) for node in nodes] + 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) @@ -2226,11 +2245,10 @@ def remove_node(self, node_id: str) -> None: if (edge.source if isinstance(edge, Edge) else edge.get("source")) == node_id or (edge.target if isinstance(edge, Edge) else edge.get("target")) == node_id ] - with pn.io.hold(): - self.nodes = nodes - if removed_edges: - remaining_edges = [edge for edge in self.edges if edge not in removed_edges] - self.edges = remaining_edges + self.nodes = nodes + if removed_edges: + remaining_edges = [edge for edge in self.edges if edge not in removed_edges] + self.edges = remaining_edges self._emit( "node_deleted", { diff --git a/src/panel_reactflow/models/reactflow.jsx b/src/panel_reactflow/models/reactflow.jsx index 652f2e9..f708b29 100644 --- a/src/panel_reactflow/models/reactflow.jsx +++ b/src/panel_reactflow/models/reactflow.jsx @@ -217,6 +217,7 @@ function FlowInner({ model, hydratedNodes, pyNodes, + nodeUpdateCount, hydratedEdges, selectionSetter, currentSelection, @@ -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, edgesSig: null, edgeEditorsSig: null }); const lastViewportSig = useRef(null); const { setViewport: setRfViewport } = useReactFlow(); @@ -291,37 +294,59 @@ 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(() => { + if (nodeUpdateCount === lastHydrated.current.nodeRevision) { 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, - }; + const expectedViewCount = (pyNodes || []).reduce((maxIdx, node) => { + const idx = node?.data?.view_idx; + if (Number.isFinite(idx)) { + return Math.max(maxIdx, idx); + } + return maxIdx; + }, -1) + 1; + const expectedEditorCount = (pyNodes || []).length; + if ((views || []).length !== expectedViewCount || (nodeEditors || []).length !== expectedEditorCount) { + return; + } + + 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; + hydrationFrameRef.current = null; }); - }, [hydratedNodes, pyNodes, setNodes, views, nodeEditors]); + }, [hydratedNodes, pyNodes, setNodes, views, nodeEditors, nodeUpdateCount]); useEffect(() => { const edgesSig = signature(hydratedEdges); @@ -329,7 +354,13 @@ function FlowInner({ 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]); @@ -409,32 +440,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", @@ -443,7 +448,7 @@ function FlowInner({ deleted_edges: deletedEdges.map((edge) => edge.id), }); }, - [schedulePatch, setNodes], + [schedulePatch], ); const onEdgesDelete = useCallback( @@ -500,6 +505,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"); @@ -614,18 +620,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, @@ -634,7 +641,7 @@ export function render({ model, view }) { }, }; }); - }, [pyNodes, nodeEditors, views, editorMode, allNodeTypes, readyViewMap]); + }, [pyNodes, nodeEditors, views, editorMode, allNodeTypes]); const hydratedEdges = useMemo(() => { return (pyEdges || []).map((edge) => { @@ -662,6 +669,7 @@ export function render({ model, view }) { model={model} hydratedNodes={hydratedNodes} pyNodes={pyNodes || []} + nodeUpdateCount={nodeUpdateCount} hydratedEdges={hydratedEdges} selectionSetter={setSelection} currentSelection={selection} diff --git a/tests/test_api.py b/tests/test_api.py index b5b2139..483d299 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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"}) @@ -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() diff --git a/tests/ui/test_ui.py b/tests/ui/test_ui.py index ccd08ed..5a86761 100644 --- a/tests/ui/test_ui.py +++ b/tests/ui/test_ui.py @@ -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") @@ -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 + }""" + + +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) @@ -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 From 7ed28454b13c69fc887f221bd127fec19f1c06f5 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Thu, 26 Mar 2026 15:08:26 +0100 Subject: [PATCH 3/3] Fix node removal re-render --- src/panel_reactflow/base.py | 4 ++-- src/panel_reactflow/models/reactflow.jsx | 20 +++++++------------- 2 files changed, 9 insertions(+), 15 deletions(-) diff --git a/src/panel_reactflow/base.py b/src/panel_reactflow/base.py index c55e67c..7ec105c 100644 --- a/src/panel_reactflow/base.py +++ b/src/panel_reactflow/base.py @@ -1972,9 +1972,9 @@ def _patch_views(self, view_models: list[UIElement]) -> None: 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 - node_ids = {self._node_id(node) for node in nodes} - self._node_view_cache = {node_id: cached for node_id, cached in self._node_view_cache.items() if node_id in node_ids} 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 diff --git a/src/panel_reactflow/models/reactflow.jsx b/src/panel_reactflow/models/reactflow.jsx index f708b29..dd2bc07 100644 --- a/src/panel_reactflow/models/reactflow.jsx +++ b/src/panel_reactflow/models/reactflow.jsx @@ -245,7 +245,7 @@ function FlowInner({ const edgesRef = useRef(edges); const hydrationFrameRef = useRef(null); const edgeHydrationFrameRef = useRef(null); - const lastHydrated = useRef({ nodeRevision: null, edgesSig: null, edgeEditorsSig: null }); + const lastHydrated = useRef({ nodeRevision: null, nodesSig: null, edgesSig: null, edgeEditorsSig: null }); const lastViewportSig = useRef(null); const { setViewport: setRfViewport } = useReactFlow(); @@ -307,18 +307,11 @@ function FlowInner({ }, []); useEffect(() => { - if (nodeUpdateCount === lastHydrated.current.nodeRevision) { - return; - } - const expectedViewCount = (pyNodes || []).reduce((maxIdx, node) => { - const idx = node?.data?.view_idx; - if (Number.isFinite(idx)) { - return Math.max(maxIdx, idx); - } - return maxIdx; - }, -1) + 1; - const expectedEditorCount = (pyNodes || []).length; - if ((views || []).length !== expectedViewCount || (nodeEditors || []).length !== expectedEditorCount) { + const nodesSig = signature(hydratedNodes); + if ( + nodeUpdateCount === lastHydrated.current.nodeRevision && + nodesSig === lastHydrated.current.nodesSig + ) { return; } @@ -344,6 +337,7 @@ function FlowInner({ return merged; }); lastHydrated.current.nodeRevision = nodeUpdateCount; + lastHydrated.current.nodesSig = nodesSig; hydrationFrameRef.current = null; }); }, [hydratedNodes, pyNodes, setNodes, views, nodeEditors, nodeUpdateCount]);