Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
9a90980
feat: support precompiled ES modules as component implementations
maartenbreddels Jul 4, 2026
cf98f73
feat: ES modules can register their own components as vue plugins
maartenbreddels Jul 4, 2026
ab02ff5
feat: modules can be imported from a url
maartenbreddels Jul 4, 2026
b710477
refactor: explicit define_module argument - str is a url, code= for s…
maartenbreddels Jul 4, 2026
b9617f8
fix: wait for an es-module-shims loaded by another library
maartenbreddels Jul 5, 2026
ec209cb
ci: keep the branch ipyvue wheel after installing ipyvuetify
maartenbreddels Jul 5, 2026
c805c09
fix: merge esmsInitOptions instead of overwriting it
maartenbreddels Jul 31, 2026
a2cb861
Make ESM module dependencies track live modules
maartenbreddels Oct 5, 2026
1bf7316
Stop pending ESM reloads from stranding consumers
maartenbreddels Oct 5, 2026
4618318
Keep mounted ESM views current after module changes
maartenbreddels Oct 5, 2026
5dcb004
Drive the ESM refresh tests from the kernel context
maartenbreddels Oct 5, 2026
25df797
Refresh mounted ESM templates when their module changes
maartenbreddels Oct 5, 2026
33fa940
Keep external Vue roots refreshing templates
maartenbreddels Oct 5, 2026
27f8f61
Stop a re-run define_module cell from deadlocking after page reload
maartenbreddels Oct 5, 2026
1cdd2fd
Refresh templates from their rendering owner
maartenbreddels Oct 5, 2026
4e69097
Refresh nested templates when ESM code changes
maartenbreddels Oct 5, 2026
2257752
Keep component entries from replacing live child templates
maartenbreddels Oct 5, 2026
b23aad2
Keep ESM refreshes from remounting unrelated widgets
maartenbreddels Oct 5, 2026
4c63437
Refresh ESM templates through the existing hot-reload path
maartenbreddels Oct 5, 2026
95ff997
Re-render ESM templates and plugin tags that rendered before their mo…
maartenbreddels Oct 5, 2026
575cb42
Keep a module's first dependencies so redefining a closed module cann…
maartenbreddels Oct 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/unit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions ipyvue/Template.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
1 change: 1 addition & 0 deletions ipyvue/__init__.py
Original file line number Diff line number Diff line change
@@ -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 (
Expand Down
111 changes: 111 additions & 0 deletions ipyvue/esm.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
"""ES module (ESM) support: ship precompiled bundles instead of .vue source.

Mirrors ipyreact's module mechanism: ``define_module(name, path_or_source)``
creates a ``Module`` widget whose source 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 Dict, List, Optional

from ipywidgets import Widget
from traitlets import List as ListTrait
from traitlets import Unicode

from ._version import semver

_module_names: List[str] = []
_module_widgets: Dict[str, "Module"] = {}
# like solara, a name keeps the dependencies it was first defined with, so a
# redefinition cannot pick up later modules and form a cycle (a->[b], b->[a])
_module_dependencies: Dict[str, 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: Optional[Path] = None,
*,
code: Optional[str] = None,
url: Optional[str] = None,
dependencies: Optional[List[str]] = None,
) -> Module:
"""Register an ES module under a name.

Parameters
----------
name:
Import-map name the module will be available under.
module:
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``).
url:
A URL the module is served from (alternative to ``module`` or
``code``).
dependencies:
Module names to wait for before loading. Defaults to the
dependencies of the first definition of this name, else to the live
modules defined earlier in this process.
"""
if isinstance(module, str):
raise TypeError("module must be a Path; use url= or code= for strings")
if sum(source is not None for source in (module, code, url)) != 1:
raise TypeError("pass exactly one of module, code, or url")
if module is not None:
code = module.read_text(encoding="utf8")
if dependencies is None:
dependencies = _module_dependencies.get(name)
existing = _module_widgets.get(name)
if existing is not None and existing.comm is not None:
# redefining updates the live widget, like solara does: a second
# widget would get the first as a dependency cycle (a->[b], b->[a])
# and both would be restored on page reload
with existing.hold_sync():
existing.url = url
existing.code = code or ""
if dependencies is not None:
existing.dependencies = dependencies
return existing
if dependencies is None:
dependencies = [
n
for n in _module_names
if n != name
and (widget := _module_widgets.get(n)) is not None
and widget.comm is not None
]
widget = Module(
code=code or "",
url=url,
name=name,
dependencies=dependencies,
)
if name not in _module_names:
_module_names.append(name)
_module_dependencies.setdefault(name, dependencies)
_module_widgets[name] = widget
return widget


def get_module_names() -> List[str]:
return list(_module_names)


__all__ = ["Module", "define_module", "get_module_names"]
91 changes: 91 additions & 0 deletions js/src/Module.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { WidgetModel } from '@jupyter-widgets/base';
import {
invalidateModule,
loadModule,
provideModule,
requestModule,
} from './esmVueTemplate';
import { installModulePlugin, refreshTemplates, usesTag } from './VueComponentModel';
import { VueModel } from './VueModel';

/* Per module name, so a slow load of old code cannot overwrite newer code. */
const moduleGenerations = new Map();

function usesModule(model, templateModel, name) {
return templateModel.get('esm_module') === name
|| Object.values(model.get('components') || {}).some(spec => spec && spec.esm_module === name);
}

/* 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 change:dependencies', () => this.load());
}

async load() {
const name = this.get('name');
const generation = (moduleGenerations.get(name) || 0) + 1;
moduleGenerations.set(name, generation);
const isCurrent = () => moduleGenerations.get(name) === generation;
invalidateModule(name);
try {
const dependencies = this.get('dependencies') || [];
await Promise.all(dependencies.map(dep => requestModule(dep)));
if (!isCurrent()) {
return;
}
const module = await loadModule(name, this.get('url'), this.get('code'), isCurrent);
if (!module) {
return;
}
const isPlugin = module.default && typeof module.default.install === 'function';
const tags = isPlugin ? installModulePlugin(name, module.default) : [];
const replaced = provideModule(name, module);
if (replaced || isPlugin) {
/* Mounted templates keep the component they resolved; re-render
* the ones that use this module or may use the plugin's tags.
* A precompiled template resolves tags in its render function,
* so any ESM template may use them. */
refreshTemplates(this.widget_manager, (model, templateModel) => (
(replaced && usesModule(model, templateModel, name))
|| (isPlugin && templateModel.get('esm_module'))
|| tags.some(tag => [templateModel.get('template'), ...Object.values(model.get('components') || {})]
.some(template => usesTag(template, tag)))
));
}
if (tags.length) {
/* VueWidgets (e.g. Html(tag=...)) rendered before the plugin loaded */
Promise.all(Object.values(this.widget_manager._models)).then(models => models
.filter(model => model instanceof VueModel && tags.some(tag => usesTag(`<${model.get('tag')}>`, tag)))
.forEach(model => model.trigger('change:tag')));
}
} catch (e) {
if (!isCurrent()) {
return;
}
console.error(`ipyvue: failed to load ES module "${name}"`, e);
provideModule(name, e);
}
}
}

ModuleModel.serializers = {
...WidgetModel.serializers,
};
2 changes: 2 additions & 0 deletions js/src/Template.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ class TemplateModel extends WidgetModel {
...{
_model_name: 'TemplateModel',
source_url: null,
esm_module: null,
esm_export: null,
},
};
}
Expand Down
59 changes: 49 additions & 10 deletions js/src/VueComponentModel.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
/* eslint camelcase: off */
import { DOMWidgetModel } from '@jupyter-widgets/base';
import {TemplateModel} from './Template';
import { VueTemplateModel } from './VueTemplateModel';
import { jupyterWidgetComponent } from './VueTemplateRenderer';
import {getAsyncComponent} from "./esmVueTemplate";
import { version } from './version';

const apps = new Set();
const appsWithBaseComponents = new WeakSet();
const registeredComponentsByApp = new WeakMap();
const modulePlugins = new Map();

export function addApp(app, widget_manager) {
apps.add(app);
Expand All @@ -16,10 +18,54 @@ 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. Returns the names of
* the components the plugin (re)registered. */
export function installModulePlugin(moduleName, plugin) {
modulePlugins.set(moduleName, plugin);
const componentNames = new Set();
apps.forEach((app) => {
const before = { ...app._context.components };
app.use(plugin);
Object.entries(app._context.components)
.filter(([name, component]) => before[name] !== component)
.forEach(([name]) => componentNames.add(name));
});
return [...componentNames];
}

function kebabCase(name) {
return name.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
}

function pascalCase(name) {
return name.split('-').map(part => part.charAt(0).toUpperCase() + part.slice(1)).join('');
}

/* Vue resolves a tag in kebab or Pascal case, whichever way it was registered. */
export function usesTag(template, name) {
const names = [...new Set([name, kebabCase(name), pascalCase(name)])].join('|');
return typeof template === 'string' && new RegExp(`\\<(${names})[ />\n]`).test(template);
}

/* Re-renders every template that isAffected(model, templateModel) selects,
* through the same change:template path as a template hot reload. */
export async function refreshTemplates(widget_manager, isAffected) {
const models = await Promise.all(Object.values(widget_manager._models));
new Set(models
.filter(model => model instanceof VueTemplateModel)
.map(model => [model, model.get('template') instanceof TemplateModel ? model.get('template') : model])
.filter(([model, templateModel]) => isAffected(model, templateModel))
.map(([, templateModel]) => templateModel))
.forEach(templateModel => templateModel.trigger('change:template'));
}

async function syncComponentModels(app, widget_manager) {
const models = await Promise.all(Object.values(widget_manager._models));
models
Expand Down Expand Up @@ -90,14 +136,10 @@ export class VueComponentModel extends DOMWidgetModel {

const affectedComponents = [];

function re(searchName) {
return new RegExp(`\\<${searchName}[ />\n]`, 'g');
}

function find_usage(searchName) {
affectedComponents.push(searchName);
componentModels
.filter(model => model.get('component').match(re(searchName)))
.filter(model => usesTag(model.get('component'), searchName))
.forEach((model) => {
const cname = model.get('name');
if (!affectedComponents.includes(cname)) {
Expand All @@ -108,11 +150,8 @@ export class VueComponentModel extends DOMWidgetModel {

find_usage(name);

const affectedTemplateModels = models
.filter(model => model instanceof TemplateModel
&& affectedComponents.some(cname => model.get('template').match(re(cname))));

affectedTemplateModels.forEach(model => model.trigger('change:template'));
refreshTemplates(widget_manager, (model, templateModel) => affectedComponents
.some(cname => usesTag(templateModel.get('template'), cname)));
})();
});
this.on('change:source_url', () => {
Expand Down
Loading
Loading