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
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..4589b57
--- /dev/null
+++ b/ipyvue/esm.py
@@ -0,0 +1,70 @@
+"""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, Optional, 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)
+ # 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)
+
+
+def define_module(
+ name: str, module: Union[str, Path, None] = None, *, code: Optional[str] = None
+) -> Module:
+ """Register an ES module under a name.
+
+ Parameters
+ ----------
+ name:
+ Import-map name the module will be available under.
+ module:
+ 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 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]:
+ 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..7244d19
--- /dev/null
+++ b/js/src/Module.js
@@ -0,0 +1,59 @@
+import { WidgetModel } from '@jupyter-widgets/base';
+import {
+ invalidateModule,
+ loadModuleFromCode,
+ loadModuleFromUrl,
+ 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,
+ * where getEsmAsyncComponent consumers await it. */
+export class ModuleModel extends WidgetModel {
+ defaults() {
+ return {
+ ...super.defaults(),
+ ...{
+ _model_name: 'ModuleModel',
+ name: '',
+ code: '',
+ url: null,
+ dependencies: [],
+ },
+ };
+ }
+
+ initialize(attributes, options) {
+ super.initialize(attributes, options);
+ this.load();
+ this.on('change:code change:url', () => {
+ 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 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);
+ }
+ 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/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/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..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
@@ -172,6 +175,105 @@ 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 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
+ * 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() {
@@ -203,7 +305,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")
@@ -222,10 +331,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..315195f
--- /dev/null
+++ b/tests/ui/test_esm_module.py
@@ -0,0 +1,158 @@
+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",
+ code="""
+ 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",
+ code="""
+ 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()
+
+
+@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",
+ code="""
+ 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()