Skip to content

feat: let the browser open widget comms (bqplot toolbar, pythreejs light defaults) - #346

Merged
cpsievert merged 5 commits into
generic-amd-externalsfrom
frontend-comm-open
Oct 28, 2026
Merged

cpsievert merged 5 commits into
generic-amd-externalsfrom
frontend-comm-open

Conversation

@cpsievert

@cpsievert cpsievert commented Oct 26, 2026 •

Copy link
Copy Markdown
Collaborator

Why

Some widgets create models in the browser rather than in Python. Two examples are bqplot's toolbar, which creates a PanZoom interaction when you click its button, and pythreejs, which creates a default target and shadow for lights you don't give them. In Jupyter, the kernel answers the browser's comm_open by building the matching Python widget. shinywidgets had no equivalent, so these models had no Python counterpart. That caused several problems:

  • Server-side TraitErrors. These fired whenever the browser sent a reference (IPY_MODEL_<id>) to one of these models. pythreejs scenes raised them on every render unless you built the light's target and shadow yourself.
  • Lost state. Python could never read the model. For example, reactive_read(fig.widget, "interaction") couldn't see which toolbar tool was active.
  • Leaks. Models the browser closed were never closed on the server.

With this PR, the browser can open, update, and close comms the same way it would talk to a kernel. bqplot's built-in toolbar now works end to end. A browser-created widget is a real Python widget for the life of the session, and when the browser closes it, Python closes it too.

The PR also adds a browser test confirming that widget updates made from a background thread, such as nglview's remote-call thread or your own threading.Thread, reach the browser promptly. This needed no change in shinywidgets itself, because py-shiny main already makes the output flush thread-safe. The test just pins that behavior.

Context

This came out of a look at whether shinywidgets should run a real Jupyter kernel behind each session instead of replacing the comm layer. The answer was no: a kernel would break Shinylive, where there's no zmq or ipykernel, and would add per-session startup time and memory. Instead, this PR closes the one real gap a kernel would have closed, which is comms the browser opens.

Implementation notes

  • Wire format. Browser messages now carry a Jupyter-style header.msg_type of comm_open, comm_msg, or comm_close. Messages without a header are still treated as comm_msg.
  • Python side. comm_open builds the widget through ipywidgets' own Widget.handle_comm_opened, so construction, buffers, and echo_update behave as they would under a kernel. The browser already has the model, so nothing is published back.
  • Rejected opens. A comm_open is ignored with a warning if its target isn't jupyter.widget, or if its id is already in use by any widget in the process. That covers other sessions' widgets and widgets that haven't been sent to the browser yet. An unknown model class is logged, and the comm is cleaned up without killing the session's input handling.
  • Browser side. _create_comm now returns a real ShinyComm that announces itself with comm_open. Closing a comm from the browser sends comm_close and runs the model's close handler, the way JupyterLab's CommHandler does, so the manager drops the model. A close that started in Python no longer bounces a comm_close back. Closing a comm during disconnect does not send a comm_close; sending is restored when Shiny reconnects.

Known limitation

A browser-created widget that is referenced only by a closed parent stays alive until the session ends. For example, re-rendering a figure while PanZoom is active retains that PanZoom and its two scales. Repeated PanZoom activation followed by re-rendering retains another such set each time, so memory can grow during a long-lived session. Closing the session releases them.

Stack

This PR is stacked on #343 (generic AMD externals). #324 (pythreejs) and #329 (ipyvolume) are stacked on this PR. Merge #343 first, then this PR, then #324, then #329. This PR doesn't depend on anything in #343. It sits there only because #324 needs both.

Verification

  • make check: format, pyright and unit tests all pass. New unit tests cover open, update, close, duplicate and in-use ids, unknown targets, unknown model classes, binary buffers, and messages without a header.
  • New Playwright tests cover:
    • bqplot's toolbar PanZoom being created, read from Python, and closed on Reset.
    • Re-rendering a toolbar figure without bouncing comm_close or accumulating models beyond the PanZoom and scales described above when PanZoom is activated only once.
    • Disconnecting with no errors.
    • Reset sending one comm_close after a real WebSocket reconnect.
    • Updates from a background thread reaching the browser.
  • Existing bqplot and AMD-externals Playwright suites still pass.

🤖 Generated with Claude Code

@cpsievert
cpsievert added this pull request to stack #347 October 26, 2026 23:09
cpsievert added a commit that referenced this pull request Oct 27, 2026
Browser-created widgets sync to Python now (#346), so the old 'don't
sync / TraitError' phrasing is wrong. The explicit target/shadow
workaround still earns its keep: browser-created widgets live until
the session ends, so without it every re-render leaks 3 models per
light (and logs a transient console error).
@cpsievert
cpsievert removed this pull request from stack #347 October 27, 2026 22:45
@cpsievert
cpsievert added this pull request to stack #352 October 27, 2026 22:46
cpsievert added a commit that referenced this pull request Oct 28, 2026
Browser-created widgets sync to Python now (#346), so the old 'don't
sync / TraitError' phrasing is wrong. The explicit target/shadow
workaround still earns its keep: browser-created widgets live until
the session ends, so without it every re-render leaks 3 models per
light (and logs a transient console error).
@cpsievert
cpsievert removed this pull request from stack #352 October 28, 2026 00:10
@cpsievert
cpsievert added this pull request to stack #357 October 28, 2026 00:13
…n use

The duplicate-id guard only checked the session's comm manager, but the
widget registry is process-wide: a comm_open naming a widget that was
constructed but not yet opened (or one from another session) replaced it
in the registry. Also wraps a long comment ruff reformatted awkwardly.
cpsievert added a commit that referenced this pull request Oct 28, 2026
Browser-created widgets sync to Python now (#346), so the old 'don't
sync / TraitError' phrasing is wrong. The explicit target/shadow
workaround still earns its keep: browser-created widgets live until
the session ends, so without it every re-render leaks 3 models per
light (and logs a transient console error).
@cpsievert
cpsievert merged commit 8ad6ef7 into main Oct 28, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant