Skip to content

feat: precompiled ES module support (vue2) - #108

Merged
maartenbreddels merged 26 commits into
1.xfrom
feat/esm-modules-1.x
Oct 5, 2026
Merged

maartenbreddels merged 26 commits into
1.xfrom
feat/esm-modules-1.x

Conversation

@maartenbreddels

@maartenbreddels maartenbreddels commented Jul 4, 2026 •

Copy link
Copy Markdown
Collaborator

Brings precompiled ES module support from the Vue 3 line (#106) to the Vue 2 1.x line, so current Solara apps can use .vue files built ahead of time.

Problem

Today every ipyvue template ships its .vue source to the browser, and the browser compiles it at runtime.
Authors cannot use npm dependencies in a template without window-global hacks, and template errors only show up when a user opens the page.
Most production apps still run on Vue 2 (1.x), so they cannot wait for a Vue 3 migration to get this.

Change

  • ipyvue.define_module(name, module: Path=None, *, code=None, url=None, dependencies=None) ships a precompiled bundle (for example vite with vue external) through es-module-shims and an import map in which vue is the running Vue 2.
    It has the same interface and default as ipyreact and #106: without dependencies=, a module waits for every module defined before it whose widget is still open.
    Defining a name again updates its widget and keeps the name's first dependency list, like Solara's esm_vue.py.
  • Template(esm_module=..., esm_export=...) uses an export as a VueTemplate's implementation, with the export's options as mixins[0] under the model mixin.
  • components={"x": {"esm_module": ..., "esm_export": ...}} uses an export as a tag. A module whose default export is a plugin ({ install }) is Vue.used.
  • Each ES module is a reactive cell. An ESM template is a functional holder that reads the cell and a per-model version, so it re-renders by itself after a module reload or an esm_module, esm_export, components or events change. Refs, events and slots pass through to the export. Ordinary compiled templates render exactly as on 1.x.
  • After a plugin installs, mounted templates and VueWidget tags re-render, so tags that rendered before the plugin resolve without remounting unrelated widgets.
  • Model listeners of a template instance are detached when it is destroyed, also for compiled templates.

Validation

  • CI is green on the PR, including 27 UI tests in tests/ui/test_esm_module.py and 6 unit tests in tests/unit/test_esm.py.
  • The UI tests cover: plugin registration and late plugin loads (string template, precompiled export, Html tag) without losing unrelated input text, module as template and as tag, module reload at the root, inside an ipyvuetify container, and inside another template, components/esm_export changes, refs, slots and events through components, switching esm_module on and off on a mounted Template, 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

  • Only precompiled components can use ESM syntax. VueTemplate <script> blocks keep module.exports, because they are evaluated with new Function.
  • 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 page that define the same name share it.
  • A plugin hot reload that replaces a component already mounted through a VueWidget tag, and a Template replaced while its widget is hidden, keep the old implementation until the next re-render.
  • After a mounted Template switches from ESM back to compiled, its $parent is the holder's wrapper until an ancestor re-renders.
  • Plugins must be objects with install; function-form Vue 2 plugins are not installed. Blob URLs from reloads are not revoked.

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, a simplification pass with Opus workers and astra and sol reviewers, and no production cost for widgets that do not use ES modules.

Crossreview results

  • Rounds: 7. Rounds 1 to 5 used astra, sol and opus on e8edbd8, 36005aa, 8e479d1, c7cd6a8 and 8bdc302. The maintainer then asked for a simplification and for astra and sol only; rounds 6 and 7 reviewed 20aad71 and 61bf824. A last confirmation reviewed bd3c154.
  • Final verdict: astra and sol APPROVE on 61bf824. On bd3c154 both confirmed their earlier MEDIUMs fixed and each raised one narrow issue about switching esm_module off on a mounted Template; astra's was fixed in 916db4c, sol's is listed under Gaps.
  • 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 process; a second session, a test run, or a re-run cell after a page reload deadlocked (a waits for b, b waits for a). Fixed: the default skips closed modules, a redefinition updates the live widget, and a name keeps its first dependency list.
    • A VueWidget tag such as Html(tag="late-tag") stayed unresolved after its plugin's first load. Fixed: VueModel renderers re-render after a plugin installs.
  • Why these defects got in: the dependency default was copied from ipyreact without considering several sessions in one process; refresh fixes were added one review finding at a time, and the accumulated machinery (owner tracking, child-cache edits, wrappers) caused regressions such as lost refs, slots and sibling state until the simplification replaced it with a reactive module cell.
  • Small fixes and confirmers: the redefinition fix (b30553f) was confirmed by opus, which raised it; the change:dependencies listener (98303bc) was confirmed by the driver; the stable compiled component (916db4c) by astra, which raised it.
  • 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); after two fix rounds on the same mechanism, re-design it before a third fix.

🤖 Generated with Claude Code

@maartenbreddels

Copy link
Copy Markdown
Collaborator Author

Build artifacts removed from this branch (squashed to one source-only commit). Installable variant lives on feat/esm-modules-1.x-dist = this branch + one prebuilt-frontend commit (never to be merged); GrottoAI/exploration#2424 installs from that.

🤖 Generated with Claude Code

@maartenbreddels

Copy link
Copy Markdown
Collaborator Author

Same API update as #106: define_module(name, module: Path | None, *, code=..., url=...) — exactly one, nothing guessed from a plain str. The -dist install branch is regenerated on top.

maartenbreddels and others added 7 commits October 5, 2026 10:22
define_module(name, code_or_path_or_url) ships an ES module once
(imported via es-module-shims, vue provided through the import map), so
components can be built ahead of time (e.g. vite, vue external) with npm
dependencies bundled in - no source over the wire, no in-browser
compilation for these components.

Two consumption forms:
- a module whose default export is a plain vue plugin ({ install })
  registers its own components: Vue.use on load (vue2 has a global
  registry; already-mounted roots are re-rendered so late registration
  resolves)
- {"esm_module": ..., "esm_export": ...} entries in a template's
  components dict load an export as a vue2 async component factory,
  with its own props/emits

Note: only the precompiled components themselves can use ESM syntax;
VueTemplate script tags keep module.exports (they are evaluated with
new Function, which cannot evaluate export statements).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Template(esm_module=, esm_export=) uses a bundle export as the widget's
component instead of compiling the template in the browser - the same
form the vue3 branch has. The export's options ride as mixins[0] under
the model mixin: vue merges mixins in order, so model traits override
the script's data() placeholders, template assignments sync back through
the model watchers, and injected event handlers override method stubs -
identical semantics to the compiled path.

The async factory is memoized per widget, keyed on the module registry
promise: the root view re-renders on resolve, and a fresh factory every
render would never settle; a module reload provides a new promise, so
hot reload gets a fresh factory.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Embedders can cache the vnodes around a widget (solara's mount point,
ipyvuetify's view render), which leaves the async component factory
without an owner to re-render when the module resolves: the component
then stays an empty placeholder forever. Wrapping the factory in a
component of our own guarantees the resolve re-renders an instance we
control.

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

Leasing-style templates declare props purely for type checkers; the
compiled-template path never honored them, so the ES module path must
not either - in vue2 a prop silently wins over mixin data, leaving
every such field at its prop default instead of the model value.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The async-factory wrapper adds a component instance that takes over
el.__vue__, which tests and tools use to reach the template's methods.
Once the module is loaded there is no reason to go through a factory at
all: building the component synchronously renders in one pass and keeps
el.__vue__ on the component itself. The wrapper remains only for
widgets that render before their module has arrived.

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 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 master to 1.x October 5, 2026 08:22
maartenbreddels and others added 13 commits October 5, 2026 11:07
Closed module widgets should not block later module loads. This adds explicit dependency support, keeps failed path reads from registering names, and records the latest widget per name for the default dependency list.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A module replacement can happen while a consumer is already waiting for that name. This keeps pending waiters attached to the next provided module, ignores stale loads by generation, and guards template scans when ESM templates have no inline template text.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Mounted Vue 2 templates could miss late plugin registrations and ESM module replacements because refreshes only reached VueView roots and the ESM component cache reused the first parent view.

This change triggers existing template-change listeners for affected templates and keys ESM components by parent view, module, export, components, and events.

Proof: npm run build:babel passed; UI tests hit Chromium errors and timeout; unit tests failed before this patch path with mismatched esm imports.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Plugins usually load before templates render. Refreshing all templates
whenever none used the new tags re-rendered every root for nothing.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Solara replaces define_module with a version that returns None, and an
unresolved custom tag has no size, so it is never visible.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
CI runs solara's define_module, which keeps its own process-wide module
list. A module that never finishes loading in one test became a
dependency of every module in later tests, so they all hung.

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>
Plugin reloads could leave mounted templates with stale global component lookups, especially when the consumer did not contain a string template tag. This refreshes ESM template models, tracked Vue roots, ESM template component cache keys, and components/events listeners so mounted views rebuild after plugin or nested ESM changes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Redefining a module with dependencies=[] to unblock it updated the trait
but the frontend kept waiting; ipyreact also reloads on this change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Nested ESM template widgets could keep stale component options because refresh only reached the root and not the vnode owner that recreates instance components.

This updates the owner and root, rebuilds nested VueTemplate instance components through a wrapper, and keeps refresh scans alive when one model promise rejects.

Proof: PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 timeout 120 /Users/maartenbreddels/.t3/worktrees/ipyvue/t3code-86b93a30/.venv/bin/python -m pytest tests/unit/test_esm.py passed, and the requested JS build check passed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Vuetify 1.x and Solara can cache child vnodes, so forcing only the ESM component and root left the old module export mounted. The refresh path now clears ancestor child vnode caches before forcing updates, and the dependency recovery test uses a Module widget so it exercises the frontend dependency listener under Solara.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Nested VueTemplate refreshes needed a transparent component wrapper so parent events, refs, and slots still reach the child.

The refresh path now rebuilds only the changed template component and keeps cached sibling vnodes intact.

Proof: timeout 300 .venv/bin/python -m pytest --solara-port=19873 -k 'not voila and not jupyter_lab and not jupyter_notebook' tests/ui/test_esm_module.py tests/ui/test_template.py passed 27 selected tests; unavailable local runner variants were deselected.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Hidden nested VueTemplate children could reuse cached component options after their template model changed, and late plugin installs forced unrelated roots to remount.

This registers refresh versions on the models and targets plugin refreshes to matching unresolved tags.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
maartenbreddels and others added 6 commits October 5, 2026 13:55
Late plugin tags in slot content and already-resolved VueWidget tags could keep old vnodes because the refresh only touched DOM tag names.

This refreshes the render context that created the slot and re-renders contexts for mounted instances of replaced plugin constructors.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The refresh code grew one review finding at a time (owner walks, child
cache surgery, DOM tag lookups, widget manager scans) and was hard to
follow. A reactive module registry, a self-rendering holder for ESM
templates, functional ESM tags and one re-render of mounted templates
after a plugin install cover the same cases with far less code.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Redefining a name whose widget was closed created a new widget with the default dependencies, all earlier live names. That gave a->[b] while b->[a], so after a page reload both modules waited on each other forever. Solara keeps a name's first dependency list; we now do the same.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The stateful ESM holder took a parent's ref and passed no $slots, a mounted Template never switched between its ES module and compiled renderers, and widget tags rendered before a plugin loaded stayed unknown. Old ESM instances also kept their trait and msg:custom handlers after each reload. A functional holder now picks the renderer per render, VueModel components re-render on plugin load, and instances detach their model handlers on destroy.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Every Template-backed widget went through the holder and its wrapper, so
plain compiled templates paid for an extra instance, saw a different
$parent, and could show a stale template after a switch back from ESM.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The holder built a new compiled component on every render, so an
unrelated parent update remounted the child and dropped its local state.

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