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
1 change: 1 addition & 0 deletions docs/how-to/react-to-events.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |

---

Expand Down
121 changes: 121 additions & 0 deletions docs/how-to/recover-from-errors.md
Original file line number Diff line number Diff line change
@@ -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.
48 changes: 48 additions & 0 deletions src/panel_reactflow/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import hashlib
import inspect
import json
import logging
import os
from collections.abc import Callable
from dataclasses import dataclass
Expand All @@ -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"
Expand Down Expand Up @@ -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.")
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down
116 changes: 116 additions & 0 deletions src/panel_reactflow/dist/css/reactflow.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Loading
Loading