From cf57d8e52789786660f635886ddab7d96461408a Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Sun, 5 Jul 2026 20:40:44 +0200 Subject: [PATCH 1/5] feat: serve component_vue templates from a precompiled ES module bundle @component_vue records every template it is given; `solara vue-bundle app.py` (or module names) imports the application and generates a bundler entry (one export per template) plus a manifest mapping each template's content hash to its export. With SOLARA_VUE_BUNDLES set to one or more manifests, component_vue resolves templates through them (Template(esm_module=, esm_export=), requires ipyvue with ES module support) instead of shipping the source to be compiled in the browser. Lookup is by content hash, so it is machine independent and a template edited after the bundle was built is a hard error (stale bundle) instead of silent drift. Building the bundle (vite, vue external) and defining the module (ipyvue.define_module, Path or url) remain the application's responsibility. Co-Authored-By: Claude Fable 5 --- solara/__main__.py | 28 +++++++++ solara/components/component_vue.py | 13 ++++ solara/components/vue_bundle.py | 98 ++++++++++++++++++++++++++++++ tests/unit/vue_bundle_test.py | 91 +++++++++++++++++++++++++++ 4 files changed, 230 insertions(+) create mode 100644 solara/components/vue_bundle.py create mode 100644 tests/unit/vue_bundle_test.py diff --git a/solara/__main__.py b/solara/__main__.py index d223c0893..d253a3e81 100644 --- a/solara/__main__.py +++ b/solara/__main__.py @@ -761,5 +761,33 @@ def main(): cli(args[1:]) +@cli.command() +@click.argument("app", nargs=-1, required=True) +@click.option("--output", default="vue-components/src", help="Directory for the generated entry and manifest.") +@click.option("--name", default="app-components", help="Bundle (ES module) name.") +def vue_bundle(app: typing.List[str], output: str, name: str): + """Generate a bundler entry + manifest for all component_vue templates. + + APP is one or more python modules (my.app) or scripts (app.py) that are + imported so every @component_vue decorator runs; the generated + -entry.js is then built by your bundler (e.g. vite, with vue + external), and the manifest is consumed at runtime via the + SOLARA_VUE_BUNDLES environment variable. + """ + import importlib + import runpy + from pathlib import Path as _Path + + from solara.components import vue_bundle as _vue_bundle + + for target in app: + if target.endswith(".py"): + runpy.run_path(target) + else: + importlib.import_module(target) + entry = _vue_bundle.write_bundle_entry(_Path(output), name=name) + print(f"wrote {entry} and {entry.parent / (name + '-manifest.json')}") + + if __name__ == "__main__": main() diff --git a/solara/components/component_vue.py b/solara/components/component_vue.py index 06d0bb0bf..8ba2e8db1 100644 --- a/solara/components/component_vue.py +++ b/solara/components/component_vue.py @@ -216,6 +216,19 @@ def Counter(count: int = 0, event_bump: Callable[[int], None] = None): raise RuntimeError("esm_module requires ipyvue with ES module support") def decorator(func: Callable[P, None]): + nonlocal vue_path, esm_module, esm_export + if vue_path is not None: + from pathlib import Path + + from . import vue_bundle + + vue_file = Path(inspect.getfile(func)).parent / vue_path + vue_bundle.record(vue_file) + if vue_bundle.enabled(): + # serve the template from the precompiled bundle instead of + # shipping its source (see solara/components/vue_bundle.py) + esm_module, esm_export = vue_bundle.lookup(vue_file) + vue_path = None 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) diff --git a/solara/components/vue_bundle.py b/solara/components/vue_bundle.py new file mode 100644 index 000000000..730442c91 --- /dev/null +++ b/solara/components/vue_bundle.py @@ -0,0 +1,98 @@ +"""Serve component_vue templates from a precompiled ES module bundle. + +The @component_vue decorator records every .vue file it is given. From that, +`write_bundle_entry` generates a vite/rollup entry (one export per template) +plus a manifest mapping each template's content hash to its export name. + +With SOLARA_VUE_BUNDLES set (comma-separated manifest paths), component_vue +resolves templates through the manifests instead of shipping their source: +the widget uses Template(esm_module=..., esm_export=...) (requires ipyvue +with ES module support). A template whose current content hash is not in any +manifest is a hard error - a changed file means the bundle is stale. + +Building the bundle (running vite) and defining the module +(ipyvue.define_module with a Path or url) remain the application's +responsibility; solara only generates text and checks it. +""" + +import hashlib +import json +import os +import re +from pathlib import Path +from typing import Dict, List, Optional, Tuple + +# every .vue file passed to @component_vue, in import order +_vue_files: List[Path] = [] + +_manifests: Optional[Dict[str, Tuple[str, str]]] = None # sha1 -> (module, export) +_manifest_names: Dict[str, List[str]] = {} # basename -> manifest names, for errors + + +def _sha1(path: Path) -> str: + return hashlib.sha1(path.read_bytes()).hexdigest() # noqa: S324 - content id, not security + + +def record(vue_file: Path) -> None: + vue_file = vue_file.resolve() + if vue_file not in _vue_files: + _vue_files.append(vue_file) + + +def _load_manifests() -> Dict[str, Tuple[str, str]]: + global _manifests + if _manifests is None: + _manifests = {} + for manifest_path in os.environ.get("SOLARA_VUE_BUNDLES", "").split(","): + if not manifest_path.strip(): + continue + manifest = json.loads(Path(manifest_path.strip()).read_text(encoding="utf8")) + for file, entry in manifest["components"].items(): + _manifests[entry["sha1"]] = (manifest["name"], entry["export"]) + _manifest_names.setdefault(Path(file).name, []).append(manifest["name"]) + return _manifests + + +def enabled() -> bool: + return bool(os.environ.get("SOLARA_VUE_BUNDLES")) + + +def lookup(vue_file: Path) -> Tuple[str, str]: + """Resolve a template to (esm_module, esm_export), or raise if the bundle + does not contain the template's current content.""" + manifests = _load_manifests() + entry = manifests.get(_sha1(vue_file)) + if entry is not None: + return entry + if vue_file.name in _manifest_names: + raise RuntimeError(f"{vue_file} changed since bundle {_manifest_names[vue_file.name]} was built, rebuild it (see write_bundle_entry)") + raise RuntimeError(f"{vue_file} is not in any bundle listed in SOLARA_VUE_BUNDLES") + + +def _export_name(vue_file: Path) -> str: + # stem for readability, content hash for uniqueness and machine + # independence (the collector also picks up templates from libraries, + # e.g. solara's own, so there is no meaningful common root) + stem = re.sub(r"[^a-zA-Z0-9]", "_", vue_file.stem) + return f"c_{stem}_{_sha1(vue_file)[:8]}" + + +def write_bundle_entry(directory: Path, name: str = "app-components") -> Path: + """Write -entry.js and -manifest.json for every component_vue + template imported so far; returns the entry path. Import the application + first, then call this, then run the bundler on the entry.""" + directory = Path(directory) + directory.mkdir(parents=True, exist_ok=True) + if not _vue_files: + raise RuntimeError("no component_vue templates recorded; import the application first") + lines = [] + components = {} + for vue_file in _vue_files: + export = _export_name(vue_file) + relative = os.path.relpath(vue_file, directory) + lines.append(f'export {{ default as {export} }} from "{relative}";') + components[str(vue_file)] = {"export": export, "sha1": _sha1(vue_file)} + entry = directory / f"{name}-entry.js" + entry.write_text("// generated by solara.components.vue_bundle.write_bundle_entry - do not edit\n" + "\n".join(lines) + "\n", encoding="utf8") + (directory / f"{name}-manifest.json").write_text(json.dumps({"name": name, "components": components}, indent=2) + "\n", encoding="utf8") + return entry diff --git a/tests/unit/vue_bundle_test.py b/tests/unit/vue_bundle_test.py new file mode 100644 index 000000000..b6783235b --- /dev/null +++ b/tests/unit/vue_bundle_test.py @@ -0,0 +1,91 @@ +import json +from pathlib import Path + +import pytest + +from solara.components import vue_bundle + +ipyvue = pytest.importorskip("ipyvue") + + +@pytest.fixture() +def clean_state(monkeypatch): + files = list(vue_bundle._vue_files) + vue_bundle._vue_files.clear() + vue_bundle._manifests = None + vue_bundle._manifest_names.clear() + monkeypatch.delenv("SOLARA_VUE_BUNDLES", raising=False) + try: + yield + finally: + vue_bundle._vue_files.clear() + vue_bundle._vue_files.extend(files) + vue_bundle._manifests = None + vue_bundle._manifest_names.clear() + + +def _make_template(tmp_path: Path, name: str, source: str) -> Path: + file = tmp_path / "app" / "components" / name + file.parent.mkdir(parents=True, exist_ok=True) + file.write_text(source) + return file + + +def test_write_entry_and_manifest(clean_state, tmp_path: Path): + a = _make_template(tmp_path, "a.vue", "") + b = _make_template(tmp_path, "b.vue", "") + vue_bundle.record(a) + vue_bundle.record(b) + + entry = vue_bundle.write_bundle_entry(tmp_path / "bundle", name="test-components") + lines = entry.read_text().splitlines() + assert any("export { default as c_a_" in line and 'from "../app/components/a.vue";' in line for line in lines) + manifest = json.loads((tmp_path / "bundle" / "test-components-manifest.json").read_text()) + assert manifest["name"] == "test-components" + entry_a = next(v for k, v in manifest["components"].items() if k.endswith("a.vue")) + assert entry_a["export"].startswith("c_a_") + assert entry_a["sha1"] == vue_bundle._sha1(a) + + +def test_lookup_by_content_hash(clean_state, tmp_path: Path, monkeypatch): + a = _make_template(tmp_path, "a.vue", "") + vue_bundle.record(a) + vue_bundle.write_bundle_entry(tmp_path / "bundle", name="test-components") + monkeypatch.setenv("SOLARA_VUE_BUNDLES", str(tmp_path / "bundle" / "test-components-manifest.json")) + vue_bundle._manifests = None + + assert vue_bundle.enabled() + assert vue_bundle.lookup(a)[0] == "test-components" and vue_bundle.lookup(a)[1].startswith("c_a_") + + # stale: content changed after the bundle was generated + a.write_text("") + with pytest.raises(RuntimeError, match="changed since bundle"): + vue_bundle.lookup(a) + + # missing: never bundled + c = _make_template(tmp_path, "c.vue", "") + with pytest.raises(RuntimeError, match="not in any bundle"): + vue_bundle.lookup(c) + + +@pytest.mark.skipif(not hasattr(ipyvue, "define_module"), reason="needs ipyvue with ES module support") +def test_component_vue_uses_bundle(clean_state, tmp_path: Path, monkeypatch): + a = _make_template(tmp_path, "a.vue", "") + vue_bundle.record(a) + vue_bundle.write_bundle_entry(tmp_path / "bundle", name="test-components") + monkeypatch.setenv("SOLARA_VUE_BUNDLES", str(tmp_path / "bundle" / "test-components-manifest.json")) + vue_bundle._manifests = None + + from solara.components.component_vue import _widget_vue + + # simulate what @component_vue does for a template resolved via the bundle + module, export = vue_bundle.lookup(a) + assert module == "test-components" and export.startswith("c_a_") + + @_widget_vue(None, esm_module=module, esm_export=export) + def Widget(value: int = 0): + pass + + widget = Widget(value=3) + assert widget.template.esm_module == "test-components" + assert widget.template.esm_export == export From 822a7e3a7e1877a0514e01e8195d4fb4100a30d4 Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Sun, 5 Jul 2026 20:50:15 +0200 Subject: [PATCH 2/5] feat: named exports and docs for vue-bundle mode Each generated export wraps the component with its file-stem name (an explicit SFC name wins), keeping vue devtools and warnings readable. Howto gains the full vite workflow incl. sourcemap guidance: external map for url-served bundles, inline when file-backed (blob urls cannot resolve a relative sourceMappingURL). Co-Authored-By: Claude Fable 5 --- solara/components/vue_bundle.py | 9 +++- .../content/10-howto/55-vue_esm_components.md | 44 +++++++++++++++++++ tests/unit/vue_bundle_test.py | 3 +- 3 files changed, 53 insertions(+), 3 deletions(-) diff --git a/solara/components/vue_bundle.py b/solara/components/vue_bundle.py index 730442c91..7d42b518f 100644 --- a/solara/components/vue_bundle.py +++ b/solara/components/vue_bundle.py @@ -86,12 +86,17 @@ def write_bundle_entry(directory: Path, name: str = "app-components") -> Path: if not _vue_files: raise RuntimeError("no component_vue templates recorded; import the application first") lines = [] + exports = [] components = {} - for vue_file in _vue_files: + for i, vue_file in enumerate(_vue_files): export = _export_name(vue_file) relative = os.path.relpath(vue_file, directory) - lines.append(f'export {{ default as {export} }} from "{relative}";') + lines.append(f'import _c{i} from "{relative}";') + # give the component a devtools/debugging name; an explicit name in + # the SFC wins (spread comes after) + exports.append(f'export const {export} = {{ name: "{vue_file.stem}", ..._c{i} }};') components[str(vue_file)] = {"export": export, "sha1": _sha1(vue_file)} + lines += [""] + exports entry = directory / f"{name}-entry.js" entry.write_text("// generated by solara.components.vue_bundle.write_bundle_entry - do not edit\n" + "\n".join(lines) + "\n", encoding="utf8") (directory / f"{name}-manifest.json").write_text(json.dumps({"name": name, "components": components}, indent=2) + "\n", encoding="utf8") 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 80f53870b..c0b9c4614 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 @@ -120,6 +120,50 @@ For urls solara serves itself this enables aggressive caching without staleness: 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. +## Bundling all component_vue templates + +`@solara.component_vue` records every template it is given, so solara can generate the +bundler input for the whole application: + +```bash +solara vue-bundle app.py --output vue-components/src --name app-components +# writes app-components-entry.js (one named export per template) +# and app-components-manifest.json (content hash -> export) +``` + +Build the entry with vite (`vue` external — ipyvue provides it via the import map): + +```js +// vue-components/vite.config.mjs +import { defineConfig } from "vite"; +import vue from "@vitejs/plugin-vue"; // or @vitejs/plugin-vue2 on the vue2 stack + +export default defineConfig({ + plugins: [vue()], + build: { + lib: { entry: "src/app-components-entry.js", formats: ["es"], fileName: () => "app-components.mjs" }, + rollupOptions: { external: ["vue"] }, + // debugging: external map next to the bundle for url-served production + // bundles; use "inline" when serving the module file-backed (blob urls + // cannot resolve a relative sourceMappingURL) + sourcemap: true, + }, +}); +``` + +Then define the module (as above) and turn the mode on: + +```bash +SOLARA_VUE_BUNDLES=vue-components/src/app-components-manifest.json solara run app.py +``` + +With the variable set, `component_vue` resolves every template through the manifests +instead of shipping its source: lookup is by content hash, so a template edited after +the bundle was built is a hard error (stale bundle) rather than silent drift. Unset, +everything behaves as before — that is the development mode, with per-template hot +reload. Each generated export carries the template's file name as the component +`name`, so vue devtools and warnings stay readable. + ## 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/unit/vue_bundle_test.py b/tests/unit/vue_bundle_test.py index b6783235b..fed99c98c 100644 --- a/tests/unit/vue_bundle_test.py +++ b/tests/unit/vue_bundle_test.py @@ -39,7 +39,8 @@ def test_write_entry_and_manifest(clean_state, tmp_path: Path): entry = vue_bundle.write_bundle_entry(tmp_path / "bundle", name="test-components") lines = entry.read_text().splitlines() - assert any("export { default as c_a_" in line and 'from "../app/components/a.vue";' in line for line in lines) + assert any('from "../app/components/a.vue";' in line for line in lines) + assert any(line.startswith("export const c_a_") and 'name: "a"' in line for line in lines) manifest = json.loads((tmp_path / "bundle" / "test-components-manifest.json").read_text()) assert manifest["name"] == "test-components" entry_a = next(v for k, v in manifest["components"].items() if k.endswith("a.vue")) From cee28c44e7659316c73d5a5ac94af420beb2e834 Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Mon, 6 Jul 2026 09:11:04 +0200 Subject: [PATCH 3/5] fix: forward slashes in generated entry imports on windows os.path.relpath emits backslashes there, which are not valid JS import paths (and failed the unit test on the windows runners). Co-Authored-By: Claude Fable 5 --- solara/components/vue_bundle.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/solara/components/vue_bundle.py b/solara/components/vue_bundle.py index 7d42b518f..a846602ca 100644 --- a/solara/components/vue_bundle.py +++ b/solara/components/vue_bundle.py @@ -18,6 +18,7 @@ import hashlib import json import os +import pathlib import re from pathlib import Path from typing import Dict, List, Optional, Tuple @@ -90,7 +91,7 @@ def write_bundle_entry(directory: Path, name: str = "app-components") -> Path: components = {} for i, vue_file in enumerate(_vue_files): export = _export_name(vue_file) - relative = os.path.relpath(vue_file, directory) + relative = pathlib.PurePath(os.path.relpath(vue_file, directory)).as_posix() lines.append(f'import _c{i} from "{relative}";') # give the component a devtools/debugging name; an explicit name in # the SFC wins (spread comes after) From a288e59da6c775d5397b877aef52ea98bed4f76f Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Mon, 6 Jul 2026 09:59:38 +0200 Subject: [PATCH 4/5] feat: name the searched bundles in the missing-template error Co-Authored-By: Claude Fable 5 --- solara/components/vue_bundle.py | 6 +++++- tests/unit/vue_bundle_test.py | 4 +++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/solara/components/vue_bundle.py b/solara/components/vue_bundle.py index a846602ca..7be85ff4b 100644 --- a/solara/components/vue_bundle.py +++ b/solara/components/vue_bundle.py @@ -28,6 +28,7 @@ _manifests: Optional[Dict[str, Tuple[str, str]]] = None # sha1 -> (module, export) _manifest_names: Dict[str, List[str]] = {} # basename -> manifest names, for errors +_loaded_bundles: List[str] = [] # manifest names, for errors def _sha1(path: Path) -> str: @@ -48,6 +49,7 @@ def _load_manifests() -> Dict[str, Tuple[str, str]]: if not manifest_path.strip(): continue manifest = json.loads(Path(manifest_path.strip()).read_text(encoding="utf8")) + _loaded_bundles.append(manifest["name"]) for file, entry in manifest["components"].items(): _manifests[entry["sha1"]] = (manifest["name"], entry["export"]) _manifest_names.setdefault(Path(file).name, []).append(manifest["name"]) @@ -67,7 +69,9 @@ def lookup(vue_file: Path) -> Tuple[str, str]: return entry if vue_file.name in _manifest_names: raise RuntimeError(f"{vue_file} changed since bundle {_manifest_names[vue_file.name]} was built, rebuild it (see write_bundle_entry)") - raise RuntimeError(f"{vue_file} is not in any bundle listed in SOLARA_VUE_BUNDLES") + raise RuntimeError( + f"{vue_file} is not in any of the bundles {_loaded_bundles} (from SOLARA_VUE_BUNDLES); regenerate and rebuild the bundle that should contain it" + ) def _export_name(vue_file: Path) -> str: diff --git a/tests/unit/vue_bundle_test.py b/tests/unit/vue_bundle_test.py index fed99c98c..f47839da0 100644 --- a/tests/unit/vue_bundle_test.py +++ b/tests/unit/vue_bundle_test.py @@ -14,6 +14,7 @@ def clean_state(monkeypatch): vue_bundle._vue_files.clear() vue_bundle._manifests = None vue_bundle._manifest_names.clear() + vue_bundle._loaded_bundles.clear() monkeypatch.delenv("SOLARA_VUE_BUNDLES", raising=False) try: yield @@ -22,6 +23,7 @@ def clean_state(monkeypatch): vue_bundle._vue_files.extend(files) vue_bundle._manifests = None vue_bundle._manifest_names.clear() + vue_bundle._loaded_bundles.clear() def _make_template(tmp_path: Path, name: str, source: str) -> Path: @@ -65,7 +67,7 @@ def test_lookup_by_content_hash(clean_state, tmp_path: Path, monkeypatch): # missing: never bundled c = _make_template(tmp_path, "c.vue", "") - with pytest.raises(RuntimeError, match="not in any bundle"): + with pytest.raises(RuntimeError, match=r"not in any of the bundles.*test-components"): vue_bundle.lookup(c) From 84185751ac3ed29a437a818831a629f6aa090d06 Mon Sep 17 00:00:00 2001 From: Maarten Breddels Date: Mon, 6 Jul 2026 10:39:20 +0200 Subject: [PATCH 5/5] feat: export bundle components under their python component name Menu instead of c_menu_64b3100a: readable in the entry, the bundle, vue devtools and stack traces. The content hash in the name was only for uniqueness (the manifest sha1 handles staleness), so it is now only a fallback suffix for a within-bundle function-name collision (warned). Co-Authored-By: Claude Fable 5 --- solara/components/component_vue.py | 2 +- solara/components/vue_bundle.py | 38 ++++++++++++++++++++---------- tests/unit/vue_bundle_test.py | 20 ++++++++-------- 3 files changed, 37 insertions(+), 23 deletions(-) diff --git a/solara/components/component_vue.py b/solara/components/component_vue.py index 8ba2e8db1..0165ecd31 100644 --- a/solara/components/component_vue.py +++ b/solara/components/component_vue.py @@ -223,7 +223,7 @@ def decorator(func: Callable[P, None]): from . import vue_bundle vue_file = Path(inspect.getfile(func)).parent / vue_path - vue_bundle.record(vue_file) + vue_bundle.record(vue_file, func.__name__) if vue_bundle.enabled(): # serve the template from the precompiled bundle instead of # shipping its source (see solara/components/vue_bundle.py) diff --git a/solara/components/vue_bundle.py b/solara/components/vue_bundle.py index 7be85ff4b..dd24daa6b 100644 --- a/solara/components/vue_bundle.py +++ b/solara/components/vue_bundle.py @@ -17,14 +17,16 @@ import hashlib import json +import logging import os import pathlib import re from pathlib import Path from typing import Dict, List, Optional, Tuple -# every .vue file passed to @component_vue, in import order -_vue_files: List[Path] = [] +# every .vue file passed to @component_vue, in import order, with the +# decorated function's name (used as the export name) +_vue_files: Dict[Path, str] = {} _manifests: Optional[Dict[str, Tuple[str, str]]] = None # sha1 -> (module, export) _manifest_names: Dict[str, List[str]] = {} # basename -> manifest names, for errors @@ -35,10 +37,10 @@ def _sha1(path: Path) -> str: return hashlib.sha1(path.read_bytes()).hexdigest() # noqa: S324 - content id, not security -def record(vue_file: Path) -> None: +def record(vue_file: Path, component_name: str) -> None: vue_file = vue_file.resolve() if vue_file not in _vue_files: - _vue_files.append(vue_file) + _vue_files[vue_file] = component_name def _load_manifests() -> Dict[str, Tuple[str, str]]: @@ -74,12 +76,23 @@ def lookup(vue_file: Path) -> Tuple[str, str]: ) -def _export_name(vue_file: Path) -> str: - # stem for readability, content hash for uniqueness and machine - # independence (the collector also picks up templates from libraries, - # e.g. solara's own, so there is no meaningful common root) - stem = re.sub(r"[^a-zA-Z0-9]", "_", vue_file.stem) - return f"c_{stem}_{_sha1(vue_file)[:8]}" +def _export_names() -> Dict[Path, str]: + # the python component name, readable in the entry, the bundle and vue + # devtools; only a within-bundle name collision (two components with the + # same function name) gets a content-hash suffix to stay unique + names: Dict[Path, str] = {} + seen: Dict[str, Path] = {} + for vue_file, component_name in _vue_files.items(): + name = re.sub(r"[^a-zA-Z0-9]", "_", component_name) + if name in seen: + logging.getLogger("solara").warning( + "duplicate component name %s (%s and %s); disambiguating with a content-hash suffix", name, seen[name], vue_file + ) + name = f"{name}_{_sha1(vue_file)[:8]}" + else: + seen[name] = vue_file + names[vue_file] = name + return names def write_bundle_entry(directory: Path, name: str = "app-components") -> Path: @@ -93,13 +106,14 @@ def write_bundle_entry(directory: Path, name: str = "app-components") -> Path: lines = [] exports = [] components = {} + export_names = _export_names() for i, vue_file in enumerate(_vue_files): - export = _export_name(vue_file) + export = export_names[vue_file] relative = pathlib.PurePath(os.path.relpath(vue_file, directory)).as_posix() lines.append(f'import _c{i} from "{relative}";') # give the component a devtools/debugging name; an explicit name in # the SFC wins (spread comes after) - exports.append(f'export const {export} = {{ name: "{vue_file.stem}", ..._c{i} }};') + exports.append(f'export const {export} = {{ name: "{export}", ..._c{i} }};') components[str(vue_file)] = {"export": export, "sha1": _sha1(vue_file)} lines += [""] + exports entry = directory / f"{name}-entry.js" diff --git a/tests/unit/vue_bundle_test.py b/tests/unit/vue_bundle_test.py index f47839da0..f50be63cf 100644 --- a/tests/unit/vue_bundle_test.py +++ b/tests/unit/vue_bundle_test.py @@ -10,7 +10,7 @@ @pytest.fixture() def clean_state(monkeypatch): - files = list(vue_bundle._vue_files) + files = dict(vue_bundle._vue_files) vue_bundle._vue_files.clear() vue_bundle._manifests = None vue_bundle._manifest_names.clear() @@ -20,7 +20,7 @@ def clean_state(monkeypatch): yield finally: vue_bundle._vue_files.clear() - vue_bundle._vue_files.extend(files) + vue_bundle._vue_files.update(files) vue_bundle._manifests = None vue_bundle._manifest_names.clear() vue_bundle._loaded_bundles.clear() @@ -36,29 +36,29 @@ def _make_template(tmp_path: Path, name: str, source: str) -> Path: def test_write_entry_and_manifest(clean_state, tmp_path: Path): a = _make_template(tmp_path, "a.vue", "") b = _make_template(tmp_path, "b.vue", "") - vue_bundle.record(a) - vue_bundle.record(b) + vue_bundle.record(a, "CompA") + vue_bundle.record(b, "CompB") entry = vue_bundle.write_bundle_entry(tmp_path / "bundle", name="test-components") lines = entry.read_text().splitlines() assert any('from "../app/components/a.vue";' in line for line in lines) - assert any(line.startswith("export const c_a_") and 'name: "a"' in line for line in lines) + assert any(line.startswith("export const CompA = ") and 'name: "CompA"' in line for line in lines) manifest = json.loads((tmp_path / "bundle" / "test-components-manifest.json").read_text()) assert manifest["name"] == "test-components" entry_a = next(v for k, v in manifest["components"].items() if k.endswith("a.vue")) - assert entry_a["export"].startswith("c_a_") + assert entry_a["export"] == "CompA" assert entry_a["sha1"] == vue_bundle._sha1(a) def test_lookup_by_content_hash(clean_state, tmp_path: Path, monkeypatch): a = _make_template(tmp_path, "a.vue", "") - vue_bundle.record(a) + vue_bundle.record(a, "CompA") vue_bundle.write_bundle_entry(tmp_path / "bundle", name="test-components") monkeypatch.setenv("SOLARA_VUE_BUNDLES", str(tmp_path / "bundle" / "test-components-manifest.json")) vue_bundle._manifests = None assert vue_bundle.enabled() - assert vue_bundle.lookup(a)[0] == "test-components" and vue_bundle.lookup(a)[1].startswith("c_a_") + assert vue_bundle.lookup(a) == ("test-components", "CompA") # stale: content changed after the bundle was generated a.write_text("") @@ -74,7 +74,7 @@ def test_lookup_by_content_hash(clean_state, tmp_path: Path, monkeypatch): @pytest.mark.skipif(not hasattr(ipyvue, "define_module"), reason="needs ipyvue with ES module support") def test_component_vue_uses_bundle(clean_state, tmp_path: Path, monkeypatch): a = _make_template(tmp_path, "a.vue", "") - vue_bundle.record(a) + vue_bundle.record(a, "CompA") vue_bundle.write_bundle_entry(tmp_path / "bundle", name="test-components") monkeypatch.setenv("SOLARA_VUE_BUNDLES", str(tmp_path / "bundle" / "test-components-manifest.json")) vue_bundle._manifests = None @@ -83,7 +83,7 @@ def test_component_vue_uses_bundle(clean_state, tmp_path: Path, monkeypatch): # simulate what @component_vue does for a template resolved via the bundle module, export = vue_bundle.lookup(a) - assert module == "test-components" and export.startswith("c_a_") + assert (module, export) == ("test-components", "CompA") @_widget_vue(None, esm_module=module, esm_export=export) def Widget(value: int = 0):