From 3025dc53761138fca8200e10e6e846ce8afb866d Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Sat, 4 Jul 2026 14:46:22 +0200 Subject: [PATCH 1/7] feat: kernel-aware ES module support for ipyvue ipyvue's vue3 branch gains define_module for precompiled ESM bundles (vite-built .vue components); this provides the same server-side handling ipyreact modules get in esm.py: definitions recorded once, Module widgets materialized per kernel (recreated when context.restart closed them, so in-place reload works), bundle Paths watched by the reloader, per-kernel bookkeeping dropped on kernel close. No-op for ipyvue without ES module support (hasattr-gated); the unit tests skip likewise until an ipyvue release ships it. Co-Authored-By: Claude Fable 5 --- solara/components/component_vue.py | 61 +++++++-- solara/server/app.py | 7 + solara/server/esm_vue.py | 80 +++++++++++ solara/server/patch.py | 18 +++ .../content/10-howto/55-vue_esm_components.md | 124 ++++++++++++++++++ tests/unit/component_frontend_test.py | 14 ++ tests/unit/esm_vue_test.py | 110 ++++++++++++++++ 7 files changed, 406 insertions(+), 8 deletions(-) create mode 100644 solara/server/esm_vue.py create mode 100644 solara/website/pages/documentation/advanced/content/10-howto/55-vue_esm_components.md create mode 100644 tests/unit/esm_vue_test.py diff --git a/solara/components/component_vue.py b/solara/components/component_vue.py index f8238726f..06d0bb0bf 100644 --- a/solara/components/component_vue.py +++ b/solara/components/component_vue.py @@ -1,6 +1,6 @@ import inspect import os -from typing import Any, Callable, Dict, Type +from typing import Any, Callable, Dict, Optional, Type import ipyvue as vue import ipyvuetify as v @@ -65,18 +65,36 @@ def event_handler(self, data, buffers=None, event_name=event_name, param=param): def _widget_vue( - vue_path: str, + vue_path: Optional[str], vuetify=True, to_json: Dict[str, Callable[[Any, widgets.Widget], Any]] = {}, from_json: Dict[str, Callable[[Any, widgets.Widget], Any]] = {}, tags: Dict[str, Any] = {}, + esm_module: Optional[str] = None, + esm_export: Optional[str] = None, ) -> Callable[[Callable[P, None]], Type[v.VuetifyTemplate]]: def decorator(func: Callable[P, None]): - class VuetifyWidgetSolara(v.VuetifyTemplate): - template_file = (os.path.abspath(inspect.getfile(func)), vue_path) + if esm_module is not None: + # the component is an export of a precompiled ES module + # (see ipyvue.define_module) instead of a compiled-in-the-browser + # .vue file + class VuetifyWidgetSolara(v.VuetifyTemplate): + @traitlets.default("template") + def _default_template(self): + return vue.Template(esm_module=esm_module, esm_export=esm_export) + + class VueWidgetSolara(vue.VueTemplate): + @traitlets.default("template") + def _default_template(self): + return vue.Template(esm_module=esm_module, esm_export=esm_export) - class VueWidgetSolara(vue.VueTemplate): - template_file = (os.path.abspath(inspect.getfile(func)), vue_path) + else: + + class VuetifyWidgetSolara(v.VuetifyTemplate): # type: ignore[no-redef] + template_file = (os.path.abspath(inspect.getfile(func)), vue_path) + + class VueWidgetSolara(vue.VueTemplate): # type: ignore[no-redef] + template_file = (os.path.abspath(inspect.getfile(func)), vue_path) base_class = VuetifyWidgetSolara if vuetify else VueWidgetSolara widget_class = _widget_from_signature("VueWidgetSolaraSub", base_class, func, "vue_", to_json=to_json, from_json=from_json, tags=tags) @@ -87,11 +105,13 @@ class VueWidgetSolara(vue.VueTemplate): def component_vue( - vue_path: str, + vue_path: Optional[str] = None, vuetify=True, tags: Dict[str, Any] = {}, to_json: Dict[str, Callable[[Any, widgets.Widget], Any]] = {}, from_json: Dict[str, Callable[[Any, widgets.Widget], Any]] = {}, + esm_module: Optional[str] = None, + esm_export: Optional[str] = None, ) -> Callable[[Callable[P, None]], Callable[P, solara.Element]]: """Decorator to create a component backed by a Vue template. @@ -165,15 +185,40 @@ def MyDateComponentSame(month: datetime.date, event_date_clicked: Callable): ``` + ## Precompiled ES modules + + Instead of a `.vue` file that is compiled in the browser, the component can be an export of a + precompiled ES module (e.g. built with vite, see `ipyvue.define_module`). This requires ipyvue + with ES module support: + + ```python + import ipyvue + import solara + + ipyvue.define_module("my-components", Path(__file__).parent / "dist/my-components.mjs") + + @solara.component_vue(esm_module="my-components", esm_export="Counter") + def Counter(count: int = 0, event_bump: Callable[[int], None] = None): + pass + ``` + ## Arguments * `vue_path`: The path to the Vue template file. * `vuetify`: Whether the Vue template uses Vuetify components. + * `esm_module`: Name of an ES module registered with `ipyvue.define_module` (alternative to `vue_path`). + * `esm_export`: The export of that module to use as the component (default: the default export). """ + if (vue_path is None) == (esm_module is None): + raise TypeError("pass either vue_path or esm_module") + if esm_module is not None and not hasattr(vue, "define_module"): + raise RuntimeError("esm_module requires ipyvue with ES module support") def decorator(func: Callable[P, None]): - VueWidgetSolaraSub = _widget_vue(vue_path, vuetify=vuetify, to_json=to_json, from_json=from_json, tags=tags)(func) + VueWidgetSolaraSub = _widget_vue( + vue_path, vuetify=vuetify, to_json=to_json, from_json=from_json, tags=tags, esm_module=esm_module, esm_export=esm_export + )(func) def wrapper(*args, **kwargs): event_callbacks = {} diff --git a/solara/server/app.py b/solara/server/app.py index cd07b358a..6b1b1f2b9 100644 --- a/solara/server/app.py +++ b/solara/server/app.py @@ -412,6 +412,13 @@ def load_app_widget(app_state, app_script: AppScript, pathname: str): solara.server.esm.create_modules() solara.server.esm.create_import_map() + import ipyvue + + if hasattr(ipyvue, "define_module"): + import solara.server.esm_vue + + solara.server.esm_vue.create_modules() + try: render_context = context.app_object app_state = app_state_initial diff --git a/solara/server/esm_vue.py b/solara/server/esm_vue.py new file mode 100644 index 000000000..9d256e42e --- /dev/null +++ b/solara/server/esm_vue.py @@ -0,0 +1,80 @@ +# Kernel-aware ES-module support for ipyvue, mirroring esm.py (ipyreact). +# ipyvue.define_module is monkey-patched (see patch.py) so modules are +# declared once at import time and materialized per kernel. Unlike the +# ipyreact variant, a redefinition recreates the Module widget when the +# kernel's previous widget was closed (e.g. by context.restart on hot +# reload), so in-place reloads actually reach the browser. +import logging +import threading +from collections import defaultdict +from pathlib import Path +from typing import Dict, List, Tuple, Union + +import ipyvue.esm + +from solara.server import kernel_context + +logger = logging.getLogger("solara.server.esm_vue") +lock = threading.Lock() + +_modules: Dict[str, Tuple[Union[str, Path], List[str]]] = {} +_modules_added_per_kernel: Dict[str, Dict[str, ipyvue.esm.Module]] = defaultdict(dict) + + +def define_module(name: str, module: Union[str, Path]): + with lock: + dependencies = list(_modules.keys()) + logger.info("define vue module %s %s (dependencies=%r)", name, module, dependencies) + if name in _modules: + _old_module, dependencies = _modules[name] + _modules[name] = (module, dependencies) + if isinstance(module, Path): + # rebuilding the bundle (e.g. vite build --watch) triggers a normal + # solara reload, which re-reads the file in create_modules + from solara.server import reload + + reload.reloader.watcher.add_file(str(module)) + if kernel_context.has_current_context(): + create_modules() + return None + + +def get_module_names() -> List[str]: + return list(_modules.keys()) + + +def _read(module: Union[str, Path]) -> str: + return module.read_text(encoding="utf8") if isinstance(module, Path) else module + + +def create_modules(): + context = kernel_context.get_current_context() + kernel_id = context.id + if kernel_id not in _modules_added_per_kernel: + # widgets close with the kernel; drop our per-kernel bookkeeping too + def cleanup(kernel_id=kernel_id) -> None: + _modules_added_per_kernel.pop(kernel_id, None) + + context.on_close(cleanup) + _modules_added = _modules_added_per_kernel[kernel_id] + widgets = {} + with lock: + for name, (module, dependencies) in _modules.items(): + widget = _modules_added.get(name) + if widget is not None and widget.comm is None: + # closed by context.restart (hot reload) - a trait update + # would go nowhere, recreate instead + widget = None + if widget is None: + widget = ipyvue.esm.Module(code=_read(module), name=name, dependencies=dependencies) + _modules_added[name] = widget + logger.info("create vue module %s", name) + else: + code = _read(module) + if widget.code != code: + widget.code = code + logger.info("update vue module %s", name) + if widget.dependencies != dependencies: + widget.dependencies = dependencies + widgets[name] = widget + return widgets diff --git a/solara/server/patch.py b/solara/server/patch.py index 63a1cdf86..d823476c9 100644 --- a/solara/server/patch.py +++ b/solara/server/patch.py @@ -369,6 +369,22 @@ def patch_ipyreact(): ipyreact.importmap._update_import_map = lambda: None +def patch_ipyvue_esm(): + import ipyvue + + if not hasattr(ipyvue, "define_module"): + # ipyvue without ES module support + return + + import ipyvue.esm + + from . import esm_vue + + ipyvue.esm.define_module = esm_vue.define_module + ipyvue.esm.get_module_names = esm_vue.get_module_names + ipyvue.define_module = esm_vue.define_module + + @solara.util.once def patch_matplotlib(): import matplotlib @@ -466,6 +482,8 @@ def patch(): else: patch_ipyreact() + patch_ipyvue_esm() + if "MPLBACKEND" not in os.environ: if ipykernel_version_major < 6: # changed in https://github.com/ipython/ipykernel/pull/591 diff --git a/solara/website/pages/documentation/advanced/content/10-howto/55-vue_esm_components.md b/solara/website/pages/documentation/advanced/content/10-howto/55-vue_esm_components.md new file mode 100644 index 000000000..6eaaa4422 --- /dev/null +++ b/solara/website/pages/documentation/advanced/content/10-howto/55-vue_esm_components.md @@ -0,0 +1,124 @@ +--- +title: Using precompiled Vue components (ES modules) +description: Ship ahead-of-time compiled Vue single file components, with npm dependencies bundled in, instead of compiling templates in the browser. +--- +# How can I use precompiled Vue components? + +Note: this requires ipyvue with ES module support (the vue3 series). + +Normally, [`@solara.component_vue`](/documentation/api/utilities/component_vue) sends the `.vue` +template source to the browser, where it is compiled at runtime. Alternatively, you can compile +your `.vue` files **ahead of time** into a single ES module with a bundler like [vite](https://vitejs.dev): + +- npm dependencies get compiled into the bundle (no CDN or window-global tricks needed), +- no template source goes over the wire, and no runtime template compiler is needed, +- the components are plain Vue code, checkable with `vue-tsc` and reusable outside solara. + +## A complete runnable example + +`ipyvue.define_module` accepts the module source as a string or a `Path`, so a minimal +example does not even need a build step: + +```python +from typing import Callable + +import ipyvue +import solara + +ipyvue.define_module( + "my-components", + """ + import { h } from "vue"; + + export const Counter = { + data: () => ({ count: 0 }), // placeholder, the real value comes from Python + methods: { + bump() {}, // placeholder, solara injects the event_bump handler + }, + render() { + return h( + "button", + { class: "my-counter", onClick: () => this.bump(1) }, + `clicked ${this.count} times`, + ); + }, + }; + """, +) + + +@solara.component_vue(esm_module="my-components", esm_export="Counter") +def Counter(count: int = 0, event_bump: Callable[[int], None] = None): + pass + + +@solara.component +def Page(): + count = solara.use_reactive(0) + Counter(count=count.value, event_bump=lambda amount: count.set(count.value + amount)) +``` + +This works exactly like a [`@solara.component_vue`](/documentation/api/utilities/component_vue) +component backed by a `.vue` file: arguments become Vue data (overriding the component's own +`data()` placeholders) and `event_*` arguments become callable methods, because the component's +options are merged under ipyvue's model mixin with the same precedence as a browser-compiled +template. + +## Building real .vue files with vite + +For actual applications, keep your components as `.vue` single file components and build them: + +```js +// vite.config.mjs +import { defineConfig } from "vite"; +import vue from "@vitejs/plugin-vue"; + +export default defineConfig({ + plugins: [vue()], + build: { + lib: { entry: "src/index.js", formats: ["es"], fileName: () => "my-components.mjs" }, + // vue is provided by ipyvue via the import map + rollupOptions: { external: ["vue"] }, + }, +}); +``` + +```js +// src/index.js — npm dependencies (e.g. canvas-confetti) simply get bundled +export { default as Dashboard } from "./dashboard.vue"; +export { default as Toggle } from "./toggle.vue"; +``` + +```python +from pathlib import Path + +import ipyvue + +ipyvue.define_module("my-components", Path(__file__).parent / "dist/my-components.mjs") +``` + +Under `solara run`, the bundle file is watched: run `vite build --watch` next to the server +and every save of a `.vue` file rebuilds the bundle and hot reloads the page in place. + +## Using a precompiled component as a tag + +An export can also be used as a tag inside any other template via the `components` dict. +In that form no model mixin is applied — the component keeps its own props/emits contract: + +```python +class MyWidget(ipyvue.VueTemplate): + template = traitlets.Unicode( + """ + + """ + ).tag(sync=True) + enabled = traitlets.Bool(False).tag(sync=True) + components = traitlets.Dict( + {"my-toggle": {"esm_module": "my-components", "esm_export": "Toggle"}} + ).tag(sync=True, **widget_serialization) + + def vue_on_input(self, value): + self.enabled = value +``` diff --git a/tests/unit/component_frontend_test.py b/tests/unit/component_frontend_test.py index fa93bed45..150eb661c 100644 --- a/tests/unit/component_frontend_test.py +++ b/tests/unit/component_frontend_test.py @@ -82,3 +82,17 @@ def ComponentVueTest(event_foo=None): widget._handle_event(None, {"event": "event_foo", "data": 43}, [b"bar2"]) mock.assert_called_with(43, [b"bar2"]) + + +def test_component_vue_esm_argument_validation(): + import ipyvue + + with pytest.raises(TypeError, match="either vue_path or esm_module"): + solara.component_vue() + + with pytest.raises(TypeError, match="either vue_path or esm_module"): + solara.component_vue("component_vue_test.vue", esm_module="my-components") + + if not hasattr(ipyvue, "define_module"): + with pytest.raises(RuntimeError, match="ES module support"): + solara.component_vue(esm_module="my-components") diff --git a/tests/unit/esm_vue_test.py b/tests/unit/esm_vue_test.py new file mode 100644 index 000000000..9d3271497 --- /dev/null +++ b/tests/unit/esm_vue_test.py @@ -0,0 +1,110 @@ +from pathlib import Path + +import pytest + +ipyvue = pytest.importorskip("ipyvue") + +if not hasattr(ipyvue, "define_module"): + pytest.skip("requires ipyvue with ES module support (>=3.0.0a9)", allow_module_level=True) + +from solara.server import esm_vue, kernel_context # noqa: E402 +from solara.server.kernel import Kernel # noqa: E402 + + +@pytest.fixture() +def clean_esm_state(): + modules = esm_vue._modules.copy() + esm_vue._modules.clear() + added = dict(esm_vue._modules_added_per_kernel) + esm_vue._modules_added_per_kernel.clear() + try: + yield + finally: + esm_vue._modules.clear() + esm_vue._modules.update(modules) + esm_vue._modules_added_per_kernel.clear() + esm_vue._modules_added_per_kernel.update(added) + + +@pytest.fixture() +def virtual_context(clean_esm_state): + context = kernel_context.VirtualKernelContext(id="esm-vue-test-1", kernel=Kernel(), session_id="session-esm-vue-1") + try: + with context: + yield context + finally: + context.close() + + +def test_create_modules_per_kernel_reuse(virtual_context): + esm_vue.define_module("esm-vue-test-module", "export default 1") + widgets = esm_vue.create_modules() + widget = widgets["esm-vue-test-module"] + # a second call must reuse the same (live) widget for this kernel + assert esm_vue.create_modules()["esm-vue-test-module"] is widget + + +def test_create_modules_recreates_closed_widget(virtual_context): + esm_vue.define_module("esm-vue-test-module", "export default 1") + widget = esm_vue.create_modules()["esm-vue-test-module"] + # context.restart() closes the kernel's widgets on (hot) reload; updating + # a trait on a closed widget never reaches the browser, so create_modules + # must hand out a fresh widget instead + widget.close() + assert widget.comm is None + widget2 = esm_vue.create_modules()["esm-vue-test-module"] + assert widget2 is not widget + assert widget2.comm is not None + + +def test_redefine_module_updates_live_widget(virtual_context): + esm_vue.define_module("esm-vue-test-module", "export default 1") + widget = esm_vue.create_modules()["esm-vue-test-module"] + esm_vue.define_module("esm-vue-test-module", "export default 2") + assert esm_vue.create_modules()["esm-vue-test-module"] is widget + assert widget.code == "export default 2" + + +def test_define_module_path_is_watched(clean_esm_state, tmp_path: Path, monkeypatch): + from solara.server import reload + + watched = [] + monkeypatch.setattr(reload.reloader.watcher, "add_file", lambda file: watched.append(str(file))) + module = tmp_path / "bundle.mjs" + module.write_text("export default 1") + esm_vue.define_module("esm-vue-test-path-module", module) + assert watched == [str(module)] + + +def test_kernel_bookkeeping_dropped_on_close(clean_esm_state): + context = kernel_context.VirtualKernelContext(id="esm-vue-test-2", kernel=Kernel(), session_id="session-esm-vue-2") + with context: + esm_vue.define_module("esm-vue-test-module", "export default 1") + esm_vue.create_modules() + assert "esm-vue-test-2" in esm_vue._modules_added_per_kernel + context.close() + assert "esm-vue-test-2" not in esm_vue._modules_added_per_kernel + + +def test_component_vue_esm_widget(virtual_context): + import solara + from solara.components.component_vue import _widget_vue + + @_widget_vue(None, esm_module="esm-vue-test-module", esm_export="Counter") + def Counter(count: int = 0): + pass + + widget = Counter(count=3) + assert widget.template.esm_module == "esm-vue-test-module" + assert widget.template.esm_export == "Counter" + assert widget.count == 3 + + # and via the public decorator (returns a reacton component) + @solara.component_vue(esm_module="esm-vue-test-module", esm_export="Counter") + def CounterComponent(count: int = 0): + pass + + box, _rc = solara.render(CounterComponent(count=5), handle_error=False) + widget = box.children[0] + assert widget.template.esm_module == "esm-vue-test-module" + assert widget.count == 5 From da8d6c0d649833b5fd52f1ab6a61677e8577823f Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Sat, 4 Jul 2026 21:54:27 +0200 Subject: [PATCH 2/7] feat: url modules + modulepreload hints in the page define_module accepts a url (e.g. a bundle served from the app's static dir); the page emits for url-backed modules, so the browser fetches them in parallel with kernel startup instead of waiting for the Module widget to arrive over the websocket (es-module-shims honors modulepreload hints in shim mode). Co-Authored-By: Claude Fable 5 --- solara/server/esm_vue.py | 19 ++++++++++++++++++- solara/server/server.py | 12 ++++++++++++ solara/server/templates/solara.html.j2 | 4 ++++ tests/unit/esm_vue_test.py | 9 +++++++++ 4 files changed, 43 insertions(+), 1 deletion(-) diff --git a/solara/server/esm_vue.py b/solara/server/esm_vue.py index 9d256e42e..6e92dd8d2 100644 --- a/solara/server/esm_vue.py +++ b/solara/server/esm_vue.py @@ -43,6 +43,16 @@ def get_module_names() -> List[str]: return list(_modules.keys()) +def get_module_urls() -> List[str]: + """Urls of url-backed modules, for modulepreload hints in the page.""" + with lock: + return [module for module, _ in _modules.values() if isinstance(module, str) and _is_url(module)] + + +def _is_url(module: Union[str, Path]) -> bool: + return isinstance(module, str) and (module.startswith("http") or module.startswith("/")) + + def _read(module: Union[str, Path]) -> str: return module.read_text(encoding="utf8") if isinstance(module, Path) else module @@ -66,9 +76,16 @@ def cleanup(kernel_id=kernel_id) -> None: # would go nowhere, recreate instead widget = None if widget is None: - widget = ipyvue.esm.Module(code=_read(module), name=name, dependencies=dependencies) + if _is_url(module): + widget = ipyvue.esm.Module(url=module, name=name, dependencies=dependencies) + else: + widget = ipyvue.esm.Module(code=_read(module), name=name, dependencies=dependencies) _modules_added[name] = widget logger.info("create vue module %s", name) + elif _is_url(module): + if widget.url != module: + widget.url = module + logger.info("update vue module url %s", name) else: code = _read(module) if widget.code != code: diff --git a/solara/server/server.py b/solara/server/server.py index 1b98faf8e..f12caaa2c 100644 --- a/solara/server/server.py +++ b/solara/server/server.py @@ -364,8 +364,20 @@ def include_js(path: str, module=False) -> Markup: code = f'' return Markup(code) + module_urls = [] + try: + import ipyvue + + if hasattr(ipyvue, "define_module"): + from solara.server import esm_vue + + module_urls = esm_vue.get_module_urls() + except ModuleNotFoundError: + pass + resources = { "theme": "light", + "module_urls": module_urls, "nbextensions": nbextensions, "nbextensions_hashes": nbextensions_hashes, "include_css": include_css, diff --git a/solara/server/templates/solara.html.j2 b/solara/server/templates/solara.html.j2 index 425df63c8..d5f677664 100644 --- a/solara/server/templates/solara.html.j2 +++ b/solara/server/templates/solara.html.j2 @@ -9,6 +9,10 @@ + {% for url in resources.module_urls -%} + {# start fetching ES modules (ipyvue.define_module) while the kernel starts #} + + {% endfor -%} {% block header %} {{ pre_rendered_css | safe }} diff --git a/tests/unit/esm_vue_test.py b/tests/unit/esm_vue_test.py index 9d3271497..e555280cb 100644 --- a/tests/unit/esm_vue_test.py +++ b/tests/unit/esm_vue_test.py @@ -108,3 +108,12 @@ def CounterComponent(count: int = 0): widget = box.children[0] assert widget.template.esm_module == "esm-vue-test-module" assert widget.count == 5 + + +def test_get_module_urls(clean_esm_state, tmp_path: Path): + esm_vue.define_module("esm-vue-url-module", "/static/public/bundle.mjs") + module = tmp_path / "bundle.mjs" + module.write_text("export default 1") + esm_vue.define_module("esm-vue-file-module", module) + # only url-backed modules can be preloaded by the page + assert esm_vue.get_module_urls() == ["/static/public/bundle.mjs"] From 98f8e9b3c3714bbc5922ebabb25bb424257e5a8f Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Sat, 4 Jul 2026 21:58:35 +0200 Subject: [PATCH 3/7] refactor: explicit define_module argument - str is a url, code= for source A plain str was ambiguously code-or-url based on a prefix heuristic; now str always means a url, Path means a file, and inline source moves to an explicit code keyword. Co-Authored-By: Claude Fable 5 --- solara/server/esm_vue.py | 21 +++++++++++++++------ tests/unit/esm_vue_test.py | 10 +++++----- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/solara/server/esm_vue.py b/solara/server/esm_vue.py index 6e92dd8d2..86e7b77c4 100644 --- a/solara/server/esm_vue.py +++ b/solara/server/esm_vue.py @@ -8,7 +8,7 @@ import threading from collections import defaultdict from pathlib import Path -from typing import Dict, List, Tuple, Union +from typing import Dict, List, Optional, Tuple, Union import ipyvue.esm @@ -21,13 +21,16 @@ _modules_added_per_kernel: Dict[str, Dict[str, ipyvue.esm.Module]] = defaultdict(dict) -def define_module(name: str, module: Union[str, Path]): +def define_module(name: str, module: Union[str, Path, None] = None, *, code: Optional[str] = None): + if (module is None) == (code is None): + raise TypeError("pass either module (url or Path) or code") + source: Union[str, Path] = _CODE_PREFIX + code if code is not None else module # type: ignore[assignment] with lock: dependencies = list(_modules.keys()) - logger.info("define vue module %s %s (dependencies=%r)", name, module, dependencies) + logger.info("define vue module %s (dependencies=%r)", name, dependencies) if name in _modules: _old_module, dependencies = _modules[name] - _modules[name] = (module, dependencies) + _modules[name] = (source, dependencies) if isinstance(module, Path): # rebuilding the bundle (e.g. vite build --watch) triggers a normal # solara reload, which re-reads the file in create_modules @@ -49,12 +52,18 @@ def get_module_urls() -> List[str]: return [module for module, _ in _modules.values() if isinstance(module, str) and _is_url(module)] +# inline source is stored with this prefix so a plain str always means a url +_CODE_PREFIX = "\x00code:" + + def _is_url(module: Union[str, Path]) -> bool: - return isinstance(module, str) and (module.startswith("http") or module.startswith("/")) + return isinstance(module, str) and not module.startswith(_CODE_PREFIX) def _read(module: Union[str, Path]) -> str: - return module.read_text(encoding="utf8") if isinstance(module, Path) else module + if isinstance(module, Path): + return module.read_text(encoding="utf8") + return module[len(_CODE_PREFIX) :] if module.startswith(_CODE_PREFIX) else module def create_modules(): diff --git a/tests/unit/esm_vue_test.py b/tests/unit/esm_vue_test.py index e555280cb..fcb9cf66e 100644 --- a/tests/unit/esm_vue_test.py +++ b/tests/unit/esm_vue_test.py @@ -37,7 +37,7 @@ def virtual_context(clean_esm_state): def test_create_modules_per_kernel_reuse(virtual_context): - esm_vue.define_module("esm-vue-test-module", "export default 1") + esm_vue.define_module("esm-vue-test-module", code="export default 1") widgets = esm_vue.create_modules() widget = widgets["esm-vue-test-module"] # a second call must reuse the same (live) widget for this kernel @@ -45,7 +45,7 @@ def test_create_modules_per_kernel_reuse(virtual_context): def test_create_modules_recreates_closed_widget(virtual_context): - esm_vue.define_module("esm-vue-test-module", "export default 1") + esm_vue.define_module("esm-vue-test-module", code="export default 1") widget = esm_vue.create_modules()["esm-vue-test-module"] # context.restart() closes the kernel's widgets on (hot) reload; updating # a trait on a closed widget never reaches the browser, so create_modules @@ -58,9 +58,9 @@ def test_create_modules_recreates_closed_widget(virtual_context): def test_redefine_module_updates_live_widget(virtual_context): - esm_vue.define_module("esm-vue-test-module", "export default 1") + esm_vue.define_module("esm-vue-test-module", code="export default 1") widget = esm_vue.create_modules()["esm-vue-test-module"] - esm_vue.define_module("esm-vue-test-module", "export default 2") + esm_vue.define_module("esm-vue-test-module", code="export default 2") assert esm_vue.create_modules()["esm-vue-test-module"] is widget assert widget.code == "export default 2" @@ -79,7 +79,7 @@ def test_define_module_path_is_watched(clean_esm_state, tmp_path: Path, monkeypa def test_kernel_bookkeeping_dropped_on_close(clean_esm_state): context = kernel_context.VirtualKernelContext(id="esm-vue-test-2", kernel=Kernel(), session_id="session-esm-vue-2") with context: - esm_vue.define_module("esm-vue-test-module", "export default 1") + esm_vue.define_module("esm-vue-test-module", code="export default 1") esm_vue.create_modules() assert "esm-vue-test-2" in esm_vue._modules_added_per_kernel context.close() From 9e77fda52763b589f8c7d0ab001a16182b45fbbf Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Sat, 4 Jul 2026 22:12:30 +0200 Subject: [PATCH 4/7] feat: content-hash versioning + aggressive caching for url modules versioned_url appends ?v= to module urls that solara serves itself (/static/public/...); the SAME url is used for the modulepreload hint in the page and the Module widget, so the preload is always a cache hit. StaticPublic sends max-age=1y, immutable when the requested hash matches the current file content - any rebuild changes the url, so caching can be aggressive without staleness. Urls with an existing query string and external urls pass through untouched. Co-Authored-By: Claude Fable 5 --- solara/server/esm_vue.py | 31 ++++++++++++++++++++++++---- solara/server/server.py | 35 ++++++++++++++++++++++++++++++++ solara/server/starlette.py | 14 +++++++++++++ tests/unit/esm_vue_test.py | 41 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 117 insertions(+), 4 deletions(-) diff --git a/solara/server/esm_vue.py b/solara/server/esm_vue.py index 86e7b77c4..c02066a8a 100644 --- a/solara/server/esm_vue.py +++ b/solara/server/esm_vue.py @@ -46,10 +46,30 @@ def get_module_names() -> List[str]: return list(_modules.keys()) +_PUBLIC_PREFIX = "/static/public/" + + +def versioned_url(url: str) -> str: + """Append ?v= to urls solara serves itself. + + The same url is used for the modulepreload hint in the page and the + Module widget, so the preload always hits the cache; StaticPublic sends + long-lived cache headers when the hash matches (see starlette.py). + Urls with an existing query string and external urls pass through. + """ + from solara.server import server + + if not url.startswith(_PUBLIC_PREFIX) or "?" in url: + return url + digest = server.public_url_content_hash(url[len(_PUBLIC_PREFIX) :]) + return f"{url}?v={digest}" if digest else url + + def get_module_urls() -> List[str]: """Urls of url-backed modules, for modulepreload hints in the page.""" with lock: - return [module for module, _ in _modules.values() if isinstance(module, str) and _is_url(module)] + urls = [module for module, _ in _modules.values() if isinstance(module, str) and _is_url(module)] + return [versioned_url(url) for url in urls] # inline source is stored with this prefix so a plain str always means a url @@ -86,14 +106,17 @@ def cleanup(kernel_id=kernel_id) -> None: widget = None if widget is None: if _is_url(module): - widget = ipyvue.esm.Module(url=module, name=name, dependencies=dependencies) + assert isinstance(module, str) + widget = ipyvue.esm.Module(url=versioned_url(module), name=name, dependencies=dependencies) else: widget = ipyvue.esm.Module(code=_read(module), name=name, dependencies=dependencies) _modules_added[name] = widget logger.info("create vue module %s", name) elif _is_url(module): - if widget.url != module: - widget.url = module + assert isinstance(module, str) + url = versioned_url(module) + if widget.url != url: + widget.url = url logger.info("update vue module url %s", name) else: code = _read(module) diff --git a/solara/server/server.py b/solara/server/server.py index f12caaa2c..202835d6e 100644 --- a/solara/server/server.py +++ b/solara/server/server.py @@ -442,6 +442,41 @@ def find_prefixed_directory(path): @solara.memoize(storage=cache_memory) +def public_directories() -> List[Path]: + from . import app as appmod + + return [app.directory.parent / "public" for app in appmod.apps.values()] + + +_public_hash_cache: Dict[str, Tuple[Tuple[float, int], str]] = {} + + +def public_url_content_hash(filename: str) -> Optional[str]: + """Content hash of a file served at /static/public/, or None. + + Memoized on (mtime, size), so rebuilt bundles get a fresh hash. + """ + for directory in public_directories(): + path = (directory / filename).resolve() + if not str(path).startswith(str(directory.resolve())): + return None + if path.exists(): + stat = path.stat() + key = str(path) + cached = _public_hash_cache.get(key) + if cached and cached[0] == (stat.st_mtime, stat.st_size): + return cached[1] + if sys.version_info[:2] < (3, 9): + h = hashlib.new("md5") + else: + h = hashlib.new("md5", usedforsecurity=False) # type: ignore + h.update(path.read_bytes()) + digest = h.hexdigest()[:12] + _public_hash_cache[key] = ((stat.st_mtime, stat.st_size), digest) + return digest + return None + + def get_nbextensions_directories() -> List[Path]: from jupyter_core.paths import jupyter_path diff --git a/solara/server/starlette.py b/solara/server/starlette.py index c9f02b68a..58c7c4cd4 100644 --- a/solara/server/starlette.py +++ b/solara/server/starlette.py @@ -53,6 +53,8 @@ from starlette.requests import HTTPConnection, Request from starlette.responses import HTMLResponse, JSONResponse, RedirectResponse, Response from starlette.routing import Mount, Route, WebSocketRoute +from urllib.parse import parse_qs + from starlette.staticfiles import StaticFiles from starlette.types import Receive, Scope, Send @@ -637,6 +639,18 @@ def lookup_path(self, *args, **kwargs): self.all_directories = self.get_directories(None, None) return super().lookup_path(*args, **kwargs) + async def get_response(self, path: str, scope): + response = await super().get_response(path, scope) + # requests versioned with the current content hash (?v=..., see + # solara.server.esm_vue.versioned_url) can be cached forever: any + # change to the file changes the url + query = parse_qs(scope.get("query_string", b"").decode()) + version = query.get("v", [None])[0] + if version is not None and response.status_code in (200, 304): + if version == server.public_url_content_hash(path): + response.headers["Cache-Control"] = "max-age=31536000, immutable" + return response + def get_directories( self, directory: Union[str, "os.PathLike[str]", None] = None, diff --git a/tests/unit/esm_vue_test.py b/tests/unit/esm_vue_test.py index fcb9cf66e..2f9670e24 100644 --- a/tests/unit/esm_vue_test.py +++ b/tests/unit/esm_vue_test.py @@ -117,3 +117,44 @@ def test_get_module_urls(clean_esm_state, tmp_path: Path): esm_vue.define_module("esm-vue-file-module", module) # only url-backed modules can be preloaded by the page assert esm_vue.get_module_urls() == ["/static/public/bundle.mjs"] + + +def test_versioned_url(clean_esm_state, tmp_path: Path, monkeypatch): + from solara.server import server + + public = tmp_path / "public" + public.mkdir() + bundle = public / "bundle.mjs" + bundle.write_text("export default 1") + monkeypatch.setattr(server, "public_directories", lambda: [public]) + server._public_hash_cache.clear() + + url = esm_vue.versioned_url("/static/public/bundle.mjs") + assert url.startswith("/static/public/bundle.mjs?v=") + # stable until the content changes + assert esm_vue.versioned_url("/static/public/bundle.mjs") == url + bundle.write_text("export default 2") + assert esm_vue.versioned_url("/static/public/bundle.mjs") != url + + # pass-through cases: external, already versioned, unresolvable, traversal + assert esm_vue.versioned_url("https://cdn.example/x.mjs") == "https://cdn.example/x.mjs" + assert esm_vue.versioned_url("/static/public/bundle.mjs?v=1") == "/static/public/bundle.mjs?v=1" + assert esm_vue.versioned_url("/static/public/missing.mjs") == "/static/public/missing.mjs" + assert esm_vue.versioned_url("/static/public/../secret.mjs") == "/static/public/../secret.mjs" + + +def test_module_widget_and_page_share_versioned_url(virtual_context, tmp_path: Path, monkeypatch): + from solara.server import server + + public = tmp_path / "public" + public.mkdir() + (public / "bundle.mjs").write_text("export default 1") + monkeypatch.setattr(server, "public_directories", lambda: [public]) + server._public_hash_cache.clear() + + esm_vue.define_module("esm-vue-versioned", "/static/public/bundle.mjs") + widget = esm_vue.create_modules()["esm-vue-versioned"] + # the preload hint in the page and the widget must agree, so the + # preloaded response is a cache hit + assert widget.url == esm_vue.get_module_urls()[0] + assert "?v=" in widget.url From 688a6b0c36fd17e69a1b365825cf4a1a25a86a83 Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Sat, 4 Jul 2026 23:54:02 +0200 Subject: [PATCH 5/7] feat: url modules + versioned preload for ipyreact too; document url serving ipyreact modules get the same treatment as ipyvue ones: define_module accepts a url (str = url, code= for inline source), the widget gets the content-hash-versioned url, and the page preloads it. versioned_url moves to server.py so esm.py does not pull in ipyvue. Co-Authored-By: Claude Fable 5 --- solara/server/esm.py | 40 +++++++++++++++++-- solara/server/esm_vue.py | 31 +++++--------- solara/server/server.py | 27 ++++++++++++- .../content/10-howto/55-vue_esm_components.md | 20 ++++++++++ tests/integration/esm_test.py | 4 +- tests/unit/esm_test.py | 10 ++--- tests/unit/esm_vue_test.py | 14 +++---- 7 files changed, 106 insertions(+), 40 deletions(-) diff --git a/solara/server/esm.py b/solara/server/esm.py index fa68362ff..7d224ef60 100644 --- a/solara/server/esm.py +++ b/solara/server/esm.py @@ -5,7 +5,7 @@ import threading from collections import defaultdict from pathlib import Path -from typing import Dict, List, Tuple, Union +from typing import Dict, List, Optional, Tuple, Union import ipyreact.importmap import ipyreact.module @@ -21,7 +21,12 @@ # in solara server, we'll monkey patch ipyreact.module with this -def define_module(name, module: Union[str, Path]): +def define_module(name, module: Union[str, Path, None] = None, *, code: Optional[str] = None): + if (module is None) == (code is None): + raise TypeError("pass either module (url or Path) or code") + if code is not None: + module = _CODE_PREFIX + code + assert module is not None # collect the dependencies at this moment with lock: dependencies = list(_modules.keys()) @@ -68,15 +73,42 @@ def cleanup(kernel_id=kernel_id): _modules_added[name] = create_module(name, module, dependencies=dependencies) logger.info("create module %s %s %s", name, module, dependencies) else: - _modules_added[name].code = module if not isinstance(module, Path) else module.read_text(encoding="utf8") + _modules_added[name].code = _read(module) if not _is_url(module) else _modules_added[name].code _modules_added[name].dependencies = dependencies logger.info("update module %s %s %s", name, module, dependencies) widgets[name] = _modules_added[name] return widgets +# inline source is stored with this prefix so a plain str always means a url +_CODE_PREFIX = "\x00code:" + + +def _is_url(module: Union[str, Path]) -> bool: + return isinstance(module, str) and not module.startswith(_CODE_PREFIX) + + +def _read(module: Union[str, Path]) -> str: + if isinstance(module, Path): + return module.read_text(encoding="utf8") + return module[len(_CODE_PREFIX) :] if module.startswith(_CODE_PREFIX) else module + + +def get_module_urls() -> List[str]: + from solara.server.server import versioned_url + + with lock: + urls = [module for module, _ in _modules.values() if isinstance(module, str) and _is_url(module)] + return [versioned_url(url) for url in urls] + + def create_module(name, module: Union[str, Path], dependencies: List[str]): - return ipyreact.module.Module(code=module if not isinstance(module, Path) else module.read_text(encoding="utf8"), name=name, dependencies=dependencies) + from solara.server.server import versioned_url + + if _is_url(module): + assert isinstance(module, str) + return ipyreact.module.Module(url=versioned_url(module), name=name, dependencies=dependencies) + return ipyreact.module.Module(code=_read(module), name=name, dependencies=dependencies) def create_import_map(): diff --git a/solara/server/esm_vue.py b/solara/server/esm_vue.py index c02066a8a..71ed9fc15 100644 --- a/solara/server/esm_vue.py +++ b/solara/server/esm_vue.py @@ -46,29 +46,12 @@ def get_module_names() -> List[str]: return list(_modules.keys()) -_PUBLIC_PREFIX = "/static/public/" - - -def versioned_url(url: str) -> str: - """Append ?v= to urls solara serves itself. - - The same url is used for the modulepreload hint in the page and the - Module widget, so the preload always hits the cache; StaticPublic sends - long-lived cache headers when the hash matches (see starlette.py). - Urls with an existing query string and external urls pass through. - """ - from solara.server import server - - if not url.startswith(_PUBLIC_PREFIX) or "?" in url: - return url - digest = server.public_url_content_hash(url[len(_PUBLIC_PREFIX) :]) - return f"{url}?v={digest}" if digest else url - - def get_module_urls() -> List[str]: """Urls of url-backed modules, for modulepreload hints in the page.""" with lock: urls = [module for module, _ in _modules.values() if isinstance(module, str) and _is_url(module)] + from solara.server.server import versioned_url + return [versioned_url(url) for url in urls] @@ -86,6 +69,12 @@ def _read(module: Union[str, Path]) -> str: return module[len(_CODE_PREFIX) :] if module.startswith(_CODE_PREFIX) else module +def _versioned(url: str) -> str: + from solara.server.server import versioned_url + + return versioned_url(url) + + def create_modules(): context = kernel_context.get_current_context() kernel_id = context.id @@ -107,14 +96,14 @@ def cleanup(kernel_id=kernel_id) -> None: if widget is None: if _is_url(module): assert isinstance(module, str) - widget = ipyvue.esm.Module(url=versioned_url(module), name=name, dependencies=dependencies) + widget = ipyvue.esm.Module(url=_versioned(module), name=name, dependencies=dependencies) else: widget = ipyvue.esm.Module(code=_read(module), name=name, dependencies=dependencies) _modules_added[name] = widget logger.info("create vue module %s", name) elif _is_url(module): assert isinstance(module, str) - url = versioned_url(module) + url = _versioned(module) if widget.url != url: widget.url = url logger.info("update vue module url %s", name) diff --git a/solara/server/server.py b/solara/server/server.py index 202835d6e..9a11acbc9 100644 --- a/solara/server/server.py +++ b/solara/server/server.py @@ -371,7 +371,15 @@ def include_js(path: str, module=False) -> Markup: if hasattr(ipyvue, "define_module"): from solara.server import esm_vue - module_urls = esm_vue.get_module_urls() + module_urls += esm_vue.get_module_urls() + except ModuleNotFoundError: + pass + try: + import ipyreact # noqa: F401 + + from solara.server import esm + + module_urls += esm.get_module_urls() except ModuleNotFoundError: pass @@ -477,6 +485,23 @@ def public_url_content_hash(filename: str) -> Optional[str]: return None +_PUBLIC_PREFIX = "/static/public/" + + +def versioned_url(url: str) -> str: + """Append ?v= to urls solara serves itself. + + The same url is used for the modulepreload hint in the page and the + Module widget, so the preload always hits the cache; StaticPublic sends + long-lived cache headers when the hash matches (see starlette.py). + Urls with an existing query string and external urls pass through. + """ + if not url.startswith(_PUBLIC_PREFIX) or "?" in url: + return url + digest = public_url_content_hash(url[len(_PUBLIC_PREFIX) :]) + return f"{url}?v={digest}" if digest else url + + def get_nbextensions_directories() -> List[Path]: from jupyter_core.paths import jupyter_path diff --git a/solara/website/pages/documentation/advanced/content/10-howto/55-vue_esm_components.md b/solara/website/pages/documentation/advanced/content/10-howto/55-vue_esm_components.md index 6eaaa4422..72e607d97 100644 --- a/solara/website/pages/documentation/advanced/content/10-howto/55-vue_esm_components.md +++ b/solara/website/pages/documentation/advanced/content/10-howto/55-vue_esm_components.md @@ -100,6 +100,26 @@ ipyvue.define_module("my-components", Path(__file__).parent / "dist/my-component Under `solara run`, the bundle file is watched: run `vite build --watch` next to the server and every save of a `.vue` file rebuilds the bundle and hot reloads the page in place. +## Serving the bundle by url (production) + +`define_module` also accepts a **url** (a plain `str` always means a url; use `code=` for +inline source). Serve the built bundle from your app's `public/` directory and pass the url: + +```python +ipyvue.define_module("my-components", "/static/public/my-components.mjs") +``` + +For urls solara serves itself this enables aggressive caching without staleness: + +- the page emits ``, + so the browser fetches the bundle in parallel with kernel startup; +- the widget uses the **same versioned url**, so the preload is always a cache hit; +- solara serves the file with `Cache-Control: max-age=1y, immutable` when the requested + hash matches — a rebuild changes the hash and therefore the url, so caches never go stale. + +The same applies to ipyreact modules. During development, prefer the `Path` form: the file +is watched, so a bundler in watch mode gives in-place hot reload. + ## Using a precompiled component as a tag An export can also be used as a tag inside any other template via the `components` dict. diff --git a/tests/integration/esm_test.py b/tests/integration/esm_test.py index 121a9159f..a0e26c9fa 100644 --- a/tests/integration/esm_test.py +++ b/tests/integration/esm_test.py @@ -23,7 +23,7 @@ return React.createElement("button", {onClick: () => setValue(value + 1), children: `clicked ${value || 0}`}) } """ -ipyreact.define_module("solara-test", test_js_code) +ipyreact.define_module("solara-test", code=test_js_code) @solara.component @@ -34,7 +34,7 @@ def Page(): @solara.component def PageDefineDuringRun(): - ipyreact.define_module("solara-test-dynamic", test_js_code) + ipyreact.define_module("solara-test-dynamic", code=test_js_code) value = solara.use_reactive(0) ipyreact.ValueWidget.element(_module="solara-test-dynamic", value=0, on_value=value.set) diff --git a/tests/unit/esm_test.py b/tests/unit/esm_test.py index e19c798ff..e0f3ac3af 100644 --- a/tests/unit/esm_test.py +++ b/tests/unit/esm_test.py @@ -38,7 +38,7 @@ def virtual_context(clean_esm_state): def test_create_modules_per_kernel_reuse(virtual_context): - esm.define_module("esm-test-module", "export default 1") + esm.define_module("esm-test-module", code="export default 1") widgets = esm.create_modules() widget = widgets["esm-test-module"] # a second call must reuse the same (live) widget for this kernel @@ -46,7 +46,7 @@ def test_create_modules_per_kernel_reuse(virtual_context): def test_create_modules_recreates_closed_widget(virtual_context): - esm.define_module("esm-test-module", "export default 1") + esm.define_module("esm-test-module", code="export default 1") widget = esm.create_modules()["esm-test-module"] # context.restart() closes the kernel's widgets on (hot) reload; updating # a trait on a closed widget never reaches the browser, so create_modules @@ -59,9 +59,9 @@ def test_create_modules_recreates_closed_widget(virtual_context): def test_redefine_module_updates_live_widget(virtual_context): - esm.define_module("esm-test-module", "export default 1") + esm.define_module("esm-test-module", code="export default 1") widget = esm.create_modules()["esm-test-module"] - esm.define_module("esm-test-module", "export default 2") + esm.define_module("esm-test-module", code="export default 2") assert esm.create_modules()["esm-test-module"] is widget assert widget.code == "export default 2" @@ -80,7 +80,7 @@ def test_define_module_path_is_watched(clean_esm_state, tmp_path: Path, monkeypa def test_kernel_bookkeeping_dropped_on_close(clean_esm_state): context = kernel_context.VirtualKernelContext(id="esm-test-2", kernel=Kernel(), session_id="session-esm-2") with context: - esm.define_module("esm-test-module", "export default 1") + esm.define_module("esm-test-module", code="export default 1") esm.create_modules() esm.create_import_map() assert "esm-test-2" in esm._modules_added_per_kernel diff --git a/tests/unit/esm_vue_test.py b/tests/unit/esm_vue_test.py index 2f9670e24..f398af582 100644 --- a/tests/unit/esm_vue_test.py +++ b/tests/unit/esm_vue_test.py @@ -129,18 +129,18 @@ def test_versioned_url(clean_esm_state, tmp_path: Path, monkeypatch): monkeypatch.setattr(server, "public_directories", lambda: [public]) server._public_hash_cache.clear() - url = esm_vue.versioned_url("/static/public/bundle.mjs") + url = server.versioned_url("/static/public/bundle.mjs") assert url.startswith("/static/public/bundle.mjs?v=") # stable until the content changes - assert esm_vue.versioned_url("/static/public/bundle.mjs") == url + assert server.versioned_url("/static/public/bundle.mjs") == url bundle.write_text("export default 2") - assert esm_vue.versioned_url("/static/public/bundle.mjs") != url + assert server.versioned_url("/static/public/bundle.mjs") != url # pass-through cases: external, already versioned, unresolvable, traversal - assert esm_vue.versioned_url("https://cdn.example/x.mjs") == "https://cdn.example/x.mjs" - assert esm_vue.versioned_url("/static/public/bundle.mjs?v=1") == "/static/public/bundle.mjs?v=1" - assert esm_vue.versioned_url("/static/public/missing.mjs") == "/static/public/missing.mjs" - assert esm_vue.versioned_url("/static/public/../secret.mjs") == "/static/public/../secret.mjs" + assert server.versioned_url("https://cdn.example/x.mjs") == "https://cdn.example/x.mjs" + assert server.versioned_url("/static/public/bundle.mjs?v=1") == "/static/public/bundle.mjs?v=1" + assert server.versioned_url("/static/public/missing.mjs") == "/static/public/missing.mjs" + assert server.versioned_url("/static/public/../secret.mjs") == "/static/public/../secret.mjs" def test_module_widget_and_page_share_versioned_url(virtual_context, tmp_path: Path, monkeypatch): From a684fd7435fde2d703d348478fa80c53aa6fefb7 Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Sun, 5 Jul 2026 00:41:09 +0200 Subject: [PATCH 6/7] refactor: explicit define_module arguments - Path, code= or url= Exactly one of module (a Path), code= or url= must be passed, matching the ipyvue/ipyreact signatures; nothing is guessed from a plain str. Co-Authored-By: Claude Fable 5 --- solara/server/esm.py | 22 ++++++++++++------- solara/server/esm_vue.py | 17 ++++++++++---- .../content/10-howto/55-vue_esm_components.md | 8 +++---- tests/unit/esm_vue_test.py | 4 ++-- 4 files changed, 33 insertions(+), 18 deletions(-) diff --git a/solara/server/esm.py b/solara/server/esm.py index 7d224ef60..548db817f 100644 --- a/solara/server/esm.py +++ b/solara/server/esm.py @@ -21,19 +21,25 @@ # in solara server, we'll monkey patch ipyreact.module with this -def define_module(name, module: Union[str, Path, None] = None, *, code: Optional[str] = None): - if (module is None) == (code is None): - raise TypeError("pass either module (url or Path) or code") +def define_module(name: str, module: Optional[Path] = None, *, code: Optional[str] = None, url: Optional[str] = None): + 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") + source: Union[str, Path] if code is not None: - module = _CODE_PREFIX + code - assert module is not None - # collect the dependencies at this moment + source = _CODE_PREFIX + code + elif url is not None: + source = url + else: + assert module is not None + source = module with lock: dependencies = list(_modules.keys()) - logger.info("define module %s %s (dependencies=%r)", name, module, dependencies) + logger.info("define module %s (dependencies=%r)", name, dependencies) if name in _modules: old_module, dependencies = _modules[name] - _modules[name] = (module, dependencies) + _modules[name] = (source, dependencies) if isinstance(module, Path): # rebuilding the bundle (e.g. vite/esbuild --watch) triggers a normal # solara reload, which re-reads the file in create_modules diff --git a/solara/server/esm_vue.py b/solara/server/esm_vue.py index 71ed9fc15..7c3ee52f7 100644 --- a/solara/server/esm_vue.py +++ b/solara/server/esm_vue.py @@ -21,10 +21,19 @@ _modules_added_per_kernel: Dict[str, Dict[str, ipyvue.esm.Module]] = defaultdict(dict) -def define_module(name: str, module: Union[str, Path, None] = None, *, code: Optional[str] = None): - if (module is None) == (code is None): - raise TypeError("pass either module (url or Path) or code") - source: Union[str, Path] = _CODE_PREFIX + code if code is not None else module # type: ignore[assignment] +def define_module(name: str, module: Optional[Path] = None, *, code: Optional[str] = None, url: Optional[str] = None): + 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") + source: Union[str, Path] + if code is not None: + source = _CODE_PREFIX + code + elif url is not None: + source = url + else: + assert module is not None + source = module with lock: dependencies = list(_modules.keys()) logger.info("define vue module %s (dependencies=%r)", name, dependencies) diff --git a/solara/website/pages/documentation/advanced/content/10-howto/55-vue_esm_components.md b/solara/website/pages/documentation/advanced/content/10-howto/55-vue_esm_components.md index 72e607d97..80f53870b 100644 --- a/solara/website/pages/documentation/advanced/content/10-howto/55-vue_esm_components.md +++ b/solara/website/pages/documentation/advanced/content/10-howto/55-vue_esm_components.md @@ -27,7 +27,7 @@ import solara ipyvue.define_module( "my-components", - """ + code=""" import { h } from "vue"; export const Counter = { @@ -102,11 +102,11 @@ and every save of a `.vue` file rebuilds the bundle and hot reloads the page in ## Serving the bundle by url (production) -`define_module` also accepts a **url** (a plain `str` always means a url; use `code=` for -inline source). Serve the built bundle from your app's `public/` directory and pass the url: +`define_module` also accepts an explicit **url** keyword (next to `module` for a `Path` and +`code=` for inline source). Serve the built bundle from your app's `public/` directory and pass the url: ```python -ipyvue.define_module("my-components", "/static/public/my-components.mjs") +ipyvue.define_module("my-components", url="/static/public/my-components.mjs") ``` For urls solara serves itself this enables aggressive caching without staleness: diff --git a/tests/unit/esm_vue_test.py b/tests/unit/esm_vue_test.py index f398af582..16e31fd64 100644 --- a/tests/unit/esm_vue_test.py +++ b/tests/unit/esm_vue_test.py @@ -111,7 +111,7 @@ def CounterComponent(count: int = 0): def test_get_module_urls(clean_esm_state, tmp_path: Path): - esm_vue.define_module("esm-vue-url-module", "/static/public/bundle.mjs") + esm_vue.define_module("esm-vue-url-module", url="/static/public/bundle.mjs") module = tmp_path / "bundle.mjs" module.write_text("export default 1") esm_vue.define_module("esm-vue-file-module", module) @@ -152,7 +152,7 @@ def test_module_widget_and_page_share_versioned_url(virtual_context, tmp_path: P monkeypatch.setattr(server, "public_directories", lambda: [public]) server._public_hash_cache.clear() - esm_vue.define_module("esm-vue-versioned", "/static/public/bundle.mjs") + esm_vue.define_module("esm-vue-versioned", url="/static/public/bundle.mjs") widget = esm_vue.create_modules()["esm-vue-versioned"] # the preload hint in the page and the widget must agree, so the # preloaded response is a cache hit From 9222dd31378eb9ca2ad35d0da0815608b4892241 Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Sun, 5 Jul 2026 12:33:04 +0200 Subject: [PATCH 7/7] fix: keep the deprecated plain-str define_module call working (ipyreact) Matches ipyreact's own deprecation path: a plain str was module code in the released api, so it keeps working with a DeprecationWarning instead of a hard error. The ipyvue variant stays strict (never released). Co-Authored-By: Claude Fable 5 --- solara/server/esm.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/solara/server/esm.py b/solara/server/esm.py index 548db817f..b14e23af8 100644 --- a/solara/server/esm.py +++ b/solara/server/esm.py @@ -2,6 +2,7 @@ # in the future, we may want to move the esm features of ipyreact # into a separate package, and then we can import it unconditionally import logging +import warnings import threading from collections import defaultdict from pathlib import Path @@ -21,11 +22,18 @@ # in solara server, we'll monkey patch ipyreact.module with this -def define_module(name: str, module: Optional[Path] = None, *, code: Optional[str] = None, url: Optional[str] = None): +def define_module(name: str, module: Union[str, Path, None] = None, *, code: Optional[str] = None, url: Optional[str] = None): 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") + if isinstance(module, str): + # the pre-0.6 ipyreact API: a plain str was module code + warnings.warn( + "passing module code as a plain str is deprecated, use code=... (or url=... for a url)", + DeprecationWarning, + stacklevel=2, + ) + code = module + module = None source: Union[str, Path] if code is not None: source = _CODE_PREFIX + code