Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
70 changes: 70 additions & 0 deletions ipyvue/esm.py
Original file line number Diff line number Diff line change
@@ -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"]
59 changes: 59 additions & 0 deletions js/src/Module.js
Original file line number Diff line number Diff line change
@@ -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,
};
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
10 changes: 10 additions & 0 deletions js/src/VueComponentModel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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
Expand Down
26 changes: 24 additions & 2 deletions js/src/VueTemplateRenderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand All @@ -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,
{
Expand All @@ -41,6 +55,7 @@ function createComponentObject(model, parentView) {
...createInstanceComponents(instanceComponents, parentView),
...createClassComponents(classComponents, model, parentView),
...createFullVueComponents(fullVueComponents),
...createEsmComponents(esmComponents),
},
},
{
Expand Down Expand Up @@ -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,
Expand Down
115 changes: 113 additions & 2 deletions js/src/esmVueTemplate.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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")
Expand All @@ -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} };`)
}

Expand Down
Loading
Loading