Skip to content

feat: share process-global widgets across sessions and re-renders (#348) - #356

Merged
cpsievert merged 14 commits into
frontend-comm-openfrom
feat-348-shared-widgets
Oct 28, 2026
Merged

cpsievert merged 14 commits into
frontend-comm-openfrom
feat-348-shared-widgets

Conversation

@cpsievert

Copy link
Copy Markdown
Collaborator

Supersedes #351, which GitHub auto-marked as merged when render-pythreejs was force-pushed containing its head. Review history is on #351.

Stacked on #346 (on top of #343). Merge order: #343 → #346 → this PR → #324 → #329. Fixes #348.

Restacked off #324: the shared-widget machinery has no functional dependency on the pythreejs renderer (only comment-level mentions), and the rebase onto #346 was conflict-free with the unit suite green. PR #324 and #329 still need their branches rebased and the GitHub stack re-linked in the new order before merge.

Why this matters

Apps built on ipyvuetify or ipyvue (and anything that uses them under the hood, like ipyvolume's pylab API) hang for ~30 s after the first re-render or in a second user's session, and one dangling reference blanks every vuetify widget on the page. The cause: ipyvue and ipyvuetify create widgets for the whole Python process (force_load_instance, one cached Template per .vue file, theme), but shinywidgets gives every widget to one session and closes it when that render is invalidated.

With this PR those widgets render, re-render, and work across concurrent sessions, with no per-package workarounds. It also adds a public shared_lifetime() for widgets you want every session to display.

What changes for users

  • ipyvuetify / ipyvue trees just work, including custom VueTemplates and module-level theme.dark = True, across re-renders and sessions (new Playwright regression test).
  • shared_lifetime() (new, public): construct a widget inside it and every session shows the same Python widget. Any user's interaction changes it for everyone, and it is never freed, so it is for static content or deliberately shared state. The docstring says how to use it without unbounded growth.
  • Ownership errors now explain both options: a widget built per session can be reused inside the session with session_lifetime(), or shared with every user with shared_lifetime().
  • Per-session widgets still raise if shown in another session or after their render was invalidated; nothing silently becomes shared.

Why it sits below #329

#329 currently works around this same gap for ipyvolume with neutralize_ipyvolume_pylab() and an ipyvue/ipyvuetify import-tolerance entry. This PR replaces both, so #329 is restacked on this branch without them and the workarounds never reach main.

How it works (short)

Widgets known to be process-global (listed packages' import-time singletons, ipyvue's cached templates, and shared_lifetime() widgets) get a comm that fans out to every session that uses them. They are opened in a session at session start, at render time, and when a referencing widget's comm opens, and they are skipped by render and session cleanup.

Known limitations

  • A shared widget that references a per-session widget (e.g. box.children += (HTML(...),) on a shared box) is an unsupported shape. Other sessions see a dangling reference rather than an error.
  • Models that a shared widget's JavaScript creates in one browser (e.g. pythreejs's default light target/shadow) belong to that browser. Confirmed in a browser: a second session waits ~30 s for the missing model (Timeout waiting for widget model) and the affected outputs never render (two-session Playwright regression test in tests/playwright/shared_pythreejs/). Give such traits explicit values built inside shared_lifetime(); the docstring says so. The new dangling-reference warning also fires in this scenario, naming the trait and the remedy.
  • References introduced by a trait update after a widget's comm is already open are not opened in other sessions (same as any cross-session reference today).
  • Pre-existing, not touched here: a container built in a rejected render over a closed child leaves a deferred open that errors, and py-shiny then ends the session.

Updates from review

  • Dangling references are now loud. open_shared_in() warns when a shared widget references a session-owned or closed widget, and a guard in SharedShinyComm.send() scans outgoing state for IPY_MODEL_ references: process-global references are opened in the missing sessions on the spot (this keeps the documented "lazy construction in a callback" pattern working), while session-owned/closed references produce a UserWarning naming both widgets, the browser symptom, and the shared_lifetime() remedy. Also fixes close_render_widgets() closing a surviving shared widget's Layout/HTMLStyle defaults (surviving process-global widgets now root the keep-walk).
  • Upstream-internals canary tests (tests/unit/test_upstream_internals.py): the ipyvue/ipyvuetify heuristics' assumptions (template_registry, import-time module bindings) are now asserted against the real packages, so an upstream restructure fails the unit suite immediately instead of surfacing as a Playwright timeout.
  • session_lifetime() un-deprecated: it was accidentally grouped under # Soft deprecated in __all__ (swept in when appended in Add session_lifetime() and fix widget cleanup leaks #258; the comment was introduced for register_widget in render_widget now attaches it's return value to the decorated function #119). It's now grouped with shared_lifetime() under # Lifetime methods.
  • The pythreejs known limitation is now verified (see Known limitations) with a two-session Playwright regression test. It uses render_widget rather than render_pythreejs so it runs on this branch's feat: let the browser open widget comms (bqplot toolbar, pythreejs light defaults) #346 base.

Verification

  • make check: 484 unit tests passed on the restacked branch (the 9 original commits plus the 5 review-feedback commits).
  • New Playwright coverage: render_ipyvuetify (re-renders and concurrent sessions) and shared_pythreejs (default vs. explicit light target/shadow across two sessions).
  • Earlier full-suite Playwright run (214) as before; render_geoviews/render_lonboard covered by CI. render_bqplot/render_ipydatagrid lifecycle tests flake intermittently on main too (verified on the base commit).

ipyvue's ForceLoad is a DOMWidget with `_view_module = None`, which crashed
the dependency lookup. Fall back to `_model_module`, then the widget's package.
…templates

ipywidgets treats a comm whose `kernel` is None like a DummyComm, and
ipyvue >= 3 uses the same check to decide whether a new Template reached a
frontend. Without it, ipyvue caches templates that no browser ever received.
A widget is process-global if it was built in shared_construction(), is held
in ipyvue's template registry, or is a module-level widget ipyvue, ipyvuetify,
lonboard or ipycanvas construct while being imported (including when that
import first runs inside a session). Render invalidation and session end no
longer close such widgets, since other renders and sessions still use them.
open_shared_in() moves a process-global widget onto a SharedShinyComm and
opens it in a session the same way a session-owned widget's deferred open
does (pre-serialize hooks, dependencies, extra open keys such as bokeh's ES
modules and itables' jQuery protection). It follows the widget's direct
references that are process-global or were built outside any session (e.g. the
Layout ipyvue's ForceLoad materializes at import time), but never another
session's widgets or closed ones, so per-session state is never shared.
Widgets constructed inside `with shared_lifetime():` (at module level or in a
session) exist once per process and are mirrored into every session's browser;
they are never freed. The ownership errors (widget constructed outside a
session, closed, or from another session) now explain when to reach for
session_lifetime() versus shared_lifetime().
…s read

importing_ipyvue(), importing_ipyvuetify(), and in_template_registry() in
_shinywidgets.py depend on upstream module internals (ipyvue.ForceLoad's
force_load_instance, ipyvuetify.Themes' theme, ipyvue.Template's
template_registry dict). If an upstream release restructures these, the
heuristics silently stop firing and the import-time-widget bug (30s browser
hangs) returns, previously caught only by a Playwright test with no
scheduled CI run. These tests import the real packages so such a change
fails immediately and loudly in the unit suite, naming the affected
heuristic.
…idget in __all__

The '# Soft deprecated' group was introduced in 4451359 (PR #119) for
register_widget alone. 1de3b2d (PR #258), the commit that added
session_lifetime() as a new feature, appended it at the end of __all__
where the comment happened to sit, so it read as soft-deprecated even
though nothing deprecates it. Group it with shared_lifetime() instead,
since both are the lifetime APIs the ownership errors now recommend.
A shared widget that references a session-owned or closed widget leaves
the other sessions' browsers waiting for a model they never receive.
Cover the two places that must become loud: open_shared_in() skipping
such a reference, and a SharedShinyComm update introducing one.
…t show

Two structural holes made this silent: open_shared_in() skipped
session-owned/closed references on the assumption that the render-time
walk rejects them (it doesn't -- it prunes at process-global widgets),
and trait updates fanned out by SharedShinyComm.send() could introduce
references that exist in only one session's browser.

- open_shared_in() now warns once per skipped reference, naming both
  widgets and pointing at shared_lifetime().
- SharedShinyComm.send() scans outgoing state for IPY_MODEL_<id>
  references (guard_shared_send): a process-global one (e.g. a shared
  widget built lazily, after some session already had this widget open)
  is opened in the fan-out sessions missing it; any other (session-owned
  or closed) warns once per referenced model.
- close_render_widgets() now roots its keep-walk at process-global
  widgets in the closing set: they survive, so what they reference must
  survive too. The new warning surfaced this -- a template-registry
  widget outlived its render while its Layout/Style were closed.
…plicit target/shadow

Two-session Playwright regression tests for the shared_lifetime() known
limitation: pythreejs's JavaScript creates a default light's target/shadow
models in the first browser, and a second session referencing them waits
30s for models that never arrive ('Timeout waiting for widget model') and
the affected outputs never render. The remedy app (explicit target/shadow
built inside shared_lifetime()) renders in both sessions.

Uses render_widget (not render_pythreejs from #324) so the test runs on
this branch's base; pythreejs is added to the test dependency groups.
(With render_pythreejs, the failure was also seen to cascade to unrelated
outputs via 'Syncing error: no comm channel defined'.)
@cpsievert
cpsievert force-pushed the feat-348-shared-widgets branch from 8277f1f to bbb36d1 Compare October 28, 2026 00:32
@cpsievert
cpsievert merged commit b61fa2a 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.

Shared singleton widgets (e.g., ipyvue's template_registry) break under render-scoped cleanup and across sessions

1 participant