Skip to content

perf: load the SFC compiler as a lazy chunk, and TypeScript support only when a template uses it - #114

Merged
maartenbreddels merged 9 commits into
masterfrom
perf/lazy-sfc-chunk
Oct 5, 2026
Merged

maartenbreddels merged 9 commits into
masterfrom
perf/lazy-sfc-chunk

Conversation

@maartenbreddels

@maartenbreddels maartenbreddels commented Oct 2, 2026 •

Copy link
Copy Markdown
Collaborator

nodeps.js shrinks from 326 to 44 KB gzip: the SFC compiler moves to a lazy chunk that hosts can preload, TypeScript support loads only when a template uses it, and every template compiles exactly as in 3.0.0.

Problem

Every page that uses jupyter-vue downloads @vue/compiler-sfc and sucrase inside nodeps.js (Solara) or index.js (classic notebook) before jupyter-vue can start: 326 KB gzip for nodeps.js.
Pages without any template pay for the compiler anyway.
sucrase only strips TypeScript, but every page downloads it, also when no template uses lang="ts".

Change

  • js/src/sfcCompiler.js (new) is the vue-sfc chunk: @vue/compiler-sfc and the compile code.
    It keeps 3.0.0's compile code from esmVueTemplate.js. The only change in it is that sucrase now comes from an awaited dynamic import (see below), with the same transform call and options.
    Every template, plain or <script setup>, compiles with the same code as in 3.0.0.
  • js/src/esmVueTemplate.js loads the chunk with import() on the first template.
    Before the load, it dispatches the window event jupyter-vue:load-chunk with {chunk: 'vue-sfc', sourceURL}, so a host can tell the user how to preload it.
    After a failed load, the next template tries again.
    The es-module-shims runtime stays in the main bundle, so addModule does not wait for the chunk.
  • sucrase moves to a second lazy chunk, vue-sfc-ts, which loads only for <script lang="ts">, with the same transform call and options.
  • js/src/publicPath.js (new) makes the AMD builds load the chunks from the folder of the file that requirejs loaded, with the same query string (the cache-busting hash of Solara or the notebook).
  • The classic notebook bundle (index.js) is now built from js/src/embed.js, which imports publicPath.js first, as the CDN embed bundle already did.
  • setup.py checks after the npm build that both chunk files exist (nodeps-vue-sfc.js, nodeps-vue-sfc-ts.js).
  • js/webpack.config.js: stable chunk file names (nodeps-vue-sfc.js, nodeps-vue-sfc-ts.js, index-vue-sfc.js, index-vue-sfc-ts.js), one uniqueName per build, named chunk ids, no split chunks. The nbextension builds set a static publicPath, which publicPath.js replaces. In the labextension build, the chunks are normal federated chunks.
  • CI: a build step checks the sizes, that compileScript is only in the vue-sfc chunks, and that sucrase is only in the vue-sfc-ts chunks, so the bundles cannot silently grow back.
  • CI: the UI tests now test this build. Before, the ui-test job installed ipyvuetify 3.0.0a3, which requires ipyvue 3.0.0a5 by URL and replaced the built wheel, so the UI tests ran against 3.0.0a5 (also on master). The job now reinstalls the wheel last and fails when the chunk file is missing.
  • JupyterLab: sucrase is no longer a shared module ("sucrase": false in jupyterlab.sharedPackages). As a shared module, one failed download would fail every later TypeScript template until a reload, because webpack keeps a failed shared install. A normal chunk retries.

Host contract (Solara)

To preload the compiler chunk, a host adds this tag before the chunk is needed:

<script defer src=".../nbextensions/jupyter-vue/nodeps-vue-sfc.js?<hash>" data-webpack="jupyter-vue-nodeps:chunk-vue-sfc" onerror="event.target.remove()"></script>

A host can preload the TypeScript chunk the same way, for pages with <script lang="ts"> templates:

<script defer src=".../nbextensions/jupyter-vue/nodeps-vue-sfc-ts.js?<hash>" data-webpack="jupyter-vue-nodeps:chunk-vue-sfc-ts" onerror="event.target.remove()"></script>

webpack then reuses the tag, and the chunk is requested once.
The onerror attribute is needed: webpack also reuses a tag that already failed, and then waits 120 s for it.
Hosts that serve nodeps.js themselves must also serve the chunk files next to it.
solara#1235 adds this tag in its default mode.

Sizes

file 3.0.0 raw / gzip this PR raw / gzip
nodeps.js (Solara) 1,119,294 / 325,610 126,327 / 43,883
nodeps-vue-sfc.js (lazy, compiler) none 790,964 / 238,962
nodeps-vue-sfc-ts.js (lazy, TypeScript) none 201,617 / 45,055
index.js (classic notebook) 1,307,537 / 392,037 324,490 / 114,006
index-vue-sfc.js (lazy, compiler) none 790,960 / 238,954
index-vue-sfc-ts.js (lazy, TypeScript) none 201,614 / 45,050

gzip sizes are from gzip -9.
A page without templates downloads 44 KB gzip (was 326), a page with templates 283 KB, and a page with a TypeScript template 328 KB.
Every Solara page renders a template (navigator.vue), so on Solara the compiler chunk always loads; Solara can preload it in parallel with the rest of the page.

Validation

  • A reviewer rendered 14 templates with released 3.0.0 and with this build, on Solara 1.64.0 and on Voila 0.5.13: classic scripts with traits, methods and computed; <script setup lang="ts"> with a scoped style; export default with scoped and unscoped styles; v-bind objects on Vuetify activators; watchers; a trait and a computed with the same name; v-model on a prop; comments; a global component; a template without a script; a template change at run time; a _name method; a broken template.
    The DOM before and after clicks and typing, the overlay HTML, the <style> contents and the console messages were identical.
  • Preload with solara#1235: exactly 1 request for the compiler chunk in its default mode. Solara 1.64.0 without the preload: the chunk loads lazily once, with the query string kept.
  • UI tests on the Solara runner: test_sfc_chunk.py (a TypeScript template loads both chunks; plain templates load the compiler chunk once and never the TypeScript chunk), test_template.py, test_v_bind.py, test_v_on.py pass. The retry test (a failed chunk load, then a template that renders) runs on the notebook runners; on Solara it skips, because navigator.vue loads the chunk before the test starts.
  • pytest tests/ui/ with this wheel, as CI runs it: 83 passed, 1 skipped (the compiler retry test skips on Solara, because navigator.vue loads the chunk first; it runs on Voila, JupyterLab and the notebook). A new test aborts the first TypeScript chunk download and checks that a later TypeScript template renders, on all 4 runners.
  • Reviews (static, without builds or tests): gpt-6.1-sol and astra reviewed every commit. gpt-6.1-sol found the JupyterLab shared-sucrase retry problem, which is fixed; nothing else was found.
  • CI runs the UI tests on Solara, Voila, JupyterLab 3.6.8 and the classic notebook.

Gaps

  • JupyterLab 4, Notebook 7, Firefox and Safari are not tested.
  • The repo has no JS unit tests. The two tests in tests/ui/test_watchers.py cannot fail as written (they never wait for their last locator); this PR does not change them.
  • Pages with templates still download the compiler, now as a separate file. Removing it needs templates that are compiled at build time.
  • jupyter-vue still bundles all of lodash. Importing all of lodash sets window._ as a side effect, and Solara uses that global (_.escape, _.debounce, _.isEqual). An earlier commit imported only cloneDeep and isEqual, which removed the global, so it was reverted. A separate PR can remove lodash together with the global, and save 19 KB gzip more.
  • A page with a TypeScript template downloads 2 KB gzip more than with 3.0.0. I did not check why.
  • The validation above ran before the lodash revert. The revert restores the 3.0.0 lodash import. I rebuilt after the revert to measure the sizes, and did not run the UI tests again.
  • An earlier version of this PR also compiled plain templates with Vue's built-in compiler, without the chunk. That changed how some templates behave, so it was dropped.

This PR is part of the Solara page-load work; the Solara PR that preloads the chunk is widgetti/solara#1235.

🤖 Generated with Claude Code

@maartenbreddels maartenbreddels changed the title perf: stop loading the SFC compiler for templates that do not need it perf: load the SFC compiler as a lazy chunk, and TypeScript support only when a template uses it Oct 4, 2026
maartenbreddels and others added 9 commits October 5, 2026 18:41
Every page with jupyter-vue downloaded @vue/compiler-sfc and sucrase
before its first render. In Solara, nodeps.js was 326 KB gzip, and every
app waited for it. These two libraries are now in a lazy chunk, vue-sfc,
and nodeps.js is about 44 KB gzip.

Templates compile exactly as in ipyvue 3.0.0. The compile code moved
unchanged into the chunk, and the first template on a page loads it.
After a failed load, the next template tries again. The es-module-shims
runtime and addModule stay in the main bundle, so they do not wait for
the chunk.

The chunk file has a stable name (nodeps-vue-sfc.js, index-vue-sfc.js)
and data-webpack key (jupyter-vue-nodeps:chunk-vue-sfc), so a host such
as Solara can preload it with a script tag. Before the load, jupyter-vue
sends the window event jupyter-vue:load-chunk, so a host can tell the
user how to preload it. The AMD builds load the chunk from the folder of
the file that requirejs loaded.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Only a template with <script lang="ts"> needs sucrase. With sucrase in the
vue-sfc chunk, every page that compiles a template paid for it. Every Solara
page compiles a template (navigator.vue), so every Solara page downloaded
and evaluated sucrase without using it.

sfcCompiler.js now imports sucrase with a dynamic import in the lang="ts"
branch only, and calls the same transform with the same options. Webpack
puts it in its own chunk, vue-sfc-ts, with stable file names
(nodeps-vue-sfc-ts.js, index-vue-sfc-ts.js), so a host can preload it like
vue-sfc. The CI build guard checks that sucrase is only in that chunk, and
the UI test checks that a plain template does not request it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
VueTemplateRenderer.js imported all of lodash to call cloneDeep and
isEqual, so webpack put the whole library in nodeps.js and index.js, which
every page loads.

It now imports lodash/cloneDeep and lodash/isEqual, the same functions, so
only those two and their helpers are in the bundle. No other file imports
lodash. In a local production build, nodeps.js went from 126,328 to 75,759
bytes (43,884 to 25,267 gzip -9), and index.js from 324,491 to 273,882.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… build

The UI job checks that the vue-sfc chunk is installed after it
reinstalls this wheel. Without that check, a wheel replaced by a
released ipyvue (which has no lazy chunk) makes the chunk tests skip or
fail for a reason that is hard to see.

In the labextension, @jupyterlab/builder shares every dependency, so
sucrase was a shared module. webpack keeps a failed shared install, so
after one failed download every later TypeScript template failed until
a reload. "sucrase": false in jupyterlab.sharedPackages makes it a
normal lazy chunk (vue-sfc-ts) in Lab too, and webpack retries a normal
chunk. A new UI test aborts the first vue-sfc-ts request and checks
that a later TypeScript template renders.

Validation: one bdist_wheel build. remoteEntry no longer registers
sucrase as shared, and static/vue-sfc-ts.<hash>.js holds sucrase. With
this wheel in a copy of the CI venv (Python 3.11), pytest tests/ui/ on
all four runners: 83 passed, 1 skipped (the vue-sfc retry test on the
solara runner, as designed).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
setup.py checks that the built files exist after npm run build. It named the
compiler chunk but not the new TypeScript chunk, so a build without
nodeps-vue-sfc-ts.js would still pass. Hosts may preload that file by name.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
nbextension.js had the same two statements as embed.js: import
publicPath.js, then re-export index.js. Both AMD bundles now need the
same thing, the chunk path from the folder of the loaded file, so one
entry file is enough and the PR adds one file less.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The separate test rendered PLAIN again only to check that a plain
template does not request the vue-sfc-ts chunk. The plain-template test
already renders PLAIN and two more templates, so it can make that check.
This keeps the coverage and saves one kernel and page run per runner.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…path

An earlier version of this branch compiled plain templates with
Vue.compile and sent names that start with _ to the full compiler. This
test guarded that switch. Now every template uses the same compile code
as 3.0.0, so the test checks nothing that this change can break.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Importing all of lodash has a side effect: webpack folds lodash's AMD
check to true, so the bundle sets window._ to lodash. Solara uses that
global (_.escape, _.debounce, _.isEqual), and user templates may too.
Importing only cloneDeep and isEqual removed the global. That change is
not needed for the lazy SFC chunk, so it belongs in its own PR that also
deals with the global.

This reverts commit 86e6b42.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@maartenbreddels
maartenbreddels marked this pull request as ready for review October 5, 2026 18:18
@maartenbreddels
maartenbreddels merged commit 20a64f6 into master Oct 5, 2026
19 of 20 checks passed
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