Skip to content

Commit 9e456ac

Browse files
authored
Ensure stale node views are updated and deletion does not cause flicker (#60)
* Ensure stale node views are updated and deletion does not cause flicker * Avoid node view render churn * Fix node removal re-render
1 parent c6c926e commit 9e456ac

4 files changed

Lines changed: 172 additions & 63 deletions

File tree

src/panel_reactflow/base.py

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1407,6 +1407,7 @@ class ReactFlow(ReactComponent):
14071407
_edge_editors = param.Dict(default={}, doc="Per-edge editors.", precedence=-1)
14081408
_edge_editor_views = Children(default=[], doc="Edge editor views (one per edge, same order).")
14091409
_views = Children(default=[], doc="Panel viewables rendered inside nodes via view_idx.")
1410+
_node_update_count = param.Integer(default=0, doc="Monotonic counter for normalized node updates.")
14101411

14111412
_bundle = DIST_PATH / "panel-reactflow.bundle.js"
14121413
_esm = Path(__file__).parent / "models" / "reactflow.jsx"
@@ -1423,6 +1424,7 @@ def __init__(self, **params: Any):
14231424
self._attached_edge_instances: dict[int, Edge] = {}
14241425
self._node_data_param_watchers: dict[str, tuple[Node, list[Any]]] = {}
14251426
self._edge_data_param_watchers: dict[str, tuple[Edge, list[Any]]] = {}
1427+
self._node_view_cache: dict[str, tuple[int, Any]] = {}
14261428
# Normalize type specs before parent init so the frontend receives
14271429
# JSON-serializable descriptors from the start.
14281430
if "node_types" in params:
@@ -1450,6 +1452,7 @@ def __init__(self, **params: Any):
14501452
self._update_edge_editors,
14511453
["edges", "selection", "edge_editors", "default_edge_editor"],
14521454
)
1455+
self.param.watch(self._update_views, ["nodes"])
14531456
self._sync_instance_flow_refs()
14541457
self._update_node_editors()
14551458
self._update_edge_editors()
@@ -1546,10 +1549,18 @@ def _node_data(node: dict[str, Any] | Node) -> dict[str, Any]:
15461549
return dict(node.data or {})
15471550
return dict(node.get("data", {}))
15481551

1549-
@staticmethod
1550-
def _node_view(node: dict[str, Any] | Node) -> Any | None:
1552+
def _node_view(self, node: dict[str, Any] | Node) -> Any | None:
15511553
if isinstance(node, Node):
1552-
return node.__panel__()
1554+
node_id = self._node_id(node)
1555+
node_ref = id(node)
1556+
if node_id is not None:
1557+
cached = self._node_view_cache.get(node_id)
1558+
if cached is not None and cached[0] == node_ref:
1559+
return cached[1]
1560+
view = node.__panel__()
1561+
if node_id is not None and view is not None:
1562+
self._node_view_cache[node_id] = (node_ref, view)
1563+
return view
15531564
return node.get("view", None)
15541565

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

1972+
def _update_views(self, *events: tuple[param.parameterized.Event]) -> None:
1973+
event = events[0] if events else None
1974+
nodes = event.new if event is not None else self.nodes
1975+
normalized = [self._coerce_node(node) for node in nodes]
1976+
node_ids = {self._node_id(node) for node in normalized}
1977+
self._node_view_cache = {node_id: cached for node_id, cached in self._node_view_cache.items() if node_id in node_ids}
1978+
is_normalized = not any(n1 is not n2 for n1, n2 in zip(normalized, nodes, strict=False))
1979+
if not is_normalized:
1980+
return
1981+
self.param.trigger("_views")
1982+
self._node_update_count += 1
1983+
19611984
def _process_param_change(self, params):
19621985
params = super()._process_param_change(params)
19631986
if "nodes" in params:

src/panel_reactflow/models/reactflow.jsx

Lines changed: 61 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,7 @@ function FlowInner({
217217
model,
218218
hydratedNodes,
219219
pyNodes,
220+
nodeUpdateCount,
220221
hydratedEdges,
221222
selectionSetter,
222223
currentSelection,
@@ -242,7 +243,9 @@ function FlowInner({
242243
const [edges, setEdges, onEdgesChange] = useEdgesState(hydratedEdges);
243244
const nodesRef = useRef(nodes);
244245
const edgesRef = useRef(edges);
245-
const lastHydrated = useRef({ nodesSig: null, viewsRef: null, editorsRef: null, edgesSig: null, edgeEditorsSig: null });
246+
const hydrationFrameRef = useRef(null);
247+
const edgeHydrationFrameRef = useRef(null);
248+
const lastHydrated = useRef({ nodeRevision: null, nodesSig: null, edgesSig: null, edgeEditorsSig: null });
246249
const lastViewportSig = useRef(null);
247250
const { setViewport: setRfViewport } = useReactFlow();
248251

@@ -291,45 +294,67 @@ function FlowInner({
291294
}, [edges]);
292295

293296
useEffect(() => {
294-
const readyByNodeId = new Map(
295-
(hydratedNodes || []).map((node) => [node?.id, Boolean(node?.data?._viewReady)]),
296-
);
297-
const pyNodesWithReady = (pyNodes || []).map((node) => ({
298-
...node,
299-
_viewReady: readyByNodeId.get(node?.id) ?? true,
300-
}));
301-
const nodesSig = signature(pyNodesWithReady);
302-
const viewsSig = signature((views || []).map((view) => view?.props?.id ?? null));
303-
const editorsSig = signature((nodeEditors || []).map((editor) => editor?.props?.id ?? null));
304-
if (nodesSig === lastHydrated.current.nodesSig && viewsSig === lastHydrated.current.viewsRef && editorsSig === lastHydrated.current.editorsRef) {
297+
return () => {
298+
if (hydrationFrameRef.current !== null) {
299+
cancelAnimationFrame(hydrationFrameRef.current);
300+
hydrationFrameRef.current = null;
301+
}
302+
if (edgeHydrationFrameRef.current !== null) {
303+
cancelAnimationFrame(edgeHydrationFrameRef.current);
304+
edgeHydrationFrameRef.current = null;
305+
}
306+
};
307+
}, []);
308+
309+
useEffect(() => {
310+
const nodesSig = signature(hydratedNodes);
311+
if (
312+
nodeUpdateCount === lastHydrated.current.nodeRevision &&
313+
nodesSig === lastHydrated.current.nodesSig
314+
) {
305315
return;
306316
}
307-
lastHydrated.current.nodesSig = nodesSig;
308-
lastHydrated.current.viewsRef = viewsSig;
309-
lastHydrated.current.editorsRef = editorsSig;
310-
311-
setNodes((curr) => {
312-
const currById = new Map(curr.map((n) => [n.id, n]));
313-
const merged = hydratedNodes.map((n) => {
314-
const prev = currById.get(n.id);
315-
if (!prev) return n;
316-
return {
317-
...n,
318-
selected: prev.selected,
319-
dragging: prev.dragging,
320-
};
317+
318+
if (hydrationFrameRef.current !== null) {
319+
cancelAnimationFrame(hydrationFrameRef.current);
320+
}
321+
hydrationFrameRef.current = requestAnimationFrame(() => {
322+
setNodes((curr) => {
323+
const currById = new Map(curr.map((n) => [n.id, n]));
324+
const merged = hydratedNodes.map((n) => {
325+
const prev = currById.get(n.id);
326+
if (!prev) return n;
327+
const next = {
328+
...n,
329+
selected: prev.selected,
330+
dragging: prev.dragging,
331+
};
332+
return areEqual(prev, next) ? prev : next;
333+
});
334+
if (merged.length === curr.length && merged.every((node, index) => node === curr[index])) {
335+
return curr;
336+
}
337+
return merged;
321338
});
322-
return merged;
339+
lastHydrated.current.nodeRevision = nodeUpdateCount;
340+
lastHydrated.current.nodesSig = nodesSig;
341+
hydrationFrameRef.current = null;
323342
});
324-
}, [hydratedNodes, pyNodes, setNodes, views, nodeEditors]);
343+
}, [hydratedNodes, pyNodes, setNodes, views, nodeEditors, nodeUpdateCount]);
325344

326345
useEffect(() => {
327346
const edgesSig = signature(hydratedEdges);
328347
const editorsSig = signature((edgeEditors || []).map((editor) => editor?.props?.id ?? null));
329348
if (edgesSig !== lastHydrated.current.edgesSig || editorsSig !== lastHydrated.current.edgeEditorsSig) {
330349
lastHydrated.current.edgesSig = edgesSig;
331350
lastHydrated.current.edgeEditorsSig = editorsSig;
332-
setEdges(hydratedEdges);
351+
if (edgeHydrationFrameRef.current !== null) {
352+
cancelAnimationFrame(edgeHydrationFrameRef.current);
353+
}
354+
edgeHydrationFrameRef.current = requestAnimationFrame(() => {
355+
setEdges((curr) => (areEqual(curr, hydratedEdges) ? curr : hydratedEdges));
356+
edgeHydrationFrameRef.current = null;
357+
});
333358
}
334359
}, [hydratedEdges, setEdges, edgeEditors]);
335360

@@ -409,32 +434,6 @@ function FlowInner({
409434
const onNodesDelete = useCallback(
410435
(deletedNodes) => {
411436
const deletedIds = deletedNodes.map((node) => node.id);
412-
const deletedViewIdx = deletedNodes
413-
.map((node) => node?.data?.view_idx)
414-
.filter((value) => Number.isFinite(value))
415-
.sort((a, b) => a - b);
416-
if (deletedViewIdx.length) {
417-
const deletedSet = new Set(deletedIds);
418-
setNodes((current) =>
419-
current.map((node) => {
420-
if (deletedSet.has(node.id)) {
421-
return node;
422-
}
423-
const viewIdx = node?.data?.view_idx;
424-
if (!Number.isFinite(viewIdx)) {
425-
return node;
426-
}
427-
const shift = deletedViewIdx.filter((idx) => idx < viewIdx).length;
428-
if (!shift) {
429-
return node;
430-
}
431-
return {
432-
...node,
433-
data: { ...node.data, view_idx: viewIdx - shift },
434-
};
435-
}),
436-
);
437-
}
438437
const deletedEdges = edgesRef.current.filter((edge) => deletedIds.includes(edge.source) || deletedIds.includes(edge.target));
439438
schedulePatch({
440439
type: "node_deleted",
@@ -443,7 +442,7 @@ function FlowInner({
443442
deleted_edges: deletedEdges.map((edge) => edge.id),
444443
});
445444
},
446-
[schedulePatch, setNodes],
445+
[schedulePatch],
447446
);
448447

449448
const onEdgesDelete = useCallback(
@@ -500,6 +499,7 @@ export function render({ model, view }) {
500499
const [readyViewMap, setReadyViewMap] = useState(() => new Map());
501500
const readyCheckTimeoutsRef = useRef(new Map());
502501
const [pyNodes] = model.useState("nodes");
502+
const [nodeUpdateCount] = model.useState("_node_update_count");
503503
const [pyEdges] = model.useState("edges");
504504
const [pyNodeTypes] = model.useState("node_types");
505505
const [defaultEdgeOptions] = model.useState("default_edge_options");
@@ -614,18 +614,19 @@ export function render({ model, view }) {
614614
return (pyNodes || []).map((node, idx) => {
615615
const data = node.data || {};
616616
const viewIndex = data.view_idx;
617+
const { view_idx, ...dataWithoutViewIdx } = data;
617618
const baseView = views[viewIndex];
618619
const baseViewId = baseView?.key;
619620
const isViewReady = baseViewId ? Boolean(readyViewMap.get(baseViewId)) : true;
620621
const editorView = nodeEditors[idx];
621622
const typeSpec = allNodeTypes[node.type] || {};
622-
const realKeys = Object.keys(data).filter((k) => k !== "view_idx");
623+
const realKeys = Object.keys(dataWithoutViewIdx);
623624
const hasEditor = realKeys.length > 0 || !!typeSpec.schema;
624625
return {
625626
...node,
626627
className: (node.type === "panel" || model.stylesheets.length > 7) ? "" : "react-flow__node-default",
627628
data: {
628-
...data,
629+
...dataWithoutViewIdx,
629630
view: baseView,
630631
editor: editorView,
631632
_viewReady: isViewReady,
@@ -634,7 +635,7 @@ export function render({ model, view }) {
634635
},
635636
};
636637
});
637-
}, [pyNodes, nodeEditors, views, editorMode, allNodeTypes, readyViewMap]);
638+
}, [pyNodes, nodeEditors, views, editorMode, allNodeTypes]);
638639

639640
const hydratedEdges = useMemo(() => {
640641
return (pyEdges || []).map((edge) => {
@@ -662,6 +663,7 @@ export function render({ model, view }) {
662663
model={model}
663664
hydratedNodes={hydratedNodes}
664665
pyNodes={pyNodes || []}
666+
nodeUpdateCount={nodeUpdateCount}
665667
hydratedEdges={hydratedEdges}
666668
selectionSetter={setSelection}
667669
currentSelection={selection}

tests/test_api.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,13 @@ class _ParameterizedNode(Node):
9494
hidden = param.String(default="secret", precedence=-1)
9595

9696

97+
class _PanelNode(Node):
98+
text = param.String(default="", precedence=0)
99+
100+
def __panel__(self):
101+
return pn.pane.Markdown(self.text)
102+
103+
97104
def test_reactflow_accepts_node_instance() -> None:
98105
flow = ReactFlow()
99106
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:
218225
assert node.flow is None
219226

220227

228+
def test_views_triggered_on_nodes_reassignment_with_panel_nodes() -> None:
229+
flow = ReactFlow(nodes=[_PanelNode(id="n1", position={"x": 0, "y": 0}, text="one")])
230+
updates = []
231+
watcher = flow.param.watch(lambda event: updates.append(event.name), "_views")
232+
try:
233+
flow.nodes = [_PanelNode(id="n2", position={"x": 20, "y": 10}, text="two")]
234+
finally:
235+
flow.param.unwatch(watcher)
236+
assert "_views" in updates
237+
238+
239+
def test_views_triggered_on_remove_node_with_panel_nodes() -> None:
240+
flow = ReactFlow(
241+
nodes=[
242+
_PanelNode(id="n1", position={"x": 0, "y": 0}, text="one"),
243+
_PanelNode(id="n2", position={"x": 20, "y": 10}, text="two"),
244+
],
245+
edges=[{"id": "e1", "source": "n1", "target": "n2", "data": {}}],
246+
)
247+
updates = []
248+
watcher = flow.param.watch(lambda event: updates.append(event.name), "_views")
249+
try:
250+
flow.remove_node("n1")
251+
finally:
252+
flow.param.unwatch(watcher)
253+
assert "_views" in updates
254+
255+
221256
def test_edge_spec_roundtrip() -> None:
222257
edge = EdgeSpec(id="e1", source="n1", target="n2", data={"weight": 0.5})
223258
payload = edge.to_dict()

tests/ui/test_ui.py

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22

33
import panel as pn
44
import panel.models.jsoneditor # noqa
5+
import param
56
import pytest
7+
from panel.custom import Child, ReactComponent
68
from panel.tests.util import serve_component, wait_until
79

8-
from panel_reactflow import EdgeSpec, JsonEditor, NodeSpec, NodeType, ReactFlow
10+
from panel_reactflow import EdgeSpec, JsonEditor, Node, NodeSpec, NodeType, ReactFlow
911

1012
pytest.importorskip("playwright")
1113

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

8183

84+
class ReactChild(ReactComponent):
85+
child = Child()
86+
render_count = param.Integer(default=0)
87+
88+
_esm = """
89+
export function render({ model }) {
90+
model.render_count += 1
91+
return <button>{model.get_child('child')}</button>
92+
}"""
93+
94+
95+
class CountingViewNode(Node):
96+
view_component = param.Parameter(default=None, precedence=-1)
97+
98+
def __panel__(self):
99+
return self.view_component
100+
101+
82102
def test_render_nodes_edges_labels_views_and_panels(page):
83103
flow = _make_flow(editor_mode="toolbar", include_edge=True)
84104
serve_component(page, flow)
@@ -304,3 +324,32 @@ def test_editor_renders_in_side_mode(page):
304324

305325
_node_locator(page, "Start").click()
306326
expect(page.locator(".jsoneditor").nth(0)).to_be_visible()
327+
328+
329+
def test_delete_node_does_not_rerender_surviving_node_views(page):
330+
view_a = ReactChild(child=pn.pane.Markdown("View A"))
331+
view_b = ReactChild(child=pn.pane.Markdown("View B"))
332+
view_c = ReactChild(child=pn.pane.Markdown("View C"))
333+
flow = ReactFlow(
334+
nodes=[
335+
CountingViewNode(id="n1", position={"x": 0, "y": 0}, label="Node A", view_component=view_a),
336+
CountingViewNode(id="n2", position={"x": 260, "y": 60}, label="Node B", view_component=view_b),
337+
CountingViewNode(id="n3", position={"x": 520, "y": 120}, label="Node C", view_component=view_c),
338+
],
339+
width=900,
340+
height=600,
341+
)
342+
serve_component(page, flow)
343+
344+
wait_until(lambda: view_a.render_count > 0 and view_b.render_count > 0 and view_c.render_count > 0, timeout=8000)
345+
b_count_before = view_b.render_count
346+
c_count_before = view_c.render_count
347+
348+
_node_locator(page, "Node A").click(force=True)
349+
page.keyboard.press("Backspace")
350+
wait_until(lambda: all(node.id != "n1" for node in flow.nodes), timeout=8000)
351+
352+
# Let any queued rerenders settle; surviving nodes should not rerender.
353+
page.wait_for_timeout(300)
354+
assert view_b.render_count == b_count_before
355+
assert view_c.render_count == c_count_before

0 commit comments

Comments
 (0)