Skip to content
Draft
5 changes: 5 additions & 0 deletions ipyvue/Template.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,11 @@ class Template(Widget):
_model_module_version = Unicode(semver).tag(sync=True)

template = 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
Expand Up @@ -3,6 +3,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
77 changes: 77 additions & 0 deletions ipyvue/esm.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"""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

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: Optional[Path] = None,
*,
code: Optional[str] = None,
url: Optional[str] = None,
) -> Module:
"""Register an ES module under a name.

Parameters
----------
name:
Import-map name the module will be available under.
module:
Path to the module source on disk (e.g. a vite/rollup build with
``vue`` marked external).
code:
The module source as a string.
url:
A url the module is served from (e.g. a bundle in the app's
static dir).
"""
if sum(x is not None for x in (module, code, url)) != 1:
raise TypeError("pass exactly one of module (a Path), code or url")
if module is not None and not isinstance(module, Path):
raise TypeError("module must be a Path; use url=... or code=... for strings")
dependencies = [n for n in _module_names if n != name]
if name not in _module_names:
_module_names.append(name)
if url is not None:
return Module(url=url, name=name, dependencies=dependencies)
if code is None:
assert module is not None
code = module.read_text(encoding="utf8")
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"]
61 changes: 61 additions & 0 deletions js/src/Module.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { WidgetModel } from '@jupyter-widgets/base';
import Vue from 'vue';
import {
forceUpdateRoots,
invalidateModule,
loadModuleFromCode,
loadModuleFromUrl,
provideModule,
requestModule,
} from './esmModule';

/* Ships a precompiled ES module (see ipyvue.esm.define_module). A module
* whose default export is a plain vue plugin ({ install }) registers its
* own components: vue2 has a global registry, so Vue.use is all we need. */
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') {
Vue.use(module.default);
forceUpdateRoots();
}
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 @@ -10,6 +10,8 @@ class TemplateModel extends WidgetModel {
...super.defaults(),
...{
_model_name: 'TemplateModel',
esm_module: null,
esm_export: null,
},
};
}
Expand Down
98 changes: 97 additions & 1 deletion js/src/VueTemplateRenderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { createObjectForNestedModel, eventToObject, vueRender } from './VueRende
import { VueModel } from './VueModel';
import { VueTemplateModel } from './VueTemplateModel';
import httpVueLoader from './httpVueLoader';
import { getEsmComponent, getLoadedModule, requestModule } from './esmModule';
import { TemplateModel } from './Template';

function normalizeScopeId(value) {
Expand Down Expand Up @@ -94,6 +95,9 @@ function createComponentObject(model, parentView) {

const isTemplateModel = model.get('template') instanceof TemplateModel;
const templateModel = isTemplateModel ? model.get('template') : model;
if (isTemplateModel && templateModel.get('esm_module')) {
return createEsmTemplateComponent(model, templateModel, parentView);
}
const template = templateModel.get('template');
const sourceCodeFile = `VUE_TEMPLATE_SCRIPT_${model.cid}`;
const vuefile = readVueFile(template, sourceCodeFile);
Expand Down Expand Up @@ -158,7 +162,8 @@ 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 esmComponents = componentEntries.filter(([, v]) => v && v.esm_module);
const classComponents = componentEntries.filter(([, v]) => !(v instanceof WidgetModel) && !(typeof v === 'string') && !(v && v.esm_module));
const fullVueComponents = componentEntries.filter(([, v]) => typeof v === 'string');

function callVueFn(name, this_) {
Expand Down Expand Up @@ -195,6 +200,7 @@ function createComponentObject(model, parentView) {
...createInstanceComponents(instanceComponents, parentView),
...createClassComponents(classComponents, model, parentView),
...createFullVueComponents(fullVueComponents),
...createEsmComponents(esmComponents),
},
computed: { ...vuefile.SCRIPT && vuefile.SCRIPT.computed, ...aliasRefProps(model) },
template: vuefile.TEMPLATE === undefined && vuefile.SCRIPT === undefined && vuefile.STYLE === undefined
Expand Down Expand Up @@ -225,6 +231,89 @@ function createComponentObject(model, parentView) {
};
}

/* Precompiled ES module export as the component implementation (see
* ipyvue.esm.define_module and Template.esm_module). The export's options
* ride as mixins[0] under the model mixin: vue merges mixins in order, so
* model traits override the script's data() placeholders and injected event
* handlers override method stubs - the same precedence as the in-browser
* compiled path. */
function createEsmTemplateComponent(model, templateModel, parentView) {
const componentEntries = Object.entries(model.get('components') || {});
const instanceComponents = componentEntries.filter(([, v]) => v instanceof WidgetModel);
const esmComponents = componentEntries.filter(([, v]) => v && v.esm_module);
const classComponents = componentEntries.filter(([, v]) => !(v instanceof WidgetModel) && !(typeof v === 'string') && !(v && v.esm_module));
const fullVueComponents = componentEntries.filter(([, v]) => typeof v === 'string');

const modelMixin = {
inject: ['viewCtx'],
data() {
return createDataMapping(model);
},
created() {
addModelListeners(model, this);
},
watch: createWatches(model, parentView, null),
methods: createMethods(model, parentView),
components: {
...createInstanceComponents(instanceComponents, parentView),
...createClassComponents(classComponents, model, parentView),
...createFullVueComponents(fullVueComponents),
...createEsmComponents(esmComponents),
},
computed: aliasRefProps(model),
};

const moduleName = templateModel.get('esm_module');
const exportName = templateModel.get('esm_export');
const componentFromModule = (module) => {
if (module instanceof Error) {
throw module;
}
let component = module[exportName || 'default'];
if (!component) {
throw new Error(`Module "${moduleName}" has no export "${exportName || 'default'}"`);
}
if (component.props) {
/* template-form components get their state as data (for the
* two-way model sync); vue2 lets a props declaration (e.g.
* written for type checkers) shadow that data, so ignore it
* like the compiled-template path does */
const { props, ...withoutProps } = component;
component = withoutProps;
}
return { mixins: [component, modelMixin] };
};
/* memoize per widget, keyed on the module registry promise: a fresh
* component every render would never settle. A module reload provides a
* new promise, so hot reload gets a fresh component. */
const modulePromise = requestModule(moduleName);
if (model.__esmComponentFor !== modulePromise) {
// eslint-disable-next-line no-param-reassign
model.__esmComponentFor = modulePromise;
const module = getLoadedModule(moduleName);
if (module) {
/* the module is already loaded: build the component
* synchronously, so the widget renders in one pass and keeps
* el.__vue__ pointing at the component itself */
// eslint-disable-next-line no-param-reassign
model.__esmComponent = componentFromModule(module);
} else {
const factory = () => modulePromise.then(componentFromModule);
/* wrap the async factory in a component of our own: resolving
* only re-renders the factory's owner, and embedders can cache
* the surrounding vnodes (rendering the factory ownerless), so
* the owner must be an instance whose render we control */
// eslint-disable-next-line no-param-reassign
model.__esmComponent = {
render(h) {
return h(factory);
},
};
}
}
return model.__esmComponent;
}

function createDataMapping(model) {
return model.keys()
.filter(prop => !prop.startsWith('_')
Expand Down Expand Up @@ -388,6 +477,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
3 changes: 3 additions & 0 deletions js/src/VueView.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { DOMWidgetView } from '@jupyter-widgets/base';
import Vue from 'vue';
import { vueRender } from './VueRenderer';
import { trackRootInstance, untrackRootInstance } from './esmModule';

export function createViewContext(view) {
return {
Expand All @@ -17,6 +18,7 @@ export function createViewContext(view) {
export class VueView extends DOMWidgetView {
remove() {
this.vueApp.$destroy();
untrackRootInstance(this.vueApp);
return super.remove();
}

Expand All @@ -33,6 +35,7 @@ export class VueView extends DOMWidgetView {
},
render: createElement => vueRender(createElement, this.model, this, {}),
});
trackRootInstance(this.vueApp);
});
}
}
2 changes: 2 additions & 0 deletions js/src/es-module-shims-txt.js

Large diffs are not rendered by default.

Loading
Loading