diff --git a/ipyvue/Template.py b/ipyvue/Template.py index 94cc2e4..097292f 100644 --- a/ipyvue/Template.py +++ b/ipyvue/Template.py @@ -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"] diff --git a/ipyvue/__init__.py b/ipyvue/__init__.py index 8482a96..0797bf6 100755 --- a/ipyvue/__init__.py +++ b/ipyvue/__init__.py @@ -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 ( diff --git a/ipyvue/esm.py b/ipyvue/esm.py new file mode 100644 index 0000000..78bf467 --- /dev/null +++ b/ipyvue/esm.py @@ -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"] diff --git a/js/src/Module.js b/js/src/Module.js new file mode 100644 index 0000000..f027e99 --- /dev/null +++ b/js/src/Module.js @@ -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, +}; diff --git a/js/src/Template.js b/js/src/Template.js index dec98ef..9a30aeb 100644 --- a/js/src/Template.js +++ b/js/src/Template.js @@ -10,6 +10,8 @@ class TemplateModel extends WidgetModel { ...super.defaults(), ...{ _model_name: 'TemplateModel', + esm_module: null, + esm_export: null, }, }; } diff --git a/js/src/VueTemplateRenderer.js b/js/src/VueTemplateRenderer.js index d07bd3c..575239d 100644 --- a/js/src/VueTemplateRenderer.js +++ b/js/src/VueTemplateRenderer.js @@ -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) { @@ -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); @@ -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_) { @@ -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 @@ -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('_') @@ -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, diff --git a/js/src/VueView.js b/js/src/VueView.js index 8635a17..84ed5f7 100644 --- a/js/src/VueView.js +++ b/js/src/VueView.js @@ -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 { @@ -17,6 +18,7 @@ export function createViewContext(view) { export class VueView extends DOMWidgetView { remove() { this.vueApp.$destroy(); + untrackRootInstance(this.vueApp); return super.remove(); } @@ -33,6 +35,7 @@ export class VueView extends DOMWidgetView { }, render: createElement => vueRender(createElement, this.model, this, {}), }); + trackRootInstance(this.vueApp); }); } } diff --git a/js/src/es-module-shims-txt.js b/js/src/es-module-shims-txt.js new file mode 100644 index 0000000..f956bf0 --- /dev/null +++ b/js/src/es-module-shims-txt.js @@ -0,0 +1,2 @@ +/* v1.8.2 */ +export default "(function(){const e=typeof window!==\"undefined\";const t=typeof document!==\"undefined\";const noop=()=>{};const r=t?document.querySelector(\"script[type=esms-options]\"):void 0;const s=r?JSON.parse(r.innerHTML):{};Object.assign(s,self.esmsInitOptions||{});let n=!t||!!s.shimMode;const a=globalHook(n&&s.onimport);const i=globalHook(n&&s.resolve);let c=s.fetch?globalHook(s.fetch):fetch;const f=s.meta?globalHook(n&&s.meta):noop;const ne=s.mapOverrides;let oe=s.nonce;if(!oe&&t){const e=document.querySelector(\"script[nonce]\");e&&(oe=e.nonce||e.getAttribute(\"nonce\"))}const ce=globalHook(s.onerror||noop);const le=s.onpolyfill?globalHook(s.onpolyfill):()=>{console.log(\"%c^^ Module TypeError above is polyfilled and can be ignored ^^\",\"font-weight:900;color:#391\")};const{revokeBlobURLs:ue,noLoadEventRetriggers:de,enforceIntegrity:pe}=s;function globalHook(e){return typeof e===\"string\"?self[e]:e}const he=Array.isArray(s.polyfillEnable)?s.polyfillEnable:[];const me=he.includes(\"css-modules\");const be=he.includes(\"json-modules\");const ke=!navigator.userAgentData&&!!navigator.userAgent.match(/Edge\\/\\d+\\.\\d+/);const we=t?document.baseURI:`${location.protocol}//${location.host}${location.pathname.includes(\"/\")?location.pathname.slice(0,location.pathname.lastIndexOf(\"/\")+1):location.pathname}`;const createBlob=(e,t=\"text/javascript\")=>URL.createObjectURL(new Blob([e],{type:t}));let{skip:ge}=s;if(Array.isArray(ge)){const e=ge.map((e=>new URL(e,we).href));ge=t=>e.some((e=>e[e.length-1]===\"/\"&&t.startsWith(e)||t===e))}else if(typeof ge===\"string\"){const e=new RegExp(ge);ge=t=>e.test(t)}else ge instanceof RegExp&&(ge=e=>ge.test(e));const eoop=e=>setTimeout((()=>{throw e}));const throwError=t=>{(self.reportError||e&&window.safari&&console.error||eoop)(t),void ce(t)};function fromParent(e){return e?` imported from ${e}`:\"\"}let ve=false;function setImportMapSrcOrLazy(){ve=true}if(!n)if(document.querySelectorAll(\"script[type=module-shim],script[type=importmap-shim],link[rel=modulepreload-shim]\").length)n=true;else{let e=false;for(const t of document.querySelectorAll(\"script[type=module],script[type=importmap]\"))if(e){if(t.type===\"importmap\"&&e){ve=true;break}}else t.type!==\"module\"||t.ep||(e=true)}const ye=/\\\\/g;function asURL(e){try{if(e.indexOf(\":\")!==-1)return new URL(e).href}catch(e){}}function resolveUrl(e,t){return resolveIfNotPlainOrUrl(e,t)||asURL(e)||resolveIfNotPlainOrUrl(\"./\"+e,t)}function resolveIfNotPlainOrUrl(e,t){const r=t.indexOf(\"#\"),s=t.indexOf(\"?\");r+s>-2&&(t=t.slice(0,r===-1?s:s===-1||s>r?r:s));e.indexOf(\"\\\\\")!==-1&&(e=e.replace(ye,\"/\"));if(e[0]===\"/\"&&e[1]===\"/\")return t.slice(0,t.indexOf(\":\")+1)+e;if(e[0]===\".\"&&(e[1]===\"/\"||e[1]===\".\"&&(e[2]===\"/\"||e.length===2&&(e+=\"/\"))||e.length===1&&(e+=\"/\"))||e[0]===\"/\"){const r=t.slice(0,t.indexOf(\":\")+1);if(r===\"blob:\")throw new TypeError(`Failed to resolve module specifier \"${e}\". Invalid relative url or base scheme isn't hierarchical.`);let s;if(t[r.length+1]===\"/\")if(r!==\"file:\"){s=t.slice(r.length+2);s=s.slice(s.indexOf(\"/\")+1)}else s=t.slice(8);else s=t.slice(r.length+(t[r.length]===\"/\"));if(e[0]===\"/\")return t.slice(0,t.length-s.length-1)+e;const n=s.slice(0,s.lastIndexOf(\"/\")+1)+e;const a=[];let i=-1;for(let e=0;e \"${e[a]}\" does not resolve`)}}let $e=!t&&(0,eval)(\"u=>import(u)\");let Se;const Oe=t&&new Promise((e=>{const t=Object.assign(document.createElement(\"script\"),{src:createBlob(\"self._d=u=>import(u)\"),ep:true});t.setAttribute(\"nonce\",oe);t.addEventListener(\"load\",(()=>{if(!(Se=!!($e=self._d))){let e;window.addEventListener(\"error\",(t=>e=t));$e=(t,r)=>new Promise(((s,n)=>{const a=Object.assign(document.createElement(\"script\"),{type:\"module\",src:createBlob(`import*as m from'${t}';self._esmsi=m`)});e=void 0;a.ep=true;oe&&a.setAttribute(\"nonce\",oe);a.addEventListener(\"error\",cb);a.addEventListener(\"load\",cb);function cb(i){document.head.removeChild(a);if(self._esmsi){s(self._esmsi,we);self._esmsi=void 0}else{n(!(i instanceof Event)&&i||e&&e.error||new Error(`Error loading ${r&&r.errUrl||t} (${a.src}).`));e=void 0}}document.head.appendChild(a)}))}document.head.removeChild(t);delete self._d;e()}));document.head.appendChild(t)}));let Le=false;let xe=false;const Ae=t&&HTMLScriptElement.supports;let Ce=Ae&&Ae.name===\"supports\"&&Ae(\"importmap\");let Ue=Se;const Ee=\"import.meta\";const Pe='import\"x\"assert{type:\"css\"}';const Ie='import\"x\"assert{type:\"json\"}';let Me=Promise.resolve(Oe).then((()=>{if(Se)return t?new Promise((e=>{const t=document.createElement(\"iframe\");t.style.display=\"none\";t.setAttribute(\"nonce\",oe);function cb({data:r}){const s=Array.isArray(r)&&r[0]===\"esms\";if(s){Ce=r[1];Ue=r[2];xe=r[3];Le=r[4];e();document.head.removeChild(t);window.removeEventListener(\"message\",cb,false)}}window.addEventListener(\"message\",cb,false);const r=`