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 ( +
{describeError(error).message}
+ {issue.id} {issue.detail}
+