From 69fe40b9699abd4aecee27355214a2743197c6c1 Mon Sep 17 00:00:00 2001 From: Philipp Rudiger Date: Fri, 21 Aug 2026 11:33:49 +0200 Subject: [PATCH] Add error recovery mode --- docs/how-to/react-to-events.md | 1 + docs/how-to/recover-from-errors.md | 121 ++++++ src/panel_reactflow/base.py | 48 +++ src/panel_reactflow/dist/css/reactflow.css | 116 ++++++ src/panel_reactflow/models/reactflow.jsx | 452 +++++++++++++++++++-- tests/test_error_recovery.py | 128 ++++++ tests/ui/test_error_recovery.py | 161 ++++++++ zensical.toml | 3 +- 8 files changed, 993 insertions(+), 37 deletions(-) create mode 100644 docs/how-to/recover-from-errors.md create mode 100644 tests/test_error_recovery.py create mode 100644 tests/ui/test_error_recovery.py diff --git a/docs/how-to/react-to-events.md b/docs/how-to/react-to-events.md index 4444716..b3741a9 100644 --- a/docs/how-to/react-to-events.md +++ b/docs/how-to/react-to-events.md @@ -30,6 +30,7 @@ the `ReactFlow` instance as a second argument. You can also listen for | `edge_data_changed` | Edge data is patched (via API, editor patch, or parameter-driven sync). | `edge_id`, `patch` | | `selection_changed` | The active selection changes. | `nodes`, `edges` | | `sync` | A batch sync from the frontend. | *(varies)* | +| `client_error` | The graph view hit a rendering error in the browser. See [Recover from Rendering Errors](recover-from-errors.md). | `source`, `message`, `stack`, `component_stack`, `attempt`, `mode` | --- diff --git a/docs/how-to/recover-from-errors.md b/docs/how-to/recover-from-errors.md new file mode 100644 index 0000000..3ac1ce0 --- /dev/null +++ b/docs/how-to/recover-from-errors.md @@ -0,0 +1,121 @@ +# Recover from Rendering Errors + +A React rendering error is unforgiving: when a component throws during +render, React unmounts the whole subtree. In a graph editor that means a +single malformed node can blank the canvas, and because the exception dies +in the browser console the server never learns about it. The user is left +staring at an empty viewport with no way back other than reloading the page, +even though their graph is still safely held in Python. + +Panel-ReactFlow wraps the canvas in an error boundary that catches those +errors, tries to bring the view back, and reports what happened to the +server. This is on by default, controlled by the `error_recovery` +parameter. + +```python +from panel_reactflow import ReactFlow + +flow = ReactFlow(nodes=nodes, edges=edges, error_recovery="auto") +``` + +--- + +## Recovery modes + +| Mode | Behavior | +|------------|----------| +| `"auto"` | *(default)* Remount the canvas once, then remount again in safe mode. If it still fails, show the recovery panel. | +| `"manual"` | Report the error and show the recovery panel immediately, without retrying. | +| `"off"` | Disable the error boundary entirely so exceptions propagate to the browser. Useful when debugging a custom node component. | + +Each retry budget refills once a remounted canvas has survived for five +seconds, so a graph that breaks again much later still gets a fresh set of +attempts rather than going straight to the failure panel. + +--- + +## What safe mode does + +On the second attempt the frontend validates the graph before handing it to +React Flow and either repairs or hides anything it cannot render: + +| Issue | Action | +|-------|--------| +| `invalid_position` | Position is missing or not finite, so the node is placed at the origin. | +| `unknown_node_type` | Node type is not registered, so the node falls back to the default renderer. | +| `unknown_edge_type` | Edge type is not registered, so the type is stripped. | +| `dangling_edge` | Edge references a node that does not exist, so it is hidden. | +| `duplicate_node_id` / `duplicate_edge_id` | Later duplicates are hidden. | +| `missing_node_id` / `missing_edge_id` / `invalid_node` / `invalid_edge` | The element is hidden. | + +Safe mode is **view-only**. It filters what the browser renders and never +sends a graph mutation back to Python, so `flow.nodes` and `flow.edges` keep +every element they had before the error. Once the underlying state is +repaired on the server, the affected elements reappear. + +A banner tells the user what was changed and offers a details list of the +individual issues: + +```text +Safe mode: repaired 1 element and hid 1 element that could not be rendered. +Nothing was deleted on the server. +``` + +--- + +## The recovery panel + +When retries are exhausted, or in `"manual"` mode, the canvas is replaced by +a panel that names the error and offers three actions: *Try again*, which +remounts the canvas, *Reload page*, which rebuilds the session from the +server-side state, and *Copy details*, which puts a JSON diagnostic blob on +the clipboard for a bug report. + +Because Python holds the canonical graph, reloading is genuinely safe: no +work is lost. The panel says so explicitly, which matters when the +alternative is a user assuming their graph is gone. + +--- + +## Log and handle errors in Python + +Every error the frontend catches is reported to the server, logged to the +`panel.reactflow` logger, and emitted as a `client_error` event. + +```python +import logging + +logging.getLogger("panel.reactflow").setLevel(logging.INFO) + +def on_client_error(payload, flow): + if payload["source"] == "safe_mode": + print("hidden or repaired:", payload["issues"]) + else: + print(f"render error on attempt {payload['attempt']}: {payload['message']}") + +flow.on("client_error", on_client_error) +``` + +Render errors carry `name`, `message`, `stack`, `component_stack`, `attempt`, +`mode` and `auto_retry`. Errors raised inside interaction handlers are +reported with `source="handler"` and the `handler` name, which catches the +case where a drag or connect silently fails and leaves the canvas showing a +change that never reached Python. Safe mode reports arrive with +`source="safe_mode"` and the list of `issues`. + +Use this hook to forward errors to your own telemetry, to snapshot the graph +for later inspection, or to attempt a server-side repair before the user +clicks *Try again*. + +--- + +## Tips + +- Keep `error_recovery="auto"` in production; switch to `"off"` while + developing a custom node component so you see the real stack trace. +- A `client_error` with `source="safe_mode"` is a strong signal that + something upstream produced invalid state. Treat it as a bug report + rather than a warning to be ignored. +- The error boundary only covers the graph canvas. Content you pass to + `top_panel`, `bottom_panel`, `left_panel` and `right_panel` stays mounted + when the canvas fails, so side panels remain usable during recovery. diff --git a/src/panel_reactflow/base.py b/src/panel_reactflow/base.py index 4cb7a01..b18fcc3 100644 --- a/src/panel_reactflow/base.py +++ b/src/panel_reactflow/base.py @@ -5,6 +5,7 @@ import hashlib import inspect import json +import logging import os from collections.abc import Callable from dataclasses import dataclass @@ -30,6 +31,8 @@ if TYPE_CHECKING: from bokeh.models import UIElement +_LOGGER = logging.getLogger("panel.reactflow") + IS_RELEASE = __version__ == base_version(__version__) BASE_PATH = Path(__file__).parent DIST_PATH = BASE_PATH / "dist" @@ -1450,6 +1453,17 @@ class ReactFlow(ReactComponent): enable_multiselect = param.Boolean(default=True, doc="Allow multiselect with modifier key.") + error_recovery = param.ObjectSelector( + default="auto", + objects=["auto", "manual", "off"], + doc=( + "How to handle a rendering error in the graph view. 'auto' silently " + "remounts the canvas, then retries in safe mode, before showing a " + "recovery panel; 'manual' shows the recovery panel immediately; " + "'off' disables the error boundary so exceptions propagate." + ), + ) + max_zoom = param.Number(default=2, bounds=(0, None), inclusive_bounds=(False, True), doc="Maximum zoom level of the viewport.") min_zoom = param.Number(default=0.5, bounds=(0, None), inclusive_bounds=(False, True), doc="Minimum zoom level of the viewport.") @@ -2327,9 +2341,39 @@ def _handle_msg(self, msg: dict[str, Any]) -> None: case "close_context_menu": self._context_menu = None self._context_menu_position = None + case "client_error": + self._handle_client_error(msg) case _: return + def _handle_client_error(self, msg: dict[str, Any]) -> None: + """Log a client-side error reported by the frontend and re-emit it. + + Rendering errors in the graph view previously died in the browser + console, leaving the server with no record that the UI had broken. The + frontend now reports them here so they land in the application log and + can be handled via ``flow.on("client_error", ...)``. + """ + source = msg.get("source", "unknown") + message = msg.get("message", "Unknown error") + if source == "safe_mode": + _LOGGER.warning( + "panel-reactflow %s Affected elements: %s", + message, + json.dumps(msg.get("issues", [])), + ) + else: + _LOGGER.error( + "panel-reactflow client error (%s, attempt %s, mode %s): %s\n%s%s", + source, + msg.get("attempt", 0), + msg.get("mode", "normal"), + message, + msg.get("stack") or "", + msg.get("component_stack") or "", + ) + self._emit("client_error", msg) + def remove_node(self, node_id: str) -> None: """Remove a node and all connected edges from the graph. @@ -2907,6 +2951,10 @@ def on(self, event_type: str, callback) -> None: - ``"edge_data_changed"``: Edge data was modified - ``"selection_changed"``: Selection changed - ``"sync"``: Full graph sync from frontend + - ``"client_error"``: The graph view hit a rendering error in the + browser. The payload carries ``source``, ``message``, ``stack``, + ``component_stack``, ``attempt`` and ``mode``, or for + ``source="safe_mode"`` the list of hidden ``issues``. - ``"*"``: All events (wildcard) callback : callable Function called when the event occurs. Receives the event payload diff --git a/src/panel_reactflow/dist/css/reactflow.css b/src/panel_reactflow/dist/css/reactflow.css index 0cfd649..9073b1c 100644 --- a/src/panel_reactflow/dist/css/reactflow.css +++ b/src/panel_reactflow/dist/css/reactflow.css @@ -115,3 +115,119 @@ padding: 4px; min-width: 120px; } + +/* Error recovery overlay and safe mode banner */ +.rf-recovery { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + background: var(--panel-background-color, #fff); + z-index: 900; +} + +.rf-recovery-card { + max-width: 520px; + width: 100%; + background: var(--xy-node-background-color, var(--panel-background-color, #fff)); + color: var(--panel-on-background-color, #222); + border: 1px solid var(--panel-border-color, #ddd); + border-radius: 8px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12); + padding: 20px 22px; + font-size: 13px; + line-height: 1.5; +} + +.rf-recovery-title { + font-size: 15px; + font-weight: 600; + margin-bottom: 8px; +} + +.rf-recovery-body { + opacity: 0.85; +} + +.rf-recovery-error { + margin: 12px 0 0; + padding: 8px 10px; + max-height: 140px; + overflow: auto; + background: rgba(127, 127, 127, 0.12); + border-radius: 4px; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 11.5px; + white-space: pre-wrap; + word-break: break-word; +} + +.rf-recovery-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 14px; +} + +.rf-recovery-meta { + margin-top: 10px; + font-size: 11.5px; + opacity: 0.65; +} + +.rf-recovery-button { + border: 1px solid var(--panel-border-color, #ccc); + background: transparent; + color: inherit; + border-radius: 4px; + padding: 5px 12px; + font-size: 12px; + cursor: pointer; +} + +.rf-recovery-button:hover { + background: rgba(127, 127, 127, 0.12); +} + +.rf-recovery-button--primary { + border-color: var(--panel-primary-color, #3477db); + color: var(--panel-primary-color, #3477db); + font-weight: 600; +} + +.rf-recovery-button--small { + padding: 2px 8px; + font-size: 11px; +} + +.rf-safe-mode-banner { + position: absolute; + top: 8px; + left: 50%; + transform: translateX(-50%); + max-width: min(640px, calc(100% - 24px)); + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + padding: 7px 12px; + border: 1px solid rgba(203, 137, 22, 0.55); + border-radius: 6px; + background: rgba(250, 204, 108, 0.18); + backdrop-filter: blur(2px); + color: var(--panel-on-background-color, #222); + font-size: 12px; + z-index: 950; +} + +.rf-safe-mode-issues { + flex-basis: 100%; + max-height: 160px; + overflow: auto; + margin: 4px 0 0; + padding-left: 20px; + font-size: 11.5px; + opacity: 0.85; +} diff --git a/src/panel_reactflow/models/reactflow.jsx b/src/panel_reactflow/models/reactflow.jsx index 0d292c8..02257c1 100644 --- a/src/panel_reactflow/models/reactflow.jsx +++ b/src/panel_reactflow/models/reactflow.jsx @@ -13,6 +13,15 @@ const BUILTIN_NODE_TYPES = { const viewWrapperClassName = "rf-node-view-wrapper rf-node-view-wrapper--bokeh-scale nodrag nopan nowheel"; +// Recovery: attempt 1 remounts the flow as-is, attempt 2 remounts it in safe +// mode with an invalid graph elements dropped from the view. Beyond that we +// stop retrying and hand control to the user. +const SAFE_MODE_ATTEMPT = 2; +const MAX_RECOVERY_ATTEMPTS = 2; +const RETRY_DELAY_MS = 100; +// How long a remounted flow must survive before its retry budget is refilled. +const HEALTHY_RESET_MS = 5000; + const figureStylesheet = ` .bk-Canvas { transform: scale(var(--rf-inverse-zoom)); @@ -245,8 +254,203 @@ function signature(value) { } } +/** + * Drop or repair graph elements that React Flow cannot render, so a structurally + * broken graph degrades to a partial view instead of an unmounted canvas. + * + * This only filters what is handed to React Flow. Nothing is sent back to + * Python, so the authoritative graph is left untouched and anything dropped here + * reappears once the underlying problem is fixed. + * + * Every issue records whether the element was `repaired` and still rendered, or + * `dropped` from the view entirely. + */ +function sanitizeGraph(nodes, edges, nodeTypes, edgeTypes) { + const issues = []; + const drop = (kind, id, detail) => issues.push({ kind, id, detail, action: "dropped" }); + const repair = (kind, id, detail) => issues.push({ kind, id, detail, action: "repaired" }); + + const safeNodes = []; + const nodeIds = new Set(); + (nodes || []).forEach((node, index) => { + if (!node || typeof node !== "object") { + drop("invalid_node", `#${index}`, "Node is not an object"); + return; + } + if (typeof node.id !== "string" || !node.id) { + drop("missing_node_id", `#${index}`, "Node has no usable id"); + return; + } + if (nodeIds.has(node.id)) { + drop("duplicate_node_id", node.id, "Duplicate node id"); + return; + } + let safeNode = node; + const { x, y } = safeNode.position || {}; + if (!Number.isFinite(x) || !Number.isFinite(y)) { + repair("invalid_position", node.id, "Position is not finite, reset to the origin"); + safeNode = { ...safeNode, position: { x: 0, y: 0 } }; + } + if (safeNode.type && !nodeTypes?.[safeNode.type]) { + repair("unknown_node_type", node.id, `Unknown node type "${safeNode.type}", rendered as "default"`); + safeNode = { ...safeNode, type: "default" }; + } + nodeIds.add(node.id); + safeNodes.push(safeNode); + }); + + const safeEdges = []; + const edgeIds = new Set(); + (edges || []).forEach((edge, index) => { + if (!edge || typeof edge !== "object") { + drop("invalid_edge", `#${index}`, "Edge is not an object"); + return; + } + if (typeof edge.id !== "string" || !edge.id) { + drop("missing_edge_id", `#${index}`, "Edge has no usable id"); + return; + } + if (edgeIds.has(edge.id)) { + drop("duplicate_edge_id", edge.id, "Duplicate edge id"); + return; + } + if (!nodeIds.has(edge.source) || !nodeIds.has(edge.target)) { + drop("dangling_edge", edge.id, `Connects a missing node (${edge.source} -> ${edge.target})`); + return; + } + let safeEdge = edge; + if (safeEdge.type && !edgeTypes?.[safeEdge.type]) { + repair("unknown_edge_type", edge.id, `Unknown edge type "${safeEdge.type}", rendered as default`); + const { type, ...rest } = safeEdge; + safeEdge = rest; + } + edgeIds.add(edge.id); + safeEdges.push(safeEdge); + }); + + return { nodes: safeNodes, edges: safeEdges, issues }; +} + +function summarizeIssues(issues) { + const repaired = issues.filter((issue) => issue.action === "repaired").length; + const dropped = issues.length - repaired; + const parts = []; + if (repaired) { + parts.push(`repaired ${repaired} element${repaired === 1 ? "" : "s"}`); + } + if (dropped) { + parts.push(`hid ${dropped} element${dropped === 1 ? "" : "s"} that could not be rendered`); + } + return `Safe mode: ${parts.join(" and ")}.`; +} + +function describeError(error, info) { + return { + name: error?.name || "Error", + message: String(error?.message ?? error ?? "Unknown error"), + stack: error?.stack || null, + component_stack: info?.componentStack || null, + }; +} + +class FlowErrorBoundary extends React.Component { + constructor(props) { + super(props); + this.state = { error: null }; + } + + static getDerivedStateFromError(error) { + return { error }; + } + + componentDidCatch(error, info) { + this.props.onError?.(error, info); + } + + render() { + if (this.state.error) { + return this.props.fallback?.(this.state.error) ?? null; + } + return this.props.children; + } +} + +function RecoveryOverlay({ status, error, attempt, mode, onRetry, onReload, onCopy, copied }) { + if (status === "recovering") { + return ( +
+
+
Recovering the graph view…
+
+ {mode === "safe" + ? "Retrying in safe mode, which hides graph elements that cannot be rendered." + : "Rebuilding the canvas from the state held on the server."} +
+
+
+ ); + } + return ( +
+
+
The graph view stopped rendering
+
+ Your graph is still held on the server and has not been modified. Reloading the page + will restore it. +
+
{describeError(error).message}
+
+ + + +
+
+ {attempt} recovery {attempt === 1 ? "attempt" : "attempts"} failed. The error has been + reported to the server log. +
+
+
+ ); +} + +function SafeModeBanner({ issues, onDismiss }) { + const [expanded, setExpanded] = useState(false); + if (!issues.length) { + return null; + } + return ( +
+ + {summarizeIssues(issues)} Nothing was deleted on the server. + + + + {expanded ? ( + + ) : null} +
+ ); +} + function FlowInner({ model, + reportError, hydratedNodes, pyNodes, nodeUpdateCount, @@ -509,6 +713,47 @@ function FlowInner({ [viewport, viewportSetter], ); + // An exception in an interaction handler leaves the canvas showing a change + // that never reached Python. Report those instead of letting them vanish into + // the browser console. + const handlers = useMemo(() => { + const wrap = (name, fn) => + typeof fn === "function" + ? (...args) => { + try { + return fn(...args); + } catch (error) { + reportError(error, null, { source: "handler", handler: name }); + return undefined; + } + } + : undefined; + return { + onNodesChange: wrap("onNodesChange", handleNodesChange), + onEdgesChange: wrap("onEdgesChange", onEdgesChange), + onSelectionChange: wrap("onSelectionChange", onSelectionChange), + onNodesDelete: wrap("onNodesDelete", onNodesDelete), + onEdgesDelete: wrap("onEdgesDelete", onEdgesDelete), + onConnect: wrap("onConnect", onConnect), + onMoveEnd: wrap("onMoveEnd", onMoveEnd), + onNodeDoubleClick: wrap("onNodeDoubleClick", onNodeDoubleClick), + onNodeContextMenu: wrap("onNodeContextMenu", onNodeContextMenu), + onPaneClick: wrap("onPaneClick", onPaneClick), + }; + }, [ + handleNodesChange, + onConnect, + onEdgesChange, + onEdgesDelete, + onMoveEnd, + onNodeContextMenu, + onNodeDoubleClick, + onNodesDelete, + onPaneClick, + onSelectionChange, + reportError, + ]); + return ( ({ ...BUILTIN_NODE_TYPES, ...(pyNodeTypes || {}) }), [pyNodeTypes]); + // Recovery state machine. `recoveryRef` mirrors the attempt counter so the + // error handler, which runs during a commit, can decide what to do without + // reading stale state. + const recoveryRef = useRef({ attempt: 0, mode: "normal" }); + const [recovery, setRecovery] = useState({ status: "ok", error: null, info: null, attempt: 0, mode: "normal" }); + const [mountKey, setMountKey] = useState(0); + const [copied, setCopied] = useState(false); + const [bannerDismissed, setBannerDismissed] = useState(false); + const reportedIssuesRef = useRef(null); + + const reportError = useCallback( + (error, info, context = {}) => { + const detail = describeError(error, info); + console.error("[panel-reactflow]", error); + try { + model.send_msg({ type: "client_error", source: "render", ...context, ...detail }); + } catch (sendError) { + console.error("[panel-reactflow] failed to report error to the server", sendError); + } + return detail; + }, + [model], + ); + + const handleRenderError = useCallback( + (error, info) => { + const attempt = recoveryRef.current.attempt + 1; + const mode = attempt >= SAFE_MODE_ATTEMPT ? "safe" : recoveryRef.current.mode; + const autoRetry = errorRecovery === "auto" && attempt <= MAX_RECOVERY_ATTEMPTS; + recoveryRef.current = { attempt, mode }; + reportError(error, info, { source: "render", attempt, mode, auto_retry: autoRetry }); + setRecovery({ status: autoRetry ? "recovering" : "failed", error, info, attempt, mode }); + }, + [errorRecovery, reportError], + ); + + const retry = useCallback(() => { + setRecovery((prev) => ({ ...prev, status: "ok", error: null, info: null })); + setMountKey((key) => key + 1); + }, []); + + useEffect(() => { + if (recovery.status !== "recovering") { + return undefined; + } + const timeout = setTimeout(retry, RETRY_DELAY_MS); + return () => clearTimeout(timeout); + }, [recovery.attempt, recovery.status, retry]); + + // Refill the retry budget once a remounted flow has stayed alive, so a later + // unrelated failure is not immediately treated as unrecoverable. + useEffect(() => { + if (recovery.status !== "ok" || recovery.attempt === 0) { + return undefined; + } + const timeout = setTimeout(() => { + recoveryRef.current = { ...recoveryRef.current, attempt: 0 }; + setRecovery((prev) => (prev.status === "ok" ? { ...prev, attempt: 0 } : prev)); + }, HEALTHY_RESET_MS); + return () => clearTimeout(timeout); + }, [mountKey, recovery.attempt, recovery.status]); + + const copyDetails = useCallback(() => { + const payload = JSON.stringify( + { + ...describeError(recovery.error, recovery.info), + attempt: recovery.attempt, + mode: recovery.mode, + node_count: (pyNodes || []).length, + edge_count: (pyEdges || []).length, + user_agent: navigator.userAgent, + }, + null, + 2, + ); + const done = () => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + if (navigator.clipboard?.writeText) { + navigator.clipboard.writeText(payload).then(done, () => console.log(payload)); + } else { + console.log(payload); + done(); + } + }, [pyEdges, pyNodes, recovery]); + useEffect(() => { const clearReadyCheckTimeouts = () => { @@ -727,35 +1051,88 @@ export function render({ model, view }) { smart_step: SmartStepEdge, }), []); + const safeMode = recovery.mode === "safe"; + const safeGraph = useMemo( + () => (safeMode ? sanitizeGraph(hydratedNodes, hydratedEdges, hydratedNodeTypes, hydratedEdgeTypes) : null), + [safeMode, hydratedNodes, hydratedEdges, hydratedNodeTypes, hydratedEdgeTypes], + ); + const safeModeIssues = safeGraph?.issues ?? []; + + useEffect(() => { + if (!safeMode || !safeModeIssues.length) { + return; + } + const sig = signature(safeModeIssues); + if (sig === reportedIssuesRef.current) { + return; + } + reportedIssuesRef.current = sig; + model.send_msg({ + type: "client_error", + source: "safe_mode", + name: "SafeModeDegraded", + message: summarizeIssues(safeModeIssues), + issues: safeModeIssues, + }); + }, [model, safeMode, safeModeIssues]); + + const renderRecoveryOverlay = useCallback( + () => ( + window.location.reload()} + onCopy={copyDetails} + copied={copied} + /> + ), + [copied, copyDetails, recovery, retry], + ); + + const flow = ( + + ); + return (
- + {errorRecovery === "off" ? ( + flow + ) : ( + + {flow} + + )} {topPanels} @@ -770,6 +1147,9 @@ export function render({ model, view }) { {selectedEditor} + {safeMode && recovery.status === "ok" && !bannerDismissed ? ( + setBannerDismissed(true)} /> + ) : null} {contextMenu && contextMenuPosition ? (
nX)", "action": "dropped"}, + { + "kind": "invalid_position", + "id": "n2", + "detail": "Position is not finite, reset to the origin", + "action": "repaired", + }, + ], +} + + +def test_error_recovery_default() -> None: + assert ReactFlow().error_recovery == "auto" + + +def test_error_recovery_rejects_unknown_mode() -> None: + with pytest.raises(ValueError): + ReactFlow(error_recovery="retry-forever") + + +def test_client_error_emits_event() -> None: + flow = ReactFlow() + received = [] + flow.on("client_error", received.append) + + flow._handle_msg(dict(RENDER_ERROR)) + + assert len(received) == 1 + assert received[0]["message"] == RENDER_ERROR["message"] + assert received[0]["component_stack"] == RENDER_ERROR["component_stack"] + + +def test_client_error_reaches_wildcard_handler() -> None: + flow = ReactFlow() + received = [] + flow.on("*", received.append) + + flow._handle_msg(dict(RENDER_ERROR)) + + assert [event["type"] for event in received] == ["client_error"] + + +def test_client_error_passes_flow_to_two_arg_callback() -> None: + flow = ReactFlow() + received = [] + flow.on("client_error", lambda payload, source: received.append((payload, source))) + + flow._handle_msg(dict(RENDER_ERROR)) + + assert received[0][1] is flow + + +def test_client_error_is_logged(caplog: pytest.LogCaptureFixture) -> None: + flow = ReactFlow() + with caplog.at_level(logging.ERROR, logger="panel.reactflow"): + flow._handle_msg(dict(RENDER_ERROR)) + + assert len(caplog.records) == 1 + record = caplog.records[0] + assert record.levelno == logging.ERROR + assert RENDER_ERROR["message"] in record.getMessage() + assert "attempt 1" in record.getMessage() + + +def test_safe_mode_report_logs_issues_as_warning(caplog: pytest.LogCaptureFixture) -> None: + flow = ReactFlow() + with caplog.at_level(logging.WARNING, logger="panel.reactflow"): + flow._handle_msg(dict(SAFE_MODE_REPORT)) + + assert len(caplog.records) == 1 + record = caplog.records[0] + assert record.levelno == logging.WARNING + assert "Safe mode" in record.getMessage() + assert "dangling_edge" in record.getMessage() + assert "repaired" in record.getMessage() + + +def test_client_error_does_not_modify_graph() -> None: + flow = ReactFlow( + nodes=[{"id": "n1", "position": {"x": 0, "y": 0}}, {"id": "n2", "position": {"x": 100, "y": 0}}], + edges=[{"id": "e1", "source": "n1", "target": "n2"}], + ) + nodes_before = list(flow.nodes) + edges_before = list(flow.edges) + + flow._handle_msg(dict(RENDER_ERROR)) + flow._handle_msg(dict(SAFE_MODE_REPORT)) + + assert flow.nodes == nodes_before + assert flow.edges == edges_before + + +def test_malformed_client_error_is_tolerated(caplog: pytest.LogCaptureFixture) -> None: + flow = ReactFlow() + received = [] + flow.on("client_error", received.append) + + with caplog.at_level(logging.ERROR, logger="panel.reactflow"): + flow._handle_msg({"type": "client_error"}) + + assert "Unknown error" in caplog.records[0].getMessage() + assert len(received) == 1 diff --git a/tests/ui/test_error_recovery.py b/tests/ui/test_error_recovery.py new file mode 100644 index 0000000..6acb365 --- /dev/null +++ b/tests/ui/test_error_recovery.py @@ -0,0 +1,161 @@ +"""UI tests for the frontend error boundary and safe mode recovery.""" + +import pytest +from panel.tests.util import serve_component, wait_until + +from panel_reactflow import ReactFlow + +pytest.importorskip("playwright") + +from playwright.sync_api import expect + +pytestmark = pytest.mark.ui + + +def _nodes(*, broken=False): + """Two nodes, optionally with a position React Flow cannot render. + + A ``None`` position makes React Flow dereference ``position.x`` during + render, which is the shape of corruption that used to leave the canvas + permanently blank. + """ + return [ + {"id": "n1", "position": None if broken else {"x": 0, "y": 0}, "label": "Start", "data": {}}, + {"id": "n2", "position": {"x": 260, "y": 60}, "label": "End", "data": {}}, + ] + + +def _edges(*, dangling=False): + return [{"id": "e1", "source": "n1", "target": "missing" if dangling else "n2", "data": {}}] + + +def _flow(**params): + params.setdefault("nodes", _nodes()) + params.setdefault("edges", _edges()) + params.setdefault("sizing_mode", "stretch_both") + return ReactFlow(**params) + + +def test_healthy_flow_renders_without_recovery_ui(page) -> None: + serve_component(page, _flow()) + + expect(page.locator(".react-flow__node")).to_have_count(2) + expect(page.locator(".rf-recovery")).to_have_count(0) + expect(page.locator(".rf-safe-mode-banner")).to_have_count(0) + + +def test_render_error_recovers_into_safe_mode(page) -> None: + """A node React Flow cannot render must degrade the view, not blank it.""" + flow = _flow() + errors = [] + flow.on("client_error", errors.append) + serve_component(page, flow) + expect(page.locator(".react-flow__node")).to_have_count(2) + + flow.nodes = _nodes(broken=True) + + # Auto recovery remounts once, hits the same error, then retries in safe mode + # where the invalid position is repaired to the origin. + expect(page.locator(".rf-safe-mode-banner")).to_be_visible(timeout=20000) + expect(page.locator(".react-flow__node")).to_have_count(2) + expect(page.locator(".react-flow__edge")).to_have_count(1) + expect(page.locator(".rf-recovery")).to_have_count(0) + + render_errors = [error for error in errors if error["source"] == "render"] + assert [error["attempt"] for error in render_errors] == [1, 2] + assert [error["mode"] for error in render_errors] == ["normal", "safe"] + assert render_errors[0]["message"] + assert render_errors[0]["stack"] + assert render_errors[0]["component_stack"] + + wait_until(lambda: any(error["source"] == "safe_mode" for error in errors), page) + issues = next(error for error in errors if error["source"] == "safe_mode")["issues"] + assert [(issue["kind"], issue["id"], issue["action"]) for issue in issues] == [("invalid_position", "n1", "repaired")] + + +def test_safe_mode_hides_dangling_edge_without_deleting_it(page) -> None: + flow = _flow() + serve_component(page, flow) + expect(page.locator(".react-flow__node")).to_have_count(2) + + flow.nodes = _nodes(broken=True) + flow.edges = _edges(dangling=True) + expect(page.locator(".rf-safe-mode-banner")).to_be_visible(timeout=20000) + + # The dangling edge is not rendered, but the server still holds it. + expect(page.locator(".react-flow__edge")).to_have_count(0) + assert len(flow.edges) == 1 + assert flow.edges[0]["target"] == "missing" + + page.locator(".rf-safe-mode-banner").get_by_text("Details").click() + expect(page.locator(".rf-safe-mode-issues")).to_contain_text("missing") + + +def test_safe_mode_banner_can_be_dismissed(page) -> None: + flow = _flow() + serve_component(page, flow) + expect(page.locator(".react-flow__node")).to_have_count(2) + + flow.nodes = _nodes(broken=True) + banner = page.locator(".rf-safe-mode-banner") + expect(banner).to_be_visible(timeout=20000) + + banner.get_by_text("Dismiss").click() + expect(banner).to_have_count(0) + expect(page.locator(".react-flow__node")).to_have_count(2) + + +def test_manual_mode_shows_recovery_panel_and_retry_works(page) -> None: + flow = _flow(error_recovery="manual") + errors = [] + flow.on("client_error", errors.append) + serve_component(page, flow) + expect(page.locator(".react-flow__node")).to_have_count(2) + + flow.nodes = _nodes(broken=True) + + panel = page.locator(".rf-recovery--failed") + expect(panel).to_be_visible(timeout=20000) + expect(panel).to_contain_text("still held on the server") + # Manual mode reports the error but does not retry on its own. + assert [error["auto_retry"] for error in errors if error["source"] == "render"] == [False] + + # Repairing the state on the server and retrying restores the canvas. + flow.nodes = _nodes() + panel.get_by_text("Try again").click() + expect(page.locator(".react-flow__node")).to_have_count(2, timeout=20000) + expect(page.locator(".rf-recovery")).to_have_count(0) + + +def test_recovery_panel_copies_diagnostics(page) -> None: + page.context.grant_permissions(["clipboard-read", "clipboard-write"]) + flow = _flow(error_recovery="manual") + serve_component(page, flow) + expect(page.locator(".react-flow__node")).to_have_count(2) + + flow.nodes = _nodes(broken=True) + panel = page.locator(".rf-recovery--failed") + expect(panel).to_be_visible(timeout=20000) + + panel.get_by_text("Copy details").click() + expect(panel).to_contain_text("Copied") + + clipboard = page.evaluate("navigator.clipboard.readText()") + assert "component_stack" in clipboard + assert "user_agent" in clipboard + + +def test_error_recovery_off_does_not_intervene(page) -> None: + """With the boundary disabled nothing is reported and nothing is recovered.""" + flow = _flow(error_recovery="off") + errors = [] + flow.on("client_error", errors.append) + serve_component(page, flow) + expect(page.locator(".react-flow__node")).to_have_count(2) + + flow.nodes = _nodes(broken=True) + + expect(page.locator(".react-flow__node")).to_have_count(0, timeout=20000) + expect(page.locator(".rf-recovery")).to_have_count(0) + expect(page.locator(".rf-safe-mode-banner")).to_have_count(0) + assert not errors diff --git a/zensical.toml b/zensical.toml index 0bbd128..3b01cfa 100644 --- a/zensical.toml +++ b/zensical.toml @@ -18,7 +18,8 @@ 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"}, - {"React to Events" = "how-to/react-to-events.md"} + {"React to Events" = "how-to/react-to-events.md"}, + {"Recover from Rendering Errors" = "how-to/recover-from-errors.md"} ]}, {"Examples" = [ {"Gallery" = "examples/index.md"},