Skip to content

feat: serve an app's vue templates and components from one prebuilt bundle - #1179

Draft
maartenbreddels wants to merge 21 commits into
masterfrom
feat/vue-bundle-mode
Draft

maartenbreddels wants to merge 21 commits into
masterfrom
feat/vue-bundle-mode

Conversation

@maartenbreddels

@maartenbreddels maartenbreddels commented Jul 5, 2026 •

Copy link
Copy Markdown
Contributor

Solara apps can now serve all their .vue templates 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 .vue files, 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 with ipyvue.register_component_from_file could not come from a bundle at all.

Change

An app turns this on with three commands, and its code does not change:

solara vue-bundle src/solara_app.py -o vue/entry.js --vite-config   # the entry, and a starter vite config
vite build -c vue/vite.config.mjs                                   # writes public/vue/app.mjs and app.mjs.json
SOLARA_VUE_BUNDLE=vue/app.mjs solara run src/solara_app.py
  • Collecting: solara vue-bundle does not import the app. It bundles every .vue file under the app's folder. It skips node_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-config writes a starter config that follows ipyvue's own source-mode rules, so bundle and source behave the same:

    • it rewrites module.exports = to export default, using vite's JS parser;
    • it reads blocks with vue's SFC parser, as ipyvue does;
    • it inserts each template's first <style> under the id ipyvue-<id> the first time the component is created, not when the bundle loads;
    • it ignores scoped unless ipyvue.scoped_css_support is on;
    • it keeps whitespace and asset urls as vue's in-browser compiler does.

    The build also writes app.mjs.json with the exact export list.

  • Serving: with SOLARA_VUE_BUNDLE set, 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:

    • its patched get_template, for component_vue and template_file widgets;
    • a patched 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 --check fails 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/public now 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

  • The bundle ran on a large production app with a local build of the ipyvue PR above: 201 templates, about 50 components registered at render time, 2.3 MB (about 500 kB gzipped). Five pages matched source mode pixel for pixel. The two remaining differences are a random phone number and an animated dot. A page whose dialog opens on mount keeps its search input focused.
  • Opus ran a small app end to end in a browser, with every kind of template, styles, an edited file, and a registration switch. Everything matched source mode, and with the setting off a .vue edit still hot-reloads.
  • tests/unit/vue_bundle_test.py (11 tests) and tests/unit/esm_vue_test.py (12 tests) pass locally on ipyvue 1.x.

Gaps

  • Nobody tested this with ipyvue 3 / Vue 3, or on Windows. Nobody measured the start-time gain.
  • Every .vue file 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 --exclude can leave files out.
  • The widget css trait is ignored for bundled templates.
  • Code that imports register_component_from_file before solara patches ipyvue keeps the source path.
  • Under flask, urls get no root path, the same as on master.
  • Bundled templates insert some styles in a slightly different order than source mode. Nobody saw a visible effect.
  • exports.foo = ..., module.exports below the top level, and <script lang="ts"> are not supported. The build fails loudly on the second.

Align results

Caution

/align was 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-config behind 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:

  1. On the first version, which collected templates by importing the app.
  2. On the redesign that bundles every .vue file 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:

  • First review:
    • The module.exports rewrite 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.
  • Second review:
    • The collector bundled a venv/ inside the app folder, and one library template that fails at load blanked the app. Fix: skip virtual environments and installed packages.
    • Registered components could not switch between source and bundle, and mounted bundle parents kept old children. Fixed in the ipyvue PR.

MEDIUM findings, all fixed:

  • export names read from comments;
  • blocks nested inside <template>;
  • legacy bare-object scripts;
  • an empty script that inserted its style at load;
  • CSS containing </script>;
  • self-closing blocks;
  • local and Docker builds that gave different style ids;
  • styles that leaked from unused templates, found on the large app;
  • dropped whitespace, which moved badges by 2px;
  • mount timing that cost an input its focus.

Small fixes and who confirmed them:

  • 39f4fae (the exported value from the parser): astra and sol.
  • 709b8e5 (whitespace): measured on a page of the large app.
  • b8ffe83 (self-closing blocks in every branch): astra.

Open by choice: the four gaps above (the css trait, early imports, flask root path, style order).

Constitution change that could have prevented these: none.

🤖 Generated with Claude Code

@maartenbreddels maartenbreddels changed the title feat: serve component_vue templates from a precompiled ES module bundle FUTURE: feat: serve component_vue templates from a precompiled ES module bundle Jul 6, 2026
@maartenbreddels maartenbreddels changed the title FUTURE: feat: serve component_vue templates from a precompiled ES module bundle feat: serve component_vue templates from a precompiled ES module bundle Jul 6, 2026
maartenbreddels and others added 11 commits October 23, 2026 17:07
@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 maartenbreddels changed the title feat: serve component_vue templates from a precompiled ES module bundle feat: serve the app's vue templates from one prebuilt bundle Oct 23, 2026
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>
maartenbreddels and others added 6 commits October 23, 2026 18:05
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>
maartenbreddels and others added 3 commits October 23, 2026 21:36
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

Copy link
Copy Markdown
Contributor Author

Parking this PR. We measured the bundle mode thoroughly on a large production app (201 .vue templates, about 70 registered components), in a production-like setup (Caddy in front of 2 uvicorn workers, a 1-core container, HTTP/2 with 40 ms latency, browser CPU at 1x and 4x), and the gain does not justify the risk and the build step.

What the best variant gave against the current release: one minified bundle, preloaded, imported once the websocket connects, and served precompressed by the proxy.

  • Home page: 120 to 470 ms faster to the first useful view.
  • A page that uses few templates: about 110 ms faster at 1x, but about 130 ms slower at 4x browser CPU. Evaluating the whole bundle costs about as much as compiling those few templates in the browser.
  • Websocket traffic: 1.0 to 1.3 MB less per page load.
  • Server CPU: only 4 to 9% less. The server's cost is per widget, not per byte: the bundle still creates the same Template and VueComponent widgets, they just carry a url instead of source.

What we learned, in case someone picks this up again:

  • Splitting into groups or per-template chunks did not help; one bundle was best.
  • Templates must be compiled with the same rules as ipyvue's runtime: first <style> only, ipyvue-<id>, scoped ignored, whitespace preserved. Otherwise pages render differently.
  • Registered components must be async, as in source mode, or mount order changes.
  • Vue 3 bundles import "vue", so the early import needs another approach there.

The root-path bug in versioned_url() that this work found is fixed separately in #1263.

This branch had an error being deployed

1 failed deployment
feat/vue-bundle-mode - solara-stable PR #1179 — b8ffe83d Deployed Oct 23, 2026 by maartenbreddels
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant