Skip to content

feat: support precompiled ES modules as template implementations - #106

Merged
maartenbreddels merged 21 commits into
masterfrom
feat/esm-modules
Oct 5, 2026
Merged

maartenbreddels merged 21 commits into
masterfrom
feat/esm-modules

Conversation

@maartenbreddels

@maartenbreddels maartenbreddels commented Jul 4, 2026 •

Copy link
Copy Markdown
Collaborator

Lets a VueTemplate use a precompiled ES module export as its implementation, so .vue files can be built ahead of time instead of compiled in the browser.

Problem

Today every ipyvue template ships its .vue source to the browser, and the browser compiles it at runtime with vue/compiler-sfc.
Authors cannot use npm dependencies in a template without window-global hacks.
Template errors only show up when a user opens the page.
ipyreact already solves this for React with define_module; ipyvue (and Solara apps on Vue 3) had no equivalent.

Change

  • ipyvue.define_module(name, module: Path=None, *, code=None, url=None, dependencies=None) ships an ES module once per kernel through es-module-shims and the import map.
    It has the same interface and default as ipyreact: without dependencies=, a module waits for every module defined before it whose widget is still open.
    Defining a name again updates its existing widget and keeps the name's first dependency list, the way Solara's esm_vue.py does.
  • Template(esm_module=..., esm_export=...) uses one export as the template's implementation.
    The export's options are mixins[0] under the ipyvue model mixin, so Python traits override data() and Python event handlers override methods, the same precedence as compiled templates.
  • components={"x": {"esm_module": ..., "esm_export": ...}} uses an export as a tag with its own props and events.
  • A module whose default export is a Vue plugin ({ install(app) }) is installed on every app, current and future.
  • Mounted views refresh through the existing change:template hot-reload path when a module is defined again, when esm_module, esm_export, components or events change, when a module that failed to load is fixed, and when a plugin loads after its tags already rendered (string templates, components strings, ESM templates and Html(tag=...)).
  • The JS module registry keeps waiters alive across a reload and drops a load that a newer load replaced.

Validation

  • CI is green on 575cb42, including 19 UI tests in tests/ui/test_esm_module.py and 6 unit tests in tests/unit/test_esm.py.
  • The UI tests cover: module as template and as tag, plugin registration, a late plugin (string template, precompiled resolveComponent, Html tag), unrelated input text kept across a plugin load, module reload at the root, inside an ipyvuetify container, inside another template, and for a root plus embedded copy, components/esm_export changes, a failed import that is fixed later, dependency-only recovery, and a reload while a consumer waits.
  • Workers ran these suites locally on each fix round; CI ran them on every push.

Gaps

  • The VueTemplate data, methods and css traits are not applied to an ESM template.
  • The module registry is global to the page, so two kernels in one JupyterLab page that define the same name share it.
  • A refresh of a nested template remounts its whole view root, like the existing template hot reload does.
  • A nested child that is hidden with v-if during a module reload shows the old export when it is shown again.
  • A plugin tag inside another component's slot, or inside a globally registered VueComponent, is not refreshed after a late plugin load.
  • A plugin that a module no longer exports after a reload stays installed. Blob URLs from reloads are not revoked.
  • Precompiled <script setup> exports do not see Python traits.

Align results

Caution

/align was not run on this change: the design questions were settled with the maintainer in the session instead. They chose ipyreact parity for define_module (same interface and default, plus an explicit dependencies=), fixing the review findings in impact order, and then a simplification pass with Opus workers and astra and sol reviewers.

Crossreview results

  • Rounds: 5. Rounds 1 to 3 used astra, sol and opus on c805c09, 27f8f61 and 2257752. The maintainer then asked for a simplification and for astra and sol only; rounds 4 and 5 reviewed 4c63437 and 575cb42.
  • Final verdict: astra APPROVE, sol APPROVE on 575cb42.
  • No CRITICAL or HIGH finding stands. The HIGH findings raised along the way, and their fixes:
    • define_module made every module depend on every name defined earlier in the Python process, so a second session or test deadlocked (a waits for b, b waits for a). This also made CI red. Fixed: the default skips closed modules, a redefinition updates the live widget, and a name keeps its first dependency list.
    • A late plugin load re-rendered every app and remounted unrelated widgets, losing typed text. Fixed by the simplification: only affected templates refresh.
    • A template whose first import failed never recovered. Fixed: the loading and error placeholders carry the refresh listener.
  • Why these defects got in: the dependency default was copied from ipyreact without considering several sessions in one process; the first refresh design assumed a root re-render reaches nested children, which is wrong in Vue 3; listeners lived only in the resolved async component.
  • Small fixes and confirmers: the redefinition fix (Solara parity) was confirmed by the reviewer who raised it on the Vue 2 PR, then ported here.
  • Constitution changes that could have prevented them: a brief that copies a design from a sibling library names that library's assumptions to check (here: one session per process); a refresh design for a component tree names the nesting cases it must cover before implementation.

🤖 Generated with Claude Code

@maartenbreddels

Copy link
Copy Markdown
Collaborator Author

API update on the branch: define_module now takes exactly one of module (a Path), code= or url= — nothing is guessed from a plain str (also mirrored in ipyreact#80 and solara#1169).

maartenbreddels and others added 7 commits October 5, 2026 10:22
define_module(name, code_or_path) ships an ES module once per kernel
(imported via the existing es-module-shims machinery and registered in
the import map), usable in two forms:

- Template(esm_module=, esm_export=): the export replaces the in-browser
  compiled template. Its options ride as mixins[0] under the ipyvue model
  mixin, the same precedence compileSfc produces, so model traits
  override script data() defaults and injected event handlers override
  methods - existing templates work AOT-compiled without changes.
- {"esm_module": ..., "esm_export": ...} entries in a template's
  components dict: the export is used as a tag with its own props/emits,
  no mixin.

This allows building .vue files ahead of time (e.g. with vite, vue
external) with npm dependencies compiled in: no template source over the
wire and no vue/compiler-sfc at runtime for these components.

The vue import-map entry is re-added before each module import and
expose() no longer deletes its window global: another library (ipyreact)
can replace the importShim global with its own es-module-shims copy
after our init, and the vue blob may then be evaluated more than once.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A module whose default export is a plain vue plugin ({ install(app) })
is app.use'd on every app, current and future. This is the vue3-idiomatic
way for a precompiled bundle to register components globally (vue3 has no
global registry): the names live in the bundle next to the components,
no Python-side registration calls, and the bundle stays a normal vue
plugin usable outside ipyvue.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
define_module(name, "/static/public/bundle.mjs") imports the module from
the url instead of shipping the code over the widget model - e.g. a
bundle served from the app's static dir in production.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ource

A plain str was ambiguously code-or-url based on a prefix heuristic; now
str always means a url, Path means a file, and inline source moves to an
explicit code keyword.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The shim must be a page-wide singleton; the script tag is the
cross-library mutex. When another library (e.g. ipyreact) is loading
its copy, wait for the importShim global instead of proceeding without
it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The ipyvuetify test wheel pins a released ipyvue, downgrading the wheel
under test (and losing the modules under test with it).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There is one es-module-shims per page, shared with any other library that
loads it (ipyreact), and it reads this global once. Overwriting dropped
ipyreact's mapOverrides, so re-pointing an import map entry was rejected
and its module hot reload silently kept serving the old module. We need
mapOverrides for our own redefinitions too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@maartenbreddels
maartenbreddels changed the base branch from vue3 to master October 5, 2026 08:22
maartenbreddels and others added 10 commits October 5, 2026 11:09
Closed Module widgets should not keep later modules waiting on names that no longer have a frontend provider. define_module now matches the ipyreact source interface, registers only created widgets, and lets callers override dependencies explicitly.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A hot reload could delete the pending module resolver and leave components waiting forever. The registry now keeps pending waiters, replacement loads invalidate synchronously, and stale generations stop before updating import maps, plugins, or consumers.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Mounted Vue templates did not always rerender when ES modules or ESM template selectors changed after the first render.

This change triggers the existing template-change path for late plugins, module replacements, and esm_module/esm_export updates, and keeps only the newest plugin object per module name.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ipywidgets_runner tests changed widgets after the kernel code returned,
outside the kernel context, so the changes never reached the page.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A root $forceUpdate re-ran the root render, but the mounted ESM template
was not replaced, so module, export and plugin changes never showed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Redefining a name created a second Module widget whose default
dependencies formed a cycle with the other redefined modules. Update the
live widget instead, as solara does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Template refreshes could miss nested Vue 3 parents and stale ESM component registrations. This records rendering owners, refreshes module consumers after good modules are provided, and covers late plugin and dependency retry paths.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Nested VueModel parents reused cached child vnodes after a template version bump, so ESM updates could refresh the owner without re-rendering the template child.

The cache now includes the template render key, and owner refreshes use Vue's public force-update path without an extra root refresh.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Parent updates could make the wrapper build a new component object and remount the child.

This caches the inner component until the template refresh key changes, while forwarding slots, attrs, listeners, and refs through the wrapper.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
maartenbreddels and others added 4 commits October 5, 2026 13:53
Plugin registration now refreshes only the instances that rendered the newly registered tags, and template refresh versions no longer depend on mounted component lifetime. This keeps local widget state intact while still refreshing hidden, root, embedded, and jupyter-widget copies after ESM changes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The per-instance refresh machinery grew one review finding at a time and was hard to follow. Triggering change:template on the affected templates reuses the root re-render that template hot reload already relies on, and covers module reloads, ESM selector changes and late plugins with one mechanism.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…dule worked

A template whose module failed or was still loading had no refresh listener, so fixing the module or changing esm_export left it blank or on the old export. Html(tag=...) widgets and string components in `components` that use a plugin tag also did not re-render once the plugin loaded.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ot form a cycle

Redefining a module whose widget was closed created a new widget with every later module as dependency, so a->[b] and b->[a] both waited forever after a page reload. Solara avoids this by keeping the first dependency list of a name; do the same.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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