Repository navigation
feat: serve an app's vue templates and components from one prebuilt bundle - #1179
Draft
maartenbreddels wants to merge 21 commits into
Draft
maartenbreddels wants to merge 21 commits into
maartenbreddels wants to merge 21 commits into
Conversation
3 of 4 tasks
@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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
The first version of bundle mode broke in practice. Templates that use
`module.exports = {...}` stayed CommonJS in the vite output, so the bundle
threw at load. A missing or stale manifest entry raised at import time, so
one edited template or a lazy import took the app down. The app also had to
define the module itself, under a name that had to match the build.
Now `solara vue-bundle app.py -o vue/entry.js` writes an entry with one
export per app template, named `<Component>_<key>`, where the key hashes the
path and content. The server reads those names from the built .mjs set in
SOLARA_VUE_BUNDLE, defines the module itself, and picks bundle or source per
widget. A template that is not in the bundle falls back to its source with a
warning, so a stale bundle degrades instead of failing. `--vite-config`
writes a starter config that rewrites `module.exports` and injects CSS, and
`--check` lets CI catch a stale bundle. Urls under /static/public now get the
deployment root path.
Validation: unit tests in tests/unit/vue_bundle_test.py and esm_vue_test.py
pass. A vite 5 + plugin-vue2 build of solara's menu.vue and a styled
template loads in node with vue stubbed, and renders and styles correctly
under `solara run` in Chrome.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The starter config imported plugin-vue2 always, which compiles templates for vue 2; with ipyvue 3 the bundle would not run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review found several ways for bundle mode to fail silently or loudly. The starter config rewrote a `module.exports` mentioned in a comment and left the real one, so the whole bundle threw at load. The export parser accepted an export list inside a trailing comment. The CLI imported only the package of a package app, missed its pages, and `--check` then passed while it checked nothing. A namespace package or any other load error stopped the server, although bundle mode must always be able to fall back to source. The rewrite now only touches an assignment at the start of a line. The parser walks back from the end over real export statements only. The CLI collects routes the same way AppScript does, accepts the `app:Page` form, looks modules up with cwd on sys.path, and `--check` fails when it finds no templates. A load error now logs a warning and keeps source mode. The starter config sets `root`, so `vite build -c vue/vite.config.mjs` works from the project root. Validation: tests/unit/vue_bundle_test.py (11) and esm_vue_test.py (12) pass. A vite build from the project root of a template with a commented `module.exports` loads in node, and `--check` finds all 3 templates. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
All three reviewers still broke both text heuristics with comments. A `module.exports =` inside a block comment or a multiline template string got rewritten and the real assignment stayed, so the bundle threw at load. Export-like text in a comment or string could also add or hide export names. Each new regex only moved the edge case. The starter vite plugin now parses the script body with rollup's parser and rewrites only the top-level `module.exports =` statement. For the exports, the same plugin writes rollup's own list of entry exports on a marker line at the end of the bundle, after minification and before the sourceMappingURL line, so source maps stay valid. Solara reads only that line. A bundle without it was not built with the plugin, so the server keeps source mode with a warning and `--check` fails. Relative paths across Windows drives now give a clear error instead of a ValueError. Validation: tests/unit/vue_bundle_test.py (12) and esm_vue_test.py (12) pass. vite builds with a block comment and a template string that mention `module.exports =` load in node, plain, fully minified and with source maps, and `--check` finds all 4 templates. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reviewers found three more inputs that broke the bundle or its lookup. With parentheses around the value or the whole assignment, the partial rewrite left unbalanced parentheses, so the template failed to build. A `<script>` inside an html comment before the real one got rewritten instead of the real one. A plugin that adds lines after ours could push the export marker out of the 5-line window, so solara ignored a good bundle. The plugin now replaces the whole top-level statement with `export default (<value>);`, and finds the script in a copy of the file with html comments blanked out, at the same offsets. Solara reads the last marker line in the file: the plugin writes it after all code, so a marker-like line in user code always comes earlier. Validation: tests/unit/vue_bundle_test.py passes (12). A vite build with both parenthesized forms and an html-commented script loads in node with each component's methods and render function, and `--check` finds all 7 templates. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The rewrite looked for the first `=` after `module.exports`, which can sit
inside a comment such as `module.exports /* = x */ = {}`; the build then
failed. The parser already knows where the value starts and ends.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
maartenbreddels
force-pushed
the
feat/vue-bundle-mode
branch
from
October 23, 2026 15:43
8418575 to
39f4fae
Compare
rich wraps the CLI output at the terminal width, so in CI a long temporary path pushed a line break into the middle of the message the test looked for. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The export marker line had to survive every later plugin and minifier, and each review round found another way to move, fake or hide it. A separate file avoids all of that: the starter vite plugin now emits app.mjs.json with rollup's list of entry exports, in the same build and the same out dir, so it cannot drift from app.mjs. SOLARA_VUE_BUNDLE still names the .mjs; solara reads the .json next to it. Without a valid .json, the server keeps source mode with a warning that points to `solara vue-bundle --vite-config`, and `--check` fails with the same message. The docs say to deploy both files together. Validation: tests/unit/vue_bundle_test.py passes (12). A vite build in /tmp/vb-check writes app.mjs.json with all 8 export names, also with sourcemap on. The bundle loads in node, and `--check` exits 0, or 1 when the .json is missing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Apps often register global components at render time, for example inside a component, so importing the app could not find all templates. Templates that plain VueTemplate widgets load through template_file were also never served from the bundle, because only component_vue chose bundle or source. `solara vue-bundle` now uses APP only to find the app root and the public dir. It bundles every .vue file under the root, without node_modules and hidden dirs, and names each export after the file. The bundle-or-source choice moves into solara's patched ipyvue get_template, so component_vue and template_file widgets share one lookup point, and component_vue is back to master. With an ipyvue that has register_component_from_esm, solara also wraps register_component_from_file: a file in the bundle registers the bundle export, other files keep the source path and its hot reload bookkeeping. `--check` compares every .vue file under the root. Validation: tests/unit/vue_bundle_test.py (11, in two runs), esm_vue_test.py (12) and component_frontend_test.py (6) pass, with ipyvue from the vue-component-esm branch. A vite build in /tmp/vb-check of the 8 .vue files loads in node, and `--check` exits 0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Running a large app (201 templates) in bundle mode showed two breaks with the starter vite config. css-injected-by-js put the CSS of every template into the page when the bundle loaded, so global rules of templates a page never uses changed that page; source mode only inserts a template's style, once per id, when the template is first used. And plugin-vue turned `<img src="/static/...">` into imports, which failed the build, while ipyvue never rewrites these urls. The solara plugin now takes global css `<style>` blocks out of each template and wraps the default export with a mixin that inserts the style once, by its id, in beforeCreate. This works for `module.exports`, an existing `export default`, and a template without a script. Scoped and lang styles stay with css-injected-by-js, because they cannot leak. plugin-vue runs with transformAssetUrls off. The docs smoke-load snippet stubs the browser globals that templates touch at load. Validation: tests/unit/vue_bundle_test.py passes (11, in two runs). In /tmp/vb-check, a build of 12 templates inserts only the scoped style at import; creating the `<style id="x">` template twice inserts style "x" once. `--check` exits 0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A large app still looked different in bundle mode, because the plugin did not follow ipyvue's style rules. ipyvue uses only the first <style> block, names its element `ipyvue-<id>`, and treats `<style scoped>` as a global style unless scoped css support is on. The bundle used every block, other element ids, and scoped CSS from plugin-vue, so some rules applied that source mode ignores and some ids did not match. The plugin now uses only the first block, removes the others, and inserts the style under ipyvue's element id, updating it when the css differs. A block without an id gets one element per file, which the docs mention, because source mode adds one per view. `--vite-config` writes ipyvue.scoped_css_support into the config; when it is off, a scoped block is a global style, as in source mode. The docs smoke snippet uses a permissive stub, so templates that touch window or document at load pass. Validation: tests/unit/vue_bundle_test.py passes (11, in two runs). In /tmp/vb-check nothing is inserted at import; creating the components inserts `ipyvue-x`, only the first of two blocks as `ipyvue-two`, and a scoped block without id as a global style, each once. `--check` exits 0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
plugin-vue2 condenses whitespace between elements, while vue 2's in-browser compiler, which serves templates in source mode, keeps it. In one app's page a space between a quote and a badge disappeared, so badges moved by 2px. Vue 3's compiler condenses by default, so it stays as is. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A venv inside the app root put every installed library template in the bundle. One of them, solara's algolia_api.vue, throws at load, and because the bundle is one module, every component of the app went blank. Test fixtures and other files that should never ship hit the same problem. The collector now skips __pycache__, site-packages, dist-packages and any dir with a pyvenv.cfg, next to node_modules and hidden dirs. A repeatable `--exclude GLOB`, matched against the path relative to the app root, skips more; the entry and `--check` use the same set, and the server serves excluded files from source. The vue `name` of each export is now the export name: the file stem alone can equal a global component tag and make a template render itself. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The plugin found <script> and <style> blocks with regexes over the whole
file, so a <style> or <script> nested inside <template> counted as a
block, and an unquoted `<style id=shared>` lost its id. ipyvue reads only
the top-level blocks with vue's SFC parser, so the bundle applied styles
that source mode ignores. Other review findings also broke the bundle or
diverged from source mode:
- a bare-object script (`<script>{ data() {...} }</script>`), which ipyvue
still accepts through its fallback, failed to build;
- an empty or export-less script with a global style inserted the CSS at
bundle import instead of on first use;
- `content:"</script>"` in CSS ended the generated <script> and failed the
build;
- the fallback style id hashed the absolute path, so local and docker
builds named the same element differently.
The plugin now uses `parseComponent` from vue 2.7 or `parse` from
@vue/compiler-sfc, picked with the plugin, and takes the style id from the
parsed attrs. When the script does not parse, it tries ipyvue's fallback,
the object from the first `{`. A script without a default export gets
`export default {}` (ipyvue uses `{}` too), wrapped with the style. Embedded
text escapes `<`, and the fallback id hashes the path relative to the
config. The docs list the excludes, why one file that throws at load
blanks the whole bundle, the early-import case of
register_component_from_file, and the ignored `css` trait.
Validation: tests/unit/vue_bundle_test.py passes (11, in two runs). In
/tmp/vb-check, 18 templates build: the venv, pyvenv.cfg and
`--exclude 'tests/**'` files are skipped; nothing is inserted at import;
only the top-level style of a template with a nested <style> applies; the
bare-object script keeps its data and methods; the empty-script style,
`content:"</script>"` and `id=shared` behave as in source mode. The docs
smoke snippet loads all exports, and `--check` exits 0.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ollector
Review round 2 found a few remaining gaps. Vue's SFC parser reports end 0
for a self-closing `<style/>` or `<script/>`, so blanking such a block cut
unrelated markup and failed the build, and `<script/>` with a global style
lost the CSS. The server lookup did not skip venvs and site-packages as the
collector does, so library templates in a venv under the app root logged a
misleading "not in the vue bundle" warning. `--exclude ./tests/**` and
`**/x.vue` did not match what users expect. And a `module.exports` below
the top level (inside an `if` or an IIFE) silently exported `{}`.
The plugin now never blanks a self-closing block, and treats a
self-closing script as no script: its tag goes and a new script carries
the style. A self-closing first style means no style, as in ipyvue. The
collector and lookup share one dir rule, so such files miss silently.
Excludes drop a leading `./`, and `**/` also matches at the top level; the
help text and docs say how globs match. A nested `module.exports` now
fails the build with a clear message.
Validation: tests/unit/vue_bundle_test.py passes (11, in two runs). In
/tmp/vb-check, `<script/>` with `<style id="selfshared">` inserts its
style on first use, `<style/>` before a second style keeps the markup and
inserts nothing, `if (true) module.exports = ...` fails the build with
the message, and the 20-template build passes `--check`.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The fix for self-closing blocks covered the module.exports path, but a script with an ESM default export still blanked them and corrupted the file, so the build failed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
maartenbreddels
force-pushed
the
feat/vue-bundle-mode
branch
from
October 23, 2026 20:06
50a8b32 to
b8ffe83
Compare
Contributor
Author
|
Parking this PR. We measured the bundle mode thoroughly on a large production app (201 What the best variant gave against the current release: one minified bundle, preloaded, imported once the websocket connects, and served precompressed by the proxy.
What we learned, in case someone picks this up again:
The root-path bug in |
This branch had an error being deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Solara apps can now serve all their
.vuetemplates and registered components from one bundle that vite builds ahead of time, instead of sending each template's source to the browser to compile there.Problem
A solara app sends the source of every vue template to the browser, once per kernel, and the browser compiles each one at runtime. For a large app, with a few hundred
.vuefiles, that costs bandwidth and start time, and template errors show up only at runtime. Until now, an app that wanted a build step had to write its own vite entry, name every module and export in Python, and keep the two in sync by hand. Components registered withipyvue.register_component_from_filecould not come from a bundle at all.Change
An app turns this on with three commands, and its code does not change:
Collecting:
solara vue-bundledoes not import the app. It bundles every.vuefile under the app's folder. It skipsnode_modules, hidden folders, virtual environments and installed packages, plus anything matched by--exclude. Each export is named<file stem>_<key>, where the key hashes the file's path inside the app and its content, so an edited file gets a new key.Building:
--vite-configwrites a starter config that follows ipyvue's own source-mode rules, so bundle and source behave the same:module.exports =toexport default, using vite's JS parser;<style>under the idipyvue-<id>the first time the component is created, not when the bundle loads;scopedunlessipyvue.scoped_css_supportis on;The build also writes
app.mjs.jsonwith the exact export list.Serving: with
SOLARA_VUE_BUNDLEset, the server reads that list at startup and defines the module itself, so app code never names a module. The page gets a modulepreload link with a content-hash url and immutable caching. Solara picks bundle or source per file in two places:get_template, forcomponent_vueandtemplate_filewidgets;register_component_from_file, for registered components, also at render time.A file whose key is not in the bundle is served from source with one warning, so a stale bundle makes the app slower, never broken.
solara vue-bundle --checkfails CI on such files.Limits: bundle mode works only under solara server, and only for production. Template edits need a restart.
A fix for every url-backed module: urls under
/static/publicnow get the deployment root path. Master has this bug for every url-backed module.Registered components need widgetti/ipyvue#119 (
register_component_from_esm). Without it, they stay source.Validation
.vueedit still hot-reloads.tests/unit/vue_bundle_test.py(11 tests) andtests/unit/esm_vue_test.py(12 tests) pass locally on ipyvue 1.x.Gaps
.vuefile under the app folder runs when the bundle loads. One broken or test-only file blanks every component. CI should smoke-load the bundle (the docs have a snippet), and--excludecan leave files out.csstrait is ignored for bundled templates.register_component_from_filebefore solara patches ipyvue keeps the source path.exports.foo = ...,module.exportsbelow the top level, and<script lang="ts">are not supported. The build fails loudly on the second.Align results
Caution
/alignwas not run on this change: Maarten settled the design in chat instead. He chose to re-use this PR, no module names in app code, one bundle per app, a manifest written by the build, readable export names, opt-in per app, solara never running vite,--vite-configbehind a flag, and support for registered components.Crossreview results
The plan had its own crossreview: 3 rounds by astra, opus and sol, before any code. The diff then had two crossreviews, by astra (adversarial), opus (correctness, end to end) and sol (contract). Each ran 3 rounds plus small fixes:
.vuefile and serves registered components, together with the ipyvue PR.All three approved the final tree, or confirmed the fixes for their own findings.
HIGH findings, all fixed:
module.exportsrewrite hit comments, block comments, strings and HTML-commented scripts, and left the real assignment, so the whole bundle failed. Fix: vite's JS parser, then vue's SFC parser. Cause: text regexes over JavaScript and SFCs.venv/inside the app folder, and one library template that fails at load blanked the app. Fix: skip virtual environments and installed packages.MEDIUM findings, all fixed:
<template>;</script>;Small fixes and who confirmed them:
Open by choice: the four gaps above (the
csstrait, early imports, flask root path, style order).Constitution change that could have prevented these: none.
🤖 Generated with Claude Code