Skip to content

perf: let minimal pages skip most of Lumino until a widget needs it - #1240

Draft
maartenbreddels wants to merge 2 commits into
perf/classic-widgets-corefrom
perf/lumino-feature
Draft

maartenbreddels wants to merge 2 commits into
perf/classic-widgets-corefrom
perf/lumino-feature

Conversation

@maartenbreddels

@maartenbreddels maartenbreddels commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Most of Lumino moves out of the core bundle into a new lumino frontend feature. This cuts about 40 KB gzip from the core on Vue 2 and Vue 3, in production builds. full still preloads it, so only minimal pages skip it, until a widget needs it. This PR is part of a stack: it sits on #1236 (the output renderers), which sits on #1235. jQuery stays in the core here, and moves out in the jquery PR (#1241).

Problem

Every Solara page downloads and runs one core bundle before it shows the first widget.
With #1236, this core is still 212 KB gzip on Vue 3 and 330 KB on Vue 2 (ipywidgets 8, production build).
About 40 KB of it is Lumino code that most pages never run: dock panels, tab bars, menus, commands and a virtual DOM.
Only the ipywidgets controls (for example Tab), the Output widget, and widgets of notebook extensions that ask requirejs for @lumino/* or @phosphor/* use it.
A page with only Vue widgets pays to download and run this code anyway.
Slow phones and laptops pay the most.

Change

  • A new frontend feature, lumino, holds the Lumino code that the core does not use.
    The core keeps what it needs: Widget, Panel, MessageLoop, @lumino/signaling, @lumino/domutils and a few helpers.
  • The full preset (the default) preloads lumino, so a full page works as in feat: load pages faster by splitting the frontend into optional features #1235.
    The minimal preset leaves it out.
    +lumino turns it on.
  • jupyter-controls and output-widget need lumino.
    The server adds it when you turn one of them on, and full,-lumino is an error while one of them is on, as for jupyter-css.
    In the bundle, their chunks load from the lumino chunk, so they run after it and share its code.
  • The requirejs modules @lumino/widgets, @lumino/algorithm, @lumino/commands and @lumino/virtualdom (and their @phosphor/* names) come from the lumino chunk.
    When a page without lumino gets the first request for one of them, the browser loads the chunk once.
    It then logs one warning that names the feature and the module that asked, and the server logs one line with the same names:
    • Browser: solara: frontend feature "lumino" was not preloaded, it loads now for the requirejs module "@lumino/widgets". Add "+lumino" to --frontend (SOLARA_FRONTEND) to preload it.
    • Server: The requirejs module '@lumino/widgets' of the page needs the frontend feature 'lumino', which this server does not preload. ...
  • When the controls or the Output widget bring lumino along, the warning names jupyter-controls or output-widget, as before, and not lumino.
  • The core still defines @lumino/signaling, @lumino/domutils (and their @phosphor/* names) and @jupyterlab/coreutils.
    A widget that asks only for those loads nothing more.
  • Webpack cannot leave out parts of Lumino's published files, because each package ships as one flat file.
    The new script packages/solara-widget-manager/tools/split-lumino.js compiles the sources of @lumino/widgets, @lumino/algorithm and @lumino/collections to one file per module.
    It uses TypeScript 3.6, the version that built these packages.
    With it, the classes stay ES5 classes and the getters stay enumerable, as in the published files.
    The first run installs TypeScript 3.6 with npm ci in the tools folder, so it needs network access.
    The widget manager packages run the script on prepare, prebuild and prewatch, so npm install, npm run build and npm run watch run it.
    build:lib and build:prod do not run it.
  • The script stops when a package is not at the version that it pins, or when TypeScript is not 3.6.
    It also stops when its output and the published file have different counts of enumerable or non-enumerable getters.
    The app build stops when the split files are missing, or when they were made for another version of the package.
  • A preloaded chunk whose required chunk did not load (for example a 404 for lumino) no longer runs.
    The page still starts, and the feature loads with what it needs on first use.
  • The docs table of frontend features has a lumino row, and says which requirejs modules load it.
  • The benchmark summary counts lumino lazy loads.

Validation

I built all 8 bundles: Vue 2 and 3, ipywidgets 7 and 8, production and development.
I compared them with the build of #1236 at 6e151ed0.
I tested on three environments: Vue 3 with ipywidgets 8, Vue 2 with ipywidgets 8, and Vue 2 with ipywidgets 7.
I built twice, because a code comment changed after the first build.
The browser tests ran on the first build, and the sizes come from the second build.
The production bundles of both builds are byte for byte the same; only the two development core files of each Vue version differ.
Sizes are bytes after gzip -9 -n.
"Full JS" is the core plus every chunk that the full preset preloads.

build core gzip, #1236 core gzip, this PR saved lumino gzip full JS gzip
Vue 3, ipywidgets 8, prod 211,539 170,977 40,562 40,916 607,910 to 608,315
Vue 3, ipywidgets 7, prod 206,203 165,731 40,472 40,858 594,021 to 594,451
Vue 2, ipywidgets 8, prod 329,787 289,266 40,521 40,915 530,452 to 530,899
Vue 2, ipywidgets 7, prod 324,386 283,934 40,452 40,857 531,686 to 532,135
Vue 3, ipywidgets 8, dev 586,152 473,775 112,377 123,749 1,361,553 to 1,373,120
Vue 2, ipywidgets 8, dev 778,631 666,010 112,621 123,748 1,191,714 to 1,203,037
  • In production builds, a full page loads about 0.4 KB gzip more JS than with perf: let minimal pages without an Output widget skip the Jupyter output renderers #1236, in one more request.
    The jupyter-controls and output-widget chunks grow by less than 30 bytes gzip.
  • In development builds, a full page loads 11.3 to 11.6 KB gzip more JS.
    These two chunks grow by 82 to 138 bytes gzip.
  • In the Vue 3, ipywidgets 8 development build, the core keeps 5 of the 28 module files of @lumino/widgets (widget, panel, layout, panellayout, title).
    The lumino chunk has the other 23, and 13 of the 18 module files of @lumino/algorithm.
    It also has @lumino/commands, dragdrop, keyboard and virtualdom.
  • I ran tests/unit/frontend*_test.py (three files) once on each environment: 94 passed on each.
    The new tests are:
    • test_lumino_out_of_core checks for all 8 builds that the core has jQuery and no DockPanel, and that the lumino chunk has DockPanel and no jQuery.
    • test_lazy_load_message_names_the_module checks that the server logs one line that names lumino and the module.
    • test_lazy_load_message_ignores_a_strange_module checks that the server leaves out a module name that the browser sent with a newline.
  • I ran 10 browser tests of tests/integration/frontend_chunks_test.py once on each environment, with the flask and the starlette server: 20 passed on each.
    The new tests are:
    • test_lumino_requirejs[minimal]: a page widget that asks requirejs for @phosphor/signaling, @lumino/domutils and @jupyterlab/coreutils loads no lumino chunk and gives no warning.
      A page widget that asks for @lumino/widgets loads lumino once, with one browser warning and one server log line that name @lumino/widgets.
      Its ES5 subclass of Widget renders in a DockPanel, and the Widget class of the chunk is the one of the core.
    • test_lumino_requirejs[full]: the same pages, with lumino preloaded and no warning.
    • test_tab[full] and test_tab[minimal]: the Tab widget switches tabs. In minimal, the only warning names jupyter-controls.
    • test_full_lumino_request_fails: the preloaded lumino chunk fails once with a 404. The page still starts, and the Tab, the Output widget and the page widget all work after the chunk loads on first use.
  • The other 5 browser tests were test_full_preloads_sync, test_full_display_outputs, test_minimal_lazy_controls, test_minimal_lazy_output and test_full_output_widget_request_fails.
  • The pre-commit hooks (ruff, mypy, codespell) passed.

Gaps

  • I did not measure load or run times on this branch.
    An earlier CPU profile of the earlier perf: let minimal pages without an Output widget skip the Jupyter output renderers #1236 builds gave estimates for this Lumino code on Vue 3.
    It runs for about 3.6 ms at normal CPU speed and about 16 ms at 4x slowdown.
    That chunk also had the rest of @jupyterlab/coreutils, which stays in the core here.
    I did not repeat that profile on this branch.
  • I did not run tests/benchmark/summary_test.py on this branch, although this commit changes it.
    It passed (7 tests) on the branch of the jquery PR (perf: let minimal pages without jQuery widgets skip loading jQuery (opt-in) #1241), which builds on this one.
  • I did not run the browser test test_minimal_soft_remount_keeps_one_widget_renderer on this branch.
    It passed on the branch of the jquery PR (perf: let minimal pages without jQuery widgets skip loading jQuery (opt-in) #1241).
  • I did not test real notebook extension widgets (for example pythreejs, ipyvolume or ipympl).
    The browser tests use a page widget that asks requirejs for the same modules.
  • The warning names only the first module that asks for lumino, because each feature warns once.
  • The core still has @lumino/polling and all of @jupyterlab/coreutils, because Q47 dropped the stand-ins that removed them.

Align results

The #1235 description lists the earlier questions of this work, Q0 to Q46.
These three settle this PR:

Crossreview results

Rebase onto #1235 at e8766df (2026-10-23): one new test: commit expects the vue-sfc warning that ipyvue 3.1 gives on minimal Vue 3 pages, and import re is back in frontend.py (the base had removed it). astra and gpt-6.1-sol compared the old and new commits with git range-diff and found no regressions.

Round 1 had two reviewers: astra and gpt-6.1-sol.
Both come from one vendor, OpenAI. Maarten chose them in Q41.
Both reviewed the one commit of this PR (ed204719) from the commit alone, without builds or tests.
gpt-6.1-sol found no defects.
astra found one MEDIUM finding, which is not a defect:

  • MEDIUM, astra: one warning per requested module.
    In minimal, when widgets ask for @lumino/widgets and then @lumino/commands, only the first module gets a browser warning and a server log line.
    • Not fixed: this is the behaviour that Q47 asks for, one warning that names the module that asked. Each feature warns once, as the other features do. The Gaps section lists it, and test_lazy_load_message_names_the_module checks it.
    • The review prompt said "one warning per module" by mistake, and the finding builds on that wording.
    • Constitution change: None, because this is not a defect.

There were no small fixes.

Round 2: astra and an Opus agent checked the PR descriptions against the code, and this version of the description has their corrections.

🤖 Generated with Claude Code

maartenbreddels added a commit that referenced this pull request Oct 5, 2026
The frozen app had no copy of the bundle that `npm run devlink` links into
the CDN cache, so it fetched the published bundle from the CDN. That bundle
lacks the chunk files this PR's page asks for, so every page load made extra
CDN requests for missing files. When one of them was slow, the page did not
finish loading within the test's 30 s, and the job failed now and then
(twice on #1240, once here). Pointing SOLARA_ASSETS_PROXY_CACHE_DIR at the
linked cache makes the test use this PR's bundle, like the other jobs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@maartenbreddels
maartenbreddels force-pushed the perf/lumino-feature branch 2 times, most recently from c8cc890 to b1ee79a Compare October 5, 2026 12:42
maartenbreddels and others added 2 commits October 23, 2026 19:42
…hat use it

Every page ran all of Lumino at startup, but the core only needs Widget,
Panel, MessageLoop, signaling and domutils. The rest (DockPanel, TabBar,
menus, commands, virtualdom, ...) is for the ipywidgets controls, the
Output widget, and widgets of notebook extensions that ask requirejs for
@lumino/* or @phosphor/*. Moving it out shrinks the core by about 40 KB
gzip, on Vue 2 and Vue 3.

The new frontend feature lumino holds that code. The full preset
preloads it and minimal leaves it out. jupyter-controls and
output-widget need it: webpack loads their chunks from the lumino chunk,
so they run after it and share its modules. A requirejs request for a
moved module loads lumino on first use, with one warning in the browser
and one in the server log that name the module that asked, because only
the browser knows which widget needs it. @lumino/signaling,
@lumino/domutils (and their @phosphor names) and @jupyterlab/coreutils
stay in the core, so widgets that only ask for those load nothing.

Webpack cannot tree-shake Lumino's flat dist files, so
tools/split-lumino.js compiles the sources of @lumino/widgets, algorithm
and collections to one module per file, with TypeScript 3.6 (the version
those packages were built with, so classes stay ES5 and accessors stay
enumerable). The build fails when a package version is not the pinned
one, when the accessors differ from dist, or when the split is stale.

A preloaded chunk whose required chunk did not load (for example a 404
for lumino) no longer runs half-way: it counts as missing, and loads
with what it needs on first use.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
With ipyvue 3.1.0, every Vue 3 page compiles its Vue template with the
lazy vue-sfc chunk, and the minimal preset leaves that chunk out
(decision Q43). The minimal runs of test_lumino_requirejs and test_tab
did not expect the vue-sfc warning and failed on Vue 3 after the rebase
onto the new frontend features branch.

The tests now expect one browser warning per page and one server
warning for vue-sfc from the _vue_sfc_warnings helper. They find the
lumino and jupyter-controls server warnings by their text, not by their
position, because the vue-sfc warning can come first. With ipyvue 3.0
or Vue 2 the helper gives no warnings, so nothing changes there.

Validation: test_lumino_requirejs, test_tab and
test_full_lumino_request_fails pass with flask and starlette on Vue 3
(ipyvue 3.1.0, ipyvuetify 3.1.0).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
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