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()