Skip to content

Make the anywidget bundle safe to evaluate more than once per page - #3287

Merged
texodus merged 2 commits into
perspective-dev:masterfrom
cpsievert:fix/anywidget-reeval-safety
Nov 11, 2026
Merged

texodus merged 2 commits into
perspective-dev:masterfrom
cpsievert:fix/anywidget-reeval-safety

Conversation

@cpsievert

Copy link
Copy Markdown
Contributor

Fixes #3286.

anywidget evaluates a widget's _esm module once per widget model (each model gets a fresh blob-URL import), so module-scope side effects in perspective-anywidget.js run once per model while customElements and globalThis remain page-global. Two module-scope side effects threw on the second evaluation, so any page with two PerspectiveWidget models — or one re-created model, e.g. re-running a notebook cell — rendered nothing:

  • import "regular-layout" self-registers regular-layout, regular-layout-frame and regular-layout-tab unguarded → second evaluation throws NotSupportedError on customElements.define.
  • Since Better WASM init #3269, module-scope perspective.init({ viewer, server }) re-registers against the page-wide wasm registry → second evaluation throws "viewer wasm is already initialized" (as an unhandled rejection, since index.js doesn't await it, leaving ready permanently rejected).

This PR completes the coexistence work started in #3192 (which guarded Perspective's own elements):

  • perspective-viewer.ts — replace the static import "regular-layout" with a customElements.get() guard: await import("regular-layout") when unregistered (top-level await keeps registration synchronous with module evaluation, as the static import was), skip-and-warn otherwise, matching Add @perspective-dev/anywidget for vscode/jupyter #3192's bootstrap pattern. Done at the source rather than as an anywidget-build alias because the viewer build inlines regular-layout — this covers every consumer of the viewer bundle.
  • registry.ts — in user mode, re-registering a consumed key that already has a source is now idempotent: keep the existing runtime, warn once per key per page, resolve. replace_server (init_server()) still throws for consumed keys, and keys consumed without ever being registered (e.g. server32 after server was selected) still throw as before.

Notes for reviewers

  • The await import makes the viewer ESM dist an async module (top-level await). All in-repo consumers are ESM/es2022 or webpack 5, so this is legal; a hypothetical classic-script consumer of the ESM dist would break (none exists in-repo).
  • Semantics worth a look: re-registering an unconsumed key still overwrites its source (pre-existing reconfiguration behavior), so a second anywidget evaluation silently refreshes the server source while the consumed viewer key skips+warns.

Validation

  • registry.spec.js: 3 new tests (second init() resolves with an object-identical runtime; warn-once-per-key across three inits; init_server() still throws on a consumed server) — suite: 11 passed, 1 pre-existing skip.
  • multi_panel/close_panel.spec.ts (exercises <regular-layout>/<regular-layout-frame> heavily): 6 passed.
  • End-to-end: importing the rebuilt perspective-anywidget.js twice via fresh blob URLs (mimicking two anywidget models) — both evaluations succeed, both exported ready promises resolve against the same runtime, zero console errors. Previously the second import threw with NotSupportedError: the name "regular-layout" has already been used with this registry.

anywidget evaluates a widget's `_esm` module once per widget model via a
fresh blob-URL import, so module-scope side effects in
`perspective-anywidget.js` run once per model while `customElements` and
`globalThis` remain page-global. Two module-scope side effects threw on the
second evaluation:

* `import "regular-layout"` self-registers `regular-layout`,
  `regular-layout-frame` and `regular-layout-tab` unguarded. Replace the
  static import with a `customElements.get()` guard and skip-and-warn,
  matching the perspective-dev#3192 pattern for Perspective's own elements.
* `init()` at module scope (since perspective-dev#3269) re-registered `viewer`/`server`
  keys against the page-wide wasm registry, which threw "already
  initialized". `register()` in `user` mode now treats re-registration of
  a consumed key that already has a source as idempotent: it keeps the
  existing runtime, warns once per key, and resolves. `replace_server`
  (`init_server()`) still throws for consumed keys, and keys consumed
  without ever being registered (e.g. `server32` after `server` was
  selected) still throw as before.

Signed-off-by: Carson <cpsievert1@gmail.com>
cpsievert added a commit to posit-dev/py-shinywidgets that referenced this pull request Nov 10, 2026
5.8.0 and 5.9.0 (released after the !=5.7.0 exclusion) do not fix the
WASM init regression: tests/playwright/render_perspective fails 6/6 on
both with the same 'viewer wasm is already initialized' errors. Pin to
<5.7 and reference the upstream fix proposal
(perspective-dev/perspective#3287).
cpsievert added a commit to posit-dev/py-shinywidgets that referenced this pull request Nov 10, 2026
Instead of <5.7, exclude 5.7.0-5.9.0 specifically. Once the upstream
fix (perspective-dev/perspective#3287) ships in a release, fresh CI
runs resolve to it automatically and tests/playwright/render_perspective
re-validates the fix against shinywidgets.
Signed-off-by: Andrew Stein <steinlink@gmail.com>

@texodus texodus left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the PR!

The await import makes the viewer ESM dist an async module (top-level await).

I've updated regular-layout texodus/regular-layout#20 and reverted this section 2739e9e

Semantics worth a look: re-registering an unconsumed key still overwrites its source

I don't know what the semantics of this should be. I'd prefer error, maybe with a "Reconsider your choices" message, except it seems many popular platforms do this. I did not realize Anywidget re-evaluates the module per model ... this library is ~400k with charts and datagrid that loads, parses, then subsequently ignored and leaks (I assume). I will do a follow-up that memoizes the bulk of Perspective's application code for anywidget to prevent this.

@texodus
texodus merged commit 376f71e into perspective-dev:master Nov 11, 2026
1 of 17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

anywidget front-end throws on the second widget instance in a page

2 participants