From dbe51d91974ef75928c6c032d971a15be9ca38de Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Sat, 4 Jul 2026 15:29:52 +0200 Subject: [PATCH 1/7] feat: support precompiled ES modules as component implementations 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 --- ipyvue/Template.py | 5 ++ ipyvue/__init__.py | 1 + ipyvue/esm.py | 55 +++++++++++++++++ js/src/Module.js | 50 ++++++++++++++++ js/src/Template.js | 2 + js/src/VueTemplateRenderer.js | 26 +++++++- js/src/esmVueTemplate.js | 91 +++++++++++++++++++++++++++- js/src/index.js | 1 + js/src/nodeps.js | 1 + tests/ui/test_esm_module.py | 109 ++++++++++++++++++++++++++++++++++ 10 files changed, 338 insertions(+), 3 deletions(-) create mode 100644 ipyvue/esm.py create mode 100644 js/src/Module.js create mode 100644 tests/ui/test_esm_module.py diff --git a/ipyvue/Template.py b/ipyvue/Template.py index 4529be0..5493bb5 100644 --- a/ipyvue/Template.py +++ b/ipyvue/Template.py @@ -76,6 +76,11 @@ class Template(Widget): template = Unicode(None, allow_none=True).tag(sync=True) source_url = Unicode(None, allow_none=True).tag(sync=True) + # When set, the component implementation comes from a precompiled ES + # module (see ipyvue.esm.define_module) instead of compiling `template` + # in the browser. `esm_export` selects the export (default: "default"). + esm_module = Unicode(None, allow_none=True).tag(sync=True) + esm_export = Unicode(None, allow_none=True).tag(sync=True) __all__ = ["Template", "watch"] diff --git a/ipyvue/__init__.py b/ipyvue/__init__.py index eb7fdbd..5271d35 100755 --- a/ipyvue/__init__.py +++ b/ipyvue/__init__.py @@ -1,6 +1,7 @@ from ._version import __version__ from .Html import Html from .Template import Template, watch +from .esm import Module, define_module from .VueWidget import VueWidget from .VueTemplateWidget import VueTemplate from .VueComponentRegistry import ( diff --git a/ipyvue/esm.py b/ipyvue/esm.py new file mode 100644 index 0000000..6e8df9b --- /dev/null +++ b/ipyvue/esm.py @@ -0,0 +1,55 @@ +"""ES module (ESM) support: ship precompiled bundles instead of .vue source. + +Mirrors ipyreact's module mechanism: ``define_module(name, code_or_path)`` +creates a ``Module`` widget whose code is sent to the frontend once, imported +via es-module-shims, and registered in the import map under ``name``. Vue +components exported by such a module can then be used as the implementation +of a VueTemplate (see ``Template.esm_module`` / ``Template.esm_export``), +bypassing the in-browser SFC compiler entirely. +""" + +from pathlib import Path +from typing import List, Union + +from ipywidgets import Widget +from traitlets import List as ListTrait +from traitlets import Unicode + +from ._version import semver + +_module_names: List[str] = [] + + +class Module(Widget): + _model_name = Unicode("ModuleModel").tag(sync=True) + _model_module = Unicode("jupyter-vue").tag(sync=True) + _model_module_version = Unicode(semver).tag(sync=True) + + name = Unicode().tag(sync=True) + code = Unicode().tag(sync=True) + dependencies = ListTrait(Unicode(), default_value=[]).tag(sync=True) + + +def define_module(name: str, module: Union[str, Path]) -> Module: + """Register an ES module under a name. + + Parameters + ---------- + name: + Import-map name the module will be available under. + module: + The ES module source, or a Path to it (e.g. a vite/rollup build with + ``vue`` marked external). + """ + code = module.read_text(encoding="utf8") if isinstance(module, Path) else module + dependencies = [n for n in _module_names if n != name] + if name not in _module_names: + _module_names.append(name) + return Module(code=code, name=name, dependencies=dependencies) + + +def get_module_names() -> List[str]: + return list(_module_names) + + +__all__ = ["Module", "define_module", "get_module_names"] diff --git a/js/src/Module.js b/js/src/Module.js new file mode 100644 index 0000000..83a90d1 --- /dev/null +++ b/js/src/Module.js @@ -0,0 +1,50 @@ +import { WidgetModel } from '@jupyter-widgets/base'; +import { + invalidateModule, + loadModuleFromCode, + provideModule, + requestModule, +} from './esmVueTemplate'; + +/* Ships a precompiled ES module (see ipyvue.esm.define_module). The code is + * imported via es-module-shims and provided to the named-module registry, + * where getEsmAsyncComponent consumers await it. */ +export class ModuleModel extends WidgetModel { + defaults() { + return { + ...super.defaults(), + ...{ + _model_name: 'ModuleModel', + name: '', + code: '', + dependencies: [], + }, + }; + } + + initialize(attributes, options) { + super.initialize(attributes, options); + this.load(); + this.on('change:code', () => { + invalidateModule(this.get('name')); + this.load(); + }); + } + + async load() { + const name = this.get('name'); + try { + const dependencies = this.get('dependencies') || []; + await Promise.all(dependencies.map(dep => requestModule(dep))); + const module = await loadModuleFromCode(this.get('code'), name); + provideModule(name, module); + } catch (e) { + console.error(`ipyvue: failed to load ES module "${name}"`, e); + provideModule(name, e); + } + } +} + +ModuleModel.serializers = { + ...WidgetModel.serializers, +}; diff --git a/js/src/Template.js b/js/src/Template.js index ceccfcf..e039c0d 100644 --- a/js/src/Template.js +++ b/js/src/Template.js @@ -11,6 +11,8 @@ class TemplateModel extends WidgetModel { ...{ _model_name: 'TemplateModel', source_url: null, + esm_module: null, + esm_export: null, }, }; } diff --git a/js/src/VueTemplateRenderer.js b/js/src/VueTemplateRenderer.js index 052bd30..cc22420 100644 --- a/js/src/VueTemplateRenderer.js +++ b/js/src/VueTemplateRenderer.js @@ -6,7 +6,7 @@ import { createObjectForNestedModel, eventToObject, vueRender } from './VueRende import { VueModel } from './VueModel'; import { VueTemplateModel } from './VueTemplateModel'; import { TemplateModel } from './Template'; -import {getAsyncComponent} from "./esmVueTemplate"; +import {getAsyncComponent, getEsmAsyncComponent, getEsmComponent} from "./esmVueTemplate"; export function vueTemplateRender(model, parentView) { return Vue.h(createComponentObject(model, parentView)); @@ -30,9 +30,23 @@ function createComponentObject(model, parentView) { const componentEntries = Object.entries(model.get('components') || {}); const instanceComponents = componentEntries.filter(([, v]) => v instanceof WidgetModel); - const classComponents = componentEntries.filter(([, v]) => !(v instanceof WidgetModel) && !(typeof v === 'string')); + const classComponents = componentEntries.filter(([, v]) => !(v instanceof WidgetModel) && !(typeof v === 'string') && !(v && v.esm_module)); + const esmComponents = componentEntries.filter(([, v]) => v && v.esm_module); const fullVueComponents = componentEntries.filter(([, v]) => typeof v === 'string'); + const esmModule = templateModel.get('esm_module'); + if (esmModule) { + return getEsmAsyncComponent(esmModule, templateModel.get('esm_export'), { + ...createModelMixin(model, templateModel, parentView), + components: { + ...createInstanceComponents(instanceComponents, parentView), + ...createClassComponents(classComponents, model, parentView), + ...createFullVueComponents(fullVueComponents), + ...createEsmComponents(esmComponents), + }, + }); + } + return getAsyncComponent( template, { @@ -41,6 +55,7 @@ function createComponentObject(model, parentView) { ...createInstanceComponents(instanceComponents, parentView), ...createClassComponents(classComponents, model, parentView), ...createFullVueComponents(fullVueComponents), + ...createEsmComponents(esmComponents), }, }, { @@ -234,6 +249,13 @@ function createClassComponents(components, containerModel, parentView) { }), {}); } +function createEsmComponents(components) { + return components.reduce((accumulator, [componentName, spec]) => ({ + ...accumulator, + [componentName]: getEsmComponent(spec.esm_module, spec.esm_export), + }), {}); +} + function createFullVueComponents(components) { return components.reduce((accumulator, [componentName, vueFile]) => ({ ...accumulator, diff --git a/js/src/esmVueTemplate.js b/js/src/esmVueTemplate.js index d6b8480..f0ab9f1 100644 --- a/js/src/esmVueTemplate.js +++ b/js/src/esmVueTemplate.js @@ -172,6 +172,93 @@ export async function addModule(name, module) { }) } +/* Named-module registry (mirrors ipyreact): ModuleModel widgets provide + * modules by name; consumers await them, so load order does not matter. */ +const _providedModules = {}; +const _moduleResolvers = {}; + +export function provideModule(name, module) { + if (_moduleResolvers[name]) { + _moduleResolvers[name].resolve(module); + delete _moduleResolvers[name]; + } else { + _providedModules[name] = Promise.resolve(module); + } +} + +export function requestModule(name) { + if (!_providedModules[name]) { + _providedModules[name] = new Promise((resolve, reject) => { + _moduleResolvers[name] = { resolve, reject }; + }); + } + return _providedModules[name]; +} + +export function invalidateModule(name) { + /* next requestModule waits for a fresh provideModule (hot reload) */ + delete _providedModules[name]; + delete _moduleResolvers[name]; +} + +export async function loadModuleFromCode(code, name) { + await init(); + /* another library (e.g. ipyreact) may have replaced the importShim + * global since init; re-add the vue mapping so this import resolves + * against the shim that will actually run it (same refresh toModule + * does for compiled SFCs) */ + addVueImportMap(); + const url = toModuleUrl(withSourceURL(code, `ipyvue-module:///${name}.mjs`)); + const module = await importShim(url); + /* Also expose under the name for inter-module imports. Import maps + * cannot remap an already-resolved specifier (hot reload in the same + * page); the named-module registry is the source of truth, so a failed + * remap only means inter-module imports keep the previous version. */ + try { + importShim.addImportMap({ imports: { [name]: url } }); + } catch (e) { + console.warn(`ipyvue: could not (re)map import "${name}" (stale inter-module imports until page reload)`, e); + } + return module; +} + +async function resolveModuleExport(moduleName, exportName) { + const module = await requestModule(moduleName); + if (module instanceof Error) { + /* ModuleModel provides its load error so consumers fail visibly */ + throw module; + } + const component = module[exportName || 'default']; + if (!component) { + throw new Error(`Module "${moduleName}" has no export "${exportName || 'default'}"`); + } + return component; +} + +/* Component whose implementation comes from a precompiled ES module instead + * of an in-browser compiled SFC. Mirrors compileSfc's output shape: the + * component's own options ride as mixins[0] so the ipyvue model mixin + * (mixins[1], providing the Python traits as data and the event methods) + * takes precedence over the component's own data() placeholders. */ +export function getEsmAsyncComponent(moduleName, exportName, mixin) { + return Vue.defineAsyncComponent(async () => { + const component = await resolveModuleExport(moduleName, exportName); + const { render, setup, __scopeId, ...rest } = component; + return { + ...(render && { render }), + ...(setup && { setup }), + ...(__scopeId && { __scopeId }), + mixins: [rest, mixin], + }; + }); +} + +/* An ES module export used directly as a component (a tag inside another + * template): no model mixin, the component keeps its own props/emits. */ +export function getEsmComponent(moduleName, exportName) { + return Vue.defineAsyncComponent(() => resolveModuleExport(moduleName, exportName)); +} + let _init_promise = null; let _vue_module_url = null; function vueModuleUrl() { @@ -222,10 +309,12 @@ function expose(module) { const id = "_ipyvue2_" + (Math.random()).toString(36); window[id] = module; const names = Object.keys(module).join(", ") + /* no delete of the global: the blob can be evaluated more than once + * (import-map updates, or a second es-module-shims instance loaded by + * another library), and each evaluation reads it */ return toModuleUrl(` const { ${names} } = window["${id}"]; export default window["${id}"].default; - delete window["${id}"]; export { ${names} };`) } diff --git a/js/src/index.js b/js/src/index.js index a05c438..cbcdb98 100644 --- a/js/src/index.js +++ b/js/src/index.js @@ -4,6 +4,7 @@ export { VueTemplateModel } from './VueTemplateModel'; export { VueView, createViewContext } from './VueView'; export { HtmlModel } from './Html'; export { TemplateModel } from './Template'; +export { ModuleModel } from './Module'; export { ForceLoadModel } from './ForceLoad'; export { vueRender, getScope } from './VueRenderer'; export { VueComponentModel, addApp, removeApp } from './VueComponentModel'; diff --git a/js/src/nodeps.js b/js/src/nodeps.js index 6637246..dbb2039 100644 --- a/js/src/nodeps.js +++ b/js/src/nodeps.js @@ -7,6 +7,7 @@ export { VueTemplateModel } from './VueTemplateModel'; export { VueView, createViewContext } from './VueView'; export { HtmlModel } from './Html'; export { TemplateModel } from './Template'; +export { ModuleModel } from './Module'; export { ForceLoadModel } from './ForceLoad'; export { vueRender, getScope } from './VueRenderer'; export { VueComponentModel, addApp, removeApp } from './VueComponentModel'; diff --git a/tests/ui/test_esm_module.py b/tests/ui/test_esm_module.py new file mode 100644 index 0000000..f3fac09 --- /dev/null +++ b/tests/ui/test_esm_module.py @@ -0,0 +1,109 @@ +import pytest +import sys + +if sys.version_info < (3, 7): + pytest.skip("requires python3.7 or higher", allow_module_level=True) + +import playwright.sync_api + + +@pytest.mark.parametrize("ipywidgets_runner", ["solara"], indirect=True) +def test_esm_module_component( + ipywidgets_runner, + page_session: playwright.sync_api.Page, +): + def kernel_code(): + import traitlets + import ipyvue + from ipywidgets import widget_serialization + from IPython.display import display + + ipyvue.define_module( + "esm-test-module", + """ + import { h } from "vue"; + + export const Label = { + data: () => ({ msg: "placeholder" }), + render() { + return h("div", { class: "esm-widget" }, this.msg); + }, + }; + """, + ) + + class Widget(ipyvue.VueTemplate): + template = traitlets.Any().tag(sync=True, **widget_serialization) + msg = traitlets.Unicode("from python").tag(sync=True) + + @traitlets.default("template") + def _template(self): + return ipyvue.Template(esm_module="esm-test-module", esm_export="Label") + + display(Widget()) + + ipywidgets_runner(kernel_code) + # the model mixin must override the module's own data() placeholder + page_session.locator(".esm-widget >> text=from python").wait_for() + + +@pytest.mark.parametrize("ipywidgets_runner", ["solara"], indirect=True) +def test_esm_module_component_as_tag( + ipywidgets_runner, + page_session: playwright.sync_api.Page, +): + def kernel_code(): + import traitlets + import ipyvue + from ipywidgets import widget_serialization + from IPython.display import display + + ipyvue.define_module( + "esm-click-module", + """ + import { h } from "vue"; + + export const ClickButton = { + props: { count: { type: Number, required: true } }, + emits: ["bump"], + render() { + return h( + "button", + { class: "esm-counter", onClick: () => this.$emit("bump", 1) }, + `${this.count} clicks`, + ); + }, + }; + """, + ) + + class Widget(ipyvue.VueTemplate): + template = traitlets.Unicode( + """ + + """ + ).tag(sync=True) + count = traitlets.Int(0).tag(sync=True) + components = traitlets.Dict( + { + "click-button": { + "esm_module": "esm-click-module", + "esm_export": "ClickButton", + } + } + ).tag(sync=True, **widget_serialization) + + def vue_on_bump(self, amount): + self.count += amount + + display(Widget()) + + ipywidgets_runner(kernel_code) + # props flow in (count), events flow out (@bump -> python -> count += 1) + counter = page_session.locator(".esm-counter") + counter.click() + page_session.locator(".esm-counter >> text=1 clicks").wait_for() + counter.click() + page_session.locator(".esm-counter >> text=2 clicks").wait_for() From e24bf0dfbcdd6a98f552b75d76dd2265dd25a845 Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Sat, 4 Jul 2026 19:59:33 +0200 Subject: [PATCH 2/7] feat: ES modules can register their own components as vue plugins 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 --- js/src/Module.js | 4 +++ js/src/VueComponentModel.js | 10 ++++++++ tests/ui/test_esm_module.py | 49 +++++++++++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+) diff --git a/js/src/Module.js b/js/src/Module.js index 83a90d1..560f644 100644 --- a/js/src/Module.js +++ b/js/src/Module.js @@ -5,6 +5,7 @@ import { provideModule, requestModule, } from './esmVueTemplate'; +import { installModulePlugin } from './VueComponentModel'; /* Ships a precompiled ES module (see ipyvue.esm.define_module). The code is * imported via es-module-shims and provided to the named-module registry, @@ -37,6 +38,9 @@ export class ModuleModel extends WidgetModel { const dependencies = this.get('dependencies') || []; await Promise.all(dependencies.map(dep => requestModule(dep))); const module = await loadModuleFromCode(this.get('code'), name); + if (module.default && typeof module.default.install === 'function') { + installModulePlugin(module.default); + } provideModule(name, module); } catch (e) { console.error(`ipyvue: failed to load ES module "${name}"`, e); diff --git a/js/src/VueComponentModel.js b/js/src/VueComponentModel.js index bba4353..9cb7316 100644 --- a/js/src/VueComponentModel.js +++ b/js/src/VueComponentModel.js @@ -8,6 +8,7 @@ import { version } from './version'; const apps = new Set(); const appsWithBaseComponents = new WeakSet(); const registeredComponentsByApp = new WeakMap(); +const modulePlugins = new Set(); export function addApp(app, widget_manager) { apps.add(app); @@ -16,10 +17,19 @@ export function addApp(app, widget_manager) { app.component('jupyter-widget', jupyterWidgetComponent()); appsWithBaseComponents.add(app); } + modulePlugins.forEach(plugin => app.use(plugin)); return syncComponentModels(app, widget_manager); } +/* An ES module (see esm.py) whose default export is a vue plugin registers + * its own components: we app.use it on every app, current and future. + * app.use ignores repeated installs of the same plugin. */ +export function installModulePlugin(plugin) { + modulePlugins.add(plugin); + apps.forEach(app => app.use(plugin)); +} + async function syncComponentModels(app, widget_manager) { const models = await Promise.all(Object.values(widget_manager._models)); models diff --git a/tests/ui/test_esm_module.py b/tests/ui/test_esm_module.py index f3fac09..f0d86fd 100644 --- a/tests/ui/test_esm_module.py +++ b/tests/ui/test_esm_module.py @@ -107,3 +107,52 @@ def vue_on_bump(self, amount): page_session.locator(".esm-counter >> text=1 clicks").wait_for() counter.click() page_session.locator(".esm-counter >> text=2 clicks").wait_for() + + +@pytest.mark.parametrize("ipywidgets_runner", ["solara"], indirect=True) +def test_esm_module_plugin_registers_components( + ipywidgets_runner, + page_session: playwright.sync_api.Page, +): + def kernel_code(): + import traitlets + import ipyvue + from IPython.display import display + + # the module registers its own components: the default export is a + # plain vue plugin, applied to every app + ipyvue.define_module( + "esm-plugin-module", + """ + import { h } from "vue"; + + const Hello = { + props: { name: { type: String, required: true } }, + render() { + const text = `hello ${this.name}`; + return h("div", { class: "esm-plugin-hello" }, text); + }, + }; + + export default { + install(app) { + app.component("esm-hello", Hello); + }, + }; + """, + ) + + class Widget(ipyvue.VueTemplate): + template = traitlets.Unicode( + """ + + """ + ).tag(sync=True) + name = traitlets.Unicode("from python").tag(sync=True) + + display(Widget()) + + ipywidgets_runner(kernel_code) + page_session.locator(".esm-plugin-hello >> text=hello from python").wait_for() From def7b45350aedf88839a453904ec013f94459b8c Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Sat, 4 Jul 2026 20:25:54 +0200 Subject: [PATCH 3/7] feat: modules can be imported from a url 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 --- ipyvue/esm.py | 9 ++++++++- js/src/Module.js | 9 +++++++-- js/src/esmVueTemplate.js | 12 ++++++++++++ 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/ipyvue/esm.py b/ipyvue/esm.py index 6e8df9b..f63b532 100644 --- a/ipyvue/esm.py +++ b/ipyvue/esm.py @@ -27,6 +27,9 @@ class Module(Widget): name = Unicode().tag(sync=True) code = Unicode().tag(sync=True) + # when set, the module is imported from this url instead of shipping the + # code over the widget model (e.g. a bundle served from a static dir) + url = Unicode(None, allow_none=True).tag(sync=True) dependencies = ListTrait(Unicode(), default_value=[]).tag(sync=True) @@ -41,10 +44,14 @@ def define_module(name: str, module: Union[str, Path]) -> Module: The ES module source, or a Path to it (e.g. a vite/rollup build with ``vue`` marked external). """ - code = module.read_text(encoding="utf8") if isinstance(module, Path) else module dependencies = [n for n in _module_names if n != name] if name not in _module_names: _module_names.append(name) + if isinstance(module, str) and ( + module.startswith("http") or module.startswith("/") + ): + return Module(url=module, name=name, dependencies=dependencies) + code = module.read_text(encoding="utf8") if isinstance(module, Path) else module return Module(code=code, name=name, dependencies=dependencies) diff --git a/js/src/Module.js b/js/src/Module.js index 560f644..7244d19 100644 --- a/js/src/Module.js +++ b/js/src/Module.js @@ -2,6 +2,7 @@ import { WidgetModel } from '@jupyter-widgets/base'; import { invalidateModule, loadModuleFromCode, + loadModuleFromUrl, provideModule, requestModule, } from './esmVueTemplate'; @@ -18,6 +19,7 @@ export class ModuleModel extends WidgetModel { _model_name: 'ModuleModel', name: '', code: '', + url: null, dependencies: [], }, }; @@ -26,7 +28,7 @@ export class ModuleModel extends WidgetModel { initialize(attributes, options) { super.initialize(attributes, options); this.load(); - this.on('change:code', () => { + this.on('change:code change:url', () => { invalidateModule(this.get('name')); this.load(); }); @@ -37,7 +39,10 @@ export class ModuleModel extends WidgetModel { try { const dependencies = this.get('dependencies') || []; await Promise.all(dependencies.map(dep => requestModule(dep))); - const module = await loadModuleFromCode(this.get('code'), name); + const url = this.get('url'); + const module = url + ? await loadModuleFromUrl(url, name) + : await loadModuleFromCode(this.get('code'), name); if (module.default && typeof module.default.install === 'function') { installModulePlugin(module.default); } diff --git a/js/src/esmVueTemplate.js b/js/src/esmVueTemplate.js index f0ab9f1..02e2275 100644 --- a/js/src/esmVueTemplate.js +++ b/js/src/esmVueTemplate.js @@ -201,6 +201,18 @@ export function invalidateModule(name) { delete _moduleResolvers[name]; } +export async function loadModuleFromUrl(url, name) { + await init(); + addVueImportMap(); + const module = await importShim(url); + try { + importShim.addImportMap({ imports: { [name]: url } }); + } catch (e) { + console.warn(`ipyvue: could not (re)map import "${name}"`, e); + } + return module; +} + export async function loadModuleFromCode(code, name) { await init(); /* another library (e.g. ipyreact) may have replaced the importShim From d4dfc48476c1d57d8856afb24b4e20a7aa2b501e Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Sat, 4 Jul 2026 21:58:06 +0200 Subject: [PATCH 4/7] refactor: explicit define_module argument - str is a url, code= for source 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 --- ipyvue/esm.py | 28 ++++++++++++++++++---------- tests/ui/test_esm_module.py | 6 +++--- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/ipyvue/esm.py b/ipyvue/esm.py index f63b532..4589b57 100644 --- a/ipyvue/esm.py +++ b/ipyvue/esm.py @@ -9,7 +9,7 @@ """ from pathlib import Path -from typing import List, Union +from typing import List, Optional, Union from ipywidgets import Widget from traitlets import List as ListTrait @@ -33,7 +33,9 @@ class Module(Widget): dependencies = ListTrait(Unicode(), default_value=[]).tag(sync=True) -def define_module(name: str, module: Union[str, Path]) -> Module: +def define_module( + name: str, module: Union[str, Path, None] = None, *, code: Optional[str] = None +) -> Module: """Register an ES module under a name. Parameters @@ -41,18 +43,24 @@ def define_module(name: str, module: Union[str, Path]) -> Module: name: Import-map name the module will be available under. module: - The ES module source, or a Path to it (e.g. a vite/rollup build with - ``vue`` marked external). + A url the module is served from (str, e.g. a bundle in the app's + static dir), or a Path to the module source on disk (e.g. a + vite/rollup build with ``vue`` marked external). + code: + The module source as a string (alternative to ``module``). """ + if (module is None) == (code is None): + raise TypeError("pass either module (url or Path) or code") dependencies = [n for n in _module_names if n != name] if name not in _module_names: _module_names.append(name) - if isinstance(module, str) and ( - module.startswith("http") or module.startswith("/") - ): - return Module(url=module, name=name, dependencies=dependencies) - code = module.read_text(encoding="utf8") if isinstance(module, Path) else module - return Module(code=code, name=name, dependencies=dependencies) + if code is not None: + return Module(code=code, name=name, dependencies=dependencies) + if isinstance(module, Path): + return Module( + code=module.read_text(encoding="utf8"), name=name, dependencies=dependencies + ) + return Module(url=module, name=name, dependencies=dependencies) def get_module_names() -> List[str]: diff --git a/tests/ui/test_esm_module.py b/tests/ui/test_esm_module.py index f0d86fd..315195f 100644 --- a/tests/ui/test_esm_module.py +++ b/tests/ui/test_esm_module.py @@ -20,7 +20,7 @@ def kernel_code(): ipyvue.define_module( "esm-test-module", - """ + code=""" import { h } from "vue"; export const Label = { @@ -60,7 +60,7 @@ def kernel_code(): ipyvue.define_module( "esm-click-module", - """ + code=""" import { h } from "vue"; export const ClickButton = { @@ -123,7 +123,7 @@ def kernel_code(): # plain vue plugin, applied to every app ipyvue.define_module( "esm-plugin-module", - """ + code=""" import { h } from "vue"; const Hello = { From 676a37ccc8f7ab30201915cf31de85ab1b24ec44 Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Sun, 5 Jul 2026 12:04:39 +0200 Subject: [PATCH 5/7] fix: wait for an es-module-shims loaded by another library 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 --- js/src/esmVueTemplate.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/js/src/esmVueTemplate.js b/js/src/esmVueTemplate.js index 02e2275..62c8321 100644 --- a/js/src/esmVueTemplate.js +++ b/js/src/esmVueTemplate.js @@ -302,7 +302,14 @@ async function init() { init(); async function loadShim() { + if (window.importShim) { + return; + } if (document.querySelectorAll("script[src*=es-module-shims][type=module]").length || document.getElementById("es-module-shims")) { + /* another library is loading it; wait for its copy */ + while (!window.importShim) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } return; } return loadScript("module", toModuleUrl(esModuleShims), "es-module-shims") From 88af3b76b6d68531988bead58c92b7a9f3d328b0 Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Sun, 5 Jul 2026 13:03:50 +0200 Subject: [PATCH 6/7] ci: keep the branch ipyvue wheel after installing ipyvuetify 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 --- .github/workflows/unit.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index ffd3342..0f4f467 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit.yml @@ -154,6 +154,8 @@ jobs: pip install "solara-server[starlette,dev] @ ${PKG_URL}/solara-server/solara_server-1.57.3-py3-none-any.whl" pip install "pytest-ipywidgets[all] @ ${PKG_URL}/pytest-ipywidgets/pytest_ipywidgets-1.57.3-py3-none-any.whl" pip install "jupyter_server<2" + # ipyvuetify's pin drags in a released ipyvue; put the branch wheel back + pip install --force-reinstall --no-deps ${wheel} - name: Install playwright browsers run: playwright install chromium From 8d1f7d6261f7383a10390b55dae4cf429c5d5e95 Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Fri, 31 Jul 2026 17:22:39 +0200 Subject: [PATCH 7/7] fix: merge esmsInitOptions instead of overwriting it 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 --- js/src/esmVueTemplate.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/js/src/esmVueTemplate.js b/js/src/esmVueTemplate.js index 62c8321..049d97b 100644 --- a/js/src/esmVueTemplate.js +++ b/js/src/esmVueTemplate.js @@ -3,7 +3,10 @@ import { parse, compileScript, compileStyle, compileTemplate } from 'vue/compile import esModuleShims from './es-module-shims-txt.js' import {transform} from "sucrase"; -window.esmsInitOptions = { shimMode: true }; +/* es-module-shims reads this global once, and there is only one shim per page + * (see loadShim below), so merge instead of overwrite: ipyreact needs + * mapOverrides to re-point an import map entry on hot reload, and so do we. */ +window.esmsInitOptions = { ...window.esmsInitOptions, shimMode: true, mapOverrides: true }; function patchCompiledTemplateCode(code) { /* Vuetify slot props can contain a Vue ref object in \`ref\`. Passing that through