Skip to content

feat: load pages faster by splitting the frontend into optional features - #1235

Draft
maartenbreddels wants to merge 26 commits into
masterfrom
t3code/speed-page-and-kernel-load
Draft

maartenbreddels wants to merge 26 commits into
masterfrom
t3code/speed-page-and-kernel-load

Conversation

@maartenbreddels

@maartenbreddels maartenbreddels commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

On the default install (Vue 3), the first widget of a cold page load now shows 23 to 32% sooner, and a new --frontend option lets an app leave out KaTeX, the Jupyter widget controls, fonts and more.

Problem

A fresh pip install solara gets ipyvue 3 and Vue 3. Before the first widget shows, the browser downloads about 1.4 MB gzip of JavaScript and CSS and runs all of it:

  • KaTeX, the Jupyter widget controls and the Output widget, also on pages that use none of them;
  • two copies of sanitize-html; React, Blueprint, Yjs and moment that nothing uses; and CodeMirror, which only code highlighting in Output widgets needs (the Vue 3 build missed the webpack aliases that the Vue 2 build has);
  • the jupyter-vue and jupyter-vuetify extensions, which the browser only requests after the websocket opens, and then one after the other.

The server also compresses every asset again on every request. That fix is in its own PR, #1243.
Users feel this as a slow first page, most on slow networks and phones.
The kernel itself is not the slow part: the server renders a session in about 10 ms.

Change

Faster by default, nothing to configure:

  • The Vue 3 build stops bundling React, Blueprint, Yjs and moment, and CodeMirror moves to two chunks that load only for a fenced code block in an Output widget's Markdown (−506 KB gzip from the core).
  • CSS is minified and deduplicated; fonts are separate files instead of data URIs.
  • On Windows, the CDN proxy now also marks files with an exact version as immutable. Its version check did not match Windows paths.
  • require.js is served minified from Solara itself.
  • The page preloads jupyter-vue and jupyter-vuetify, so they no longer wait for the websocket or for each other.
  • Markdown uses the KaTeX in the bundle instead of loading a second copy from the CDN, which also makes it work offline.
  • With an IPython that loads its magics on first use (9.17 does), each kernel skips the ScriptMagics setup (kernel creation 4.4 to 1.9 ms). Older IPython versions still run it.

Optional features: the app bundles are split into webpack chunks, one per feature. font-awesome and mermaid come from their own packages, and vue-sfc from ipyvue:

feature what gzip (Vue 3)
vuetify Vuetify JS; always on, also on Vue 3, and so is mdi; it brings roboto and vuetify-css unless you add -roboto or -vuetify-css 180 KB
vuetify-css Vuetify's stylesheet; leave it out with -vuetify-css when the app ships its own Vuetify CSS 71 KB
mdi, material-icons, roboto, font-awesome icon fonts and the Roboto font 36, 11, 1, 7 KB
jupyter-controls ipywidgets controls (needs jupyter-css) 36 KB, plus a shared sanitize-html chunk of 64 KB
jupyter-css ipywidgets base CSS 6 KB
output-widget Output widget and rendermime (needs jupyter-css, shares the sanitize-html chunk) 9 KB
katex math in Markdown and widget descriptions 77 + 4 KB
mermaid diagrams in Markdown; never preloaded: with the feature on, it loads on the first Markdown mount, as on master 891 KB
vue-sfc ipyvue's full SFC compiler, see the ipyvue PR 283 KB
  • The server preloads the chunks of the enabled features, so the default page (full) behaves as before.
  • --frontend / SOLARA_FRONTEND / solara.server.settings.main.frontend picks a preset (full, minimal) and adds or removes features, for example --frontend minimal,+katex or --frontend full,-mermaid.
  • A widget that needs a feature that was not preloaded still works. Its chunk loads lazily, and the browser console and the server log say once which flag to add.
  • Vuetify is always on, also on Vue 3, as on Vue 2, because the page shell and the default layout are made of Vuetify. On Vue 3, Vuetify is a chunk of its own that every page preloads. A page without Vuetify on Vue 3 is in the draft PR feat: let Vue 3 pages without Vuetify widgets skip loading Vuetify (experiment) #1238. With SOLARA_DEFAULT_CONTAINER=Fragment, minimal loads jupyter-controls the first time a component renders more than one element without a container, and warns once, because reacton's Fragment is an ipywidgets VBox.
  • roboto and vuetify-css come with vuetify. An app with its own font adds -roboto, and an app that ships its own Vuetify CSS (for example built from Vuetify's SASS) adds -vuetify-css. On Vue 2, Vuetify's CSS moves out of main{7,8}.css into main{7,8}.vuetify.css, at the same place in the page.
  • On Vue 2, Vuetify stays in the core bundle. On both builds, the page always loads the mdi CSS chunk with Vuetify; the other features are chunks.
  • Jupyter (Lab, notebook, Voila) does not change.
  • tests/benchmark measures page loads with Playwright, and a new CI job prints its numbers in the job summary. Timings never fail the job. It fails only when a configuration gets no measurement, so do not make it a required check.

The always-on core of the Vue 3 build goes from 1149 to 244 KB gzip. full loads about 740 KB gzip (was 1391 KB). minimal loads 534 KB: the core (244 KB) plus Vuetify (181 KB), its CSS (72 KB), mdi (36 KB) and Roboto (1 KB).

Companion changes, needed for the full Vue 3 gain but not for this PR to work. Both are released:

Validation

Benchmark apps from tests/benchmark, this PR at 8ef03a4 (without the asset compression, which moved to #1243) against master with the published bundles. Each cell has 11 cold visits, in interleaved rounds, on one machine. The numbers are the median ms to the first visible widget.

app@preset Vue 3 master Vue 3 PR Vue 2 master Vue 2 PR
hello@full 946 646 (−32%) 470 462 (−2%)
dashboard@full 1332 1020 (−23%) 560 534 (−5%)
hello@minimal 586 (−38%*) 401 (−15%*)
dashboard@minimal 1000 (−25%*) 480 (−14%*)
hello@full, Fast 4G + 4x CPU 5020 3637 (−28%) 2733 2640 (−3%)
dashboard@full, Fast 4G + 4x CPU 5649 4414 (−22%) 3223 2904 (−10%)
hello@minimal, Fast 4G + 4x CPU 3361 (−33%*) 2294 (−16%*)
dashboard@minimal, Fast 4G + 4x CPU 4133 (−27%*) 2557 (−21%*)
  • * A minimal cell compares with master's full cell of the same app.
  • The paired difference per round, with a 95% bootstrap interval of the median: Vue 3 full is 307 ms [281, 323] (hello) and 304 ms [288, 369] (dashboard) faster at normal CPU, and about 1.3 s faster at 4x CPU. On Vue 2 at normal CPU, the difference for hello is −5 ms, and its interval [−30, +42] crosses zero. At 4x CPU, Vue 2 full is 107 ms [35, 165] (hello) and 307 ms [253, 385] (dashboard) faster.
  • minimal is 60 ms faster than full for hello on both Vue versions at normal CPU, and about 320 ms faster at 4x CPU.
  • An earlier run at 0371680 still had the asset compression in this PR, and measured −53% (Vue 3) and −32% (Vue 2) for hello@full. Most of the Vue 2 gain at normal CPU came from the compression: master gzips every asset again on every request, which costs server time on each cold load. #1243 has that change now.
  • The benchmark's "session start" column (time from run to finished as the browser sees it) went up, for example 37 to 154 ms. That is not server time: the PR sends run about 450 ms earlier, while the browser still loads chunks. A timer around load_app_widget in the server shows 8 to 11 ms per session on both master and the PR.
  • Full mode against master: screenshots (0 pixel difference) and computed styles of every element, on Vue 3, Vue 2 with ipywidgets 8 and 7, in light and dark mode, in the Jupyter popout and in JupyterLab.
  • After Vuetify became always on (831f49d), full mode against 17acfe7: 0 pixel difference and the same ordered stylesheet rules on 5 apps (6 views), Vue 3 and Vue 2.
  • Unit tests and about 30 integration test files pass on Vue 3, Vue 2 and ipywidgets 7. New tests: tests/unit/frontend*_test.py, tests/integration/frontend_chunks_test.py, tests/benchmark.
  • The gc.freeze unit test now asserts on the change in the freeze count. On Python 3.12 a collection moves immortal objects to the permanent generation, and this PR's first CI run failed on that.
  • Offline: with the CDN unreachable and assets from the local cache, full and minimal pages load with no failed requests.
  • Real app, Vue 2 (a production Solara app with about 190 .vue templates, solara 1.63.1 as the base, assets served the way production does, 20 cold loads per setup):
    • Full mode on 7 pages, each with a menu or dialog open: 0 pixel difference in 22 screenshots, equal computed styles, no new console errors or failed requests. Route changes, refresh, reconnect after a server restart and hot reload behave the same. Each page transfers about 133 KB less.
    • Measured at 0371680, with the asset compression still in this PR, served with gzip only (no brotli). First useful view, PR at 0371680, against stock: faster in every case. At 4x CPU the dashboard page is 313 ms faster in full and 384 ms in minimal,-roboto, the home page 206 ms and 303 ms (all with a 95% interval below zero). At normal CPU it is 30 to 63 ms faster. Together with a core-js update in ipyvue and ipyvuetify 1.x (separate change, not in this PR), minimal,-roboto makes the home page 666 ms faster at 4x CPU (-28%).
    • At 60586ce (Vuetify always on, the asset compression still in, gzip only), 20 interleaved loads per setup: at 4x CPU the home page is 351 ms faster in full and 428 ms in minimal,-roboto. With the core-js update in ipyvue and ipyvuetify 1.x, it is 833 ms (−31%) and 927 ms (−35%) faster.
    • The stack on top of this PR renders the same: 0 pixel difference against stock in full, in Chromium at 3 widths and in Firefox.
  • Six review rounds plus a final break check (every finding checked by a second agent) found 18, 8, 7, 4, 6, 4 and 3 real problems. All are fixed except the one under Gaps. The real-app test found two more, both fixed: minimal on Vue 2 left out the Roboto font that Vuetify's text classes use, and our own Terser setup (terser 5.43 and newer) dropped the parentheses around the bundle's main function, so V8 compiled the core on the main thread instead of while it downloads (about 70-80 ms at 4x CPU; a unit test now checks the built bundle). The worst ones were in full mode: a missing top padding under the app bar with a Sidebar (CSS minifier), the first IPython.display.Markdown output failing, and KaTeX square roots and arrows disappearing (CSS order). Each has a regression test now. The last round found no full-mode problems; it checked the opt-in modes end to end, JupyterLab and the notebook popout, SOLARA_ASSETS_PROXY=false, a self-hosted CDN mirror, solara ssg and staticbuild, and a mount under a root_path.
  • A last review and trim pass found two more problems in the new code. Both are fixed, and the benchmark fix has a new test:
    • When every cold visit of a benchmark configuration failed, its row left the summary and the run still exited with 0. Now the configuration counts as failed, and the run exits with 1.
    • After one failed load of the CodeMirror chunk, code highlighting stayed off until a page refresh. Now the next code block tries the load again.
  • The same pass removed code that nothing used: a frame recorder in the benchmark, a counter in a webpack plugin, and a fix to the website's front matter parser (it can be a separate PR). It also fixed the docs, which said that full preloads mermaid.

Upgrade notes

  • If you keep a full copy of solara.html.j2 in your templates/ folder, update the copy before you change its npm versions to this release. The app bundles now load Vuetify, KaTeX, the Jupyter widget CSS and the fonts as separate files, and fonts.css no longer exists. A copy that keeps its old versions works as before. A template that calls {{ super() }} in each block it overrides needs no change.
  • The AMD module @jupyterlab/coreutils can no longer format times: moment is now an empty module, so Time.format and Time.formatHuman throw. Before, only the Vue 2 production build for ipywidgets 8 did this. We found no widget library that calls these functions.

Gaps

  • The asset compression (compress each static file once, opt-in brotli) moved to perf: compress each static file once instead of on every request #1243.
  • Release order: both npm packages (@widgetti/solara-vuetify-app, @widgetti/solara-vuetify3-app) need a release before the Python release, because the template now loads chunk files that the published bundles do not have. release.sh checks both again.
  • With --frontend minimal, a render error loads jupyter-controls and suggests +jupyter-controls, because reacton's error display uses ipywidgets.HTML. Fixing it needs a hook in reacton.
  • A page without Vuetify on Vue 3 is not in this PR. It needed a twin for every Solara template, its own shell and theme fixes, and it helps only apps without any Vuetify widget. The draft PR feat: let Vue 3 pages without Vuetify widgets skip loading Vuetify (experiment) #1238 shows what it entails.
  • CodeMirror (rendermime code highlighting in Output widgets) is a silent lazy chunk, not a flag; it loads only for a Markdown output with a fenced code block.
  • solara.FigurePlotly (anywidget) and other third-party widgets need their own extensions as before; widgets that need jupyter-css look unstyled in minimal until you add it.
  • Not measured: the pyodide page, and a real deployment over a network (the benchmark runs on localhost and does not throttle websocket frames).
  • With -vuetify-css on Vue 3, ipyvuetify 3.0.0 still adds its own copy of Vuetify's CSS. ipyvuetify 3.1.0 and newer do not (widgetti/ipyvuetify#350).
  • Unrelated, seen while testing: a GridDraggable as the root view does not render on Vue 3, also on master.

Align results

The questions and answers come from the align session of 2026-10-02 and later decisions by Maarten, as kept in the work file.

49 questions
  • Q0: The "unless" in the Q23 answer: nothing more.
  • Q1: Main metric: time from URL to first visible widget, cold cache. Also track warm cache, server session start, bytes.
  • Q2: Runtimes in scope: Solara server only. Jupyter (notebook, lab, voila) unchanged. Pyodide later.
  • Q3: Compatibility rule: invisible wins become the default; removing a feature is opt-in. Default preset stays full.
  • Q4: Vue 2 or Vue 3: support both (fresh pip install gives Vue 3 today).
  • Q5: Minimal mode content: became the minimal preset, refined by Q15, Q27.
  • Q6: Order: benchmark and baseline, quick wins, then the flags. (Roadmap part replaced by Q20.)
  • Q7: Named features plus presets (full = today, minimal).
  • Q8: Flags only, no on-demand/lazy loading. Clear error when a widget needs a disabled feature.
  • Q9: Flags are per server: CLI option, env var, solara.server.settings.
  • Q10: No new HTML element API. Without Vuetify, people use custom Vue components, ipyreact, or anywidget.
  • Q11: Shell without Vuetify keeps: loading screen, reconnect/refresh notice, hot reload, error/traceback display, routing, title/meta.
  • Q12: No new npm package. Split the existing builds (solara-vuetify-app Vue 2, solara-vuetify3-app Vue 3, each for ipywidgets 7 and 8) per feature.
  • Q13: Benchmark: Playwright script in the repo; CI prints numbers in the job summary; no failure threshold at first.
  • Q14: Each win reaches Vue 2 and Vue 3 eventually; may ship for one first (vuetify-off may ship for Vue 3 first).
  • Q15: Presets plus +feature/-feature. nbextensions/requirejs are always on (not a feature), so every widget works.
  • Q16: Error for a disabled feature: server side at widget creation (by _model_module, names the flag), plus a browser fallback.
  • Q17: One list setting: --frontend minimal,+katex, full,-mermaid.
  • Q18: Shell CSS without Vuetify: class prefix solara-, variables --solara-. No theme/dark mode there.
  • Q19: Benchmark app: synthetic app in the repo (AppLayout, 2 routes, DataFrame with fake data, slider, Markdown), one per preset.
  • Q20: No roadmap. One PR, built in this session.
  • Q21: PR scope: everything (benchmark, quick wins, flag system, all feature splits, Vue-only shell).
  • Q22: Python Jupyter parts stay; only skip ScriptMagics per session (server session start is ~35 ms of ~1000 ms).
  • Q23: Mermaid and KaTeX become features; when on they load up front (no progressive math). Preload nbextensions; skip ScriptMagics.
  • Q24: KaTeX: Markdown uses the copy in the app bundle (drop the 2nd CDN copy). Mermaid: loads when the first Markdown mounts, as now.
  • Q25: Features: vuetify (needs mdi), mdi, material-icons, roboto, font-awesome (replaces SOLARA_ASSETS_FONTAWESOME_ENABLED, keep old setting working), jupyter-controls (needs jupyter-css), output-widget, jupyter-css, katex, mermaid, + vue-sfc (Q32). Always on: Vue, widget core (@jupyter-widgets/base, kernel connection, Lumino, sanitizer), requirejs, nbextensions.
  • Q26: Shell: Vuetify shell in full (unchanged). With vuetify off: a pure Vue shell (no Vuetify tags).
  • Q27: Vue always on. vuetify off: root container ipyvue.Html, no default layout, theme widgets not created, Vuetify components raise the Q16 error.
  • Q28: Quick wins (default): Vue 3 webpack aliases (@jupyterlab/codemirror, postcss, moment), dedupe + minify Vue 3 main8.css, compress assets once + brotli, minified require.js, preload nbextensions (jupyter-vuetify only when vuetify on), skip ScriptMagics, KaTeX once.
  • Q29: Also one PR each in ipyvue and ipyvuetify (Vue 3 nodeps: drop 2nd Vuetify JS/CSS/source maps; split the SFC compiler).
  • Q30: Navigator, solara.HTML, GridLayout, VegaLite (and Title, HeadTag): VueTemplate instead of VuetifyTemplate when the .vue file has no Vuetify tags, in all modes.
  • Q31: vuetify off: Column, Row, Div, HBox, VBox, Text render as ipyvue.Html with prefixed solara- CSS. full unchanged.
  • Q32: ipyvue 3 SFC compiler + sucrase become feature vue-sfc (separate file, included up front when on). Off: template via Vue's built-in compiler, classic script as ES module, plain style tag; clear error for script setup, scoped style, lang=ts. Not lazy.
  • Q34: Setting name: --frontend, SOLARA_FRONTEND, solara.server.settings.main.frontend.
  • Q36: Split mechanism (user: "chunks"): each feature is a webpack chunk of the existing builds (also vue-sfc in ipyvue). The server adds <script> tags for enabled chunks up front, so import() resolves without a fetch; a disabled chunk is never requested, the code raises the clear error instead.
  • Q37: A chunk that is not preloaded (Solara server): lazy load it, so a widget always works. Server logs one warning per feature, browser console too, naming the flag to add (e.g. +katex). Flags only control preloading. This REVISES Q8 (no lazy loading), Q16 (error becomes warning), Q27 and Q32 (Vuetify widgets / vue-sfc templates lazy load instead of erroring), Q35 (math/diagrams lazy load instead of raw text). Shell choice and root container still follow the vuetify flag.
  • Q38: Chunks in Jupyter: only ipyvue's vue-sfc chunk lazy loads in Jupyter (nbextension + labextension). ipyvuetify lazy Vuetify in Lab (feat: support IPython clear_output for output widget #212) is a later PR.
  • Q39: Trim the Jupyter core (~252 KB gz JS, no CSS): a) one sanitize-html copy (alias to newest 2.x), moved into the jupyter-controls and output-widget chunks, core keeps a stub; b) remove nested Lumino copies and unused @jupyterlab/services barrel exports; c) jQuery out of the core only if nothing needs it at startup. d) own small kernel client instead of @jupyterlab/services: explore only, report whether ~20 KB gz is worth it.
  • Q39: outcome after the checked trace: c dropped, jQuery stays (pythreejs/ipyvolume use $el.empty/append/css/find). d explored: not worth it now (~15-20 KB gz after b; LabWidgetManager base needs a full IKernelConnection; 0 third-party widgets touch the kernel); do later together with replacing the LabWidgetManager base. Extra: controls (35 KB gz), output path (45 KB gz) and katex (77 KB gz) are always-on today and move to their chunks. No third-party widget among 17 checked needs AMD @lumino/, @phosphor/, @jupyterlab/* or @jupyter-widgets/controls. moment alias breaks the AMD coreutils Time export (unused by third parties).
  • Q35: (superseded by Q37) Markdown with katex/mermaid off: show raw TeX / mermaid code as text, one console warning naming the feature.
  • Q40: (Maarten, 2026-10-04): "different behaviour is killing, let's keep this simple": drop ipyvue#114's light path. Solara as desktop app #114 = only the lazy vue-sfc chunk with 3.0.0's exact compile behaviour (light path kept on local branch perf/light-path). Consequence: a page with any template loads the compiler chunk; the real win needs precompiled templates. Open: preload vue-sfc in feat: load pages faster by splitting the frontend into optional features #1235 minimal too? Correction: Vue 3 full with both companion PRs is ~1086 KB gz (803 left out the preloaded 283 KB chunk).
  • Q41: (Maarten, 2026-10-04): Opus agents only for work; reviews by astra and sol 6.1. Also: runs are too slow: run reviews in parallel with building, keep test scope targeted, no rebuilds in reviews.
  • Q42: (Maarten): split sucrase from the compiler chunk (lazy, only for lang="ts"); plus lodash single-function imports (both no behaviour change).
  • Q43: (Maarten): vue-sfc is NOT preloaded in minimal; fix later (minimal on Vue 3 lazy loads the compiler and warns on almost every page until then). The 4 feat: load pages faster by splitting the frontend into optional features #1235 leftovers (navigator.vue, frontend.py:359 _SFC_ONLY, docs line 134, main-vuetify.js:191) wait for that follow-up.
  • Q44: (Maarten, 2026-10-04): 1 brotli opt-in: yes. 2 missing tests/CI fixes (CI: Test against Python 3.10 #346, 1.x): yes. 3 stacked small-core PR: yes ("guess so"). 4 ipyvue/ipyvuetify 1.x core-js draft PRs: yes, show the links. 5 wrapper fix in CI: Test against Python 3.10 #346: yes, show before/after. 6 dev-mode issue: explained (style.py:64 'no running event loop' on .py hot reload, also in stock); waiting for yes/no to open an issue.
  • Q45: (Maarten, 2026-10-05): perf: let minimal pages without an Output widget skip the Jupyter output renderers #1236 option C: two plain features jquery and lumino, on in full (as feat: load pages faster by splitting the frontend into optional features #1235), off in minimal, opt in with +jquery / +lumino; no lazy load for widgets that may need them: a clear error that names the flag. Each its own PR, as a GitHub stacked PR (gh stack link): master <- feat: load pages faster by splitting the frontend into optional features #1235 <- perf: let minimal pages without an Output widget skip the Jupyter output renderers #1236 (rest of the small core) <- jquery PR <- lumino PR (order may follow the measured win). Driver defaults: jupyter-controls / output-widget REQUIRE what their code imports, so their lazy load brings jquery/lumino along (as vuetify brings mdi); the create_view hook, the prototype swap and the guessing go; a tiny $ stand-in stays (base imports jQuery at module top). Deviates from Q37 (a widget always works) for third-party non-Vue widgets in minimal: Maarten's call.
  • Q46: (Maarten, 2026-10-05): Vuetify is not an option in feat: load pages faster by splitting the frontend into optional features #1235 ('too difficult to rip out, it will simplify a lot'): always on, also on Vue 3 (CAN_DISABLE both builds minus vuetify, mdi). The without-Vuetify mode moves to a draft PR (perf/without-vuetify-experiment = feat: load pages faster by splitting the frontend into optional features #1235 + revert of the removal), base feat: load pages faster by splitting the frontend into optional features #1235's branch, outside the linear stack. Cost accepted: Vue 3 text-only minimal page +61/+343 ms (1x/4x) with ipyvuetify#346, +142/+705 ms with 3.0.0; +290 KB gz dist. Gain: feat: load pages faster by splitting the frontend into optional features #1235 -35 files, -1364/+176 lines. Keep/drop judge (wf_02822deb-057) agreed. Experiment PR must fix before merge: DateSliderWidget is ipyvue.VueTemplate with (slider.py:403), v-* tags in user templates, warn when tabs/AppBar drop, docs list, mixin instead of twins, re-benchmark after ipyvue#114 + perf: let minimal pages without an Output widget skip the Jupyter output renderers #1236.
  • Q47: (Maarten, 2026-10-05, after the jq-lumino measurement wf_d86ce988-485 recommended D): jQuery stays option C (opt-in jquery, clear error naming +jquery, no lazy load; accepted: anywidget/FigurePlotly/pythreejs need +jquery in minimal). semver port: dropped (keep the real semver). Lumino: lazy load + one warning naming the requesting module (CONFIRMED by Maarten 'ok, lazy it is'; first a driver default after Maarten asked why Lumino can lazy load: every path to the moved Lumino is async: requirejs define deps, or the controls/Output chunks; jQuery is called synchronously by base for every view). Driver defaults: drop the coreutils/kernelspec/minimist stand-ins and StandInGuardPlugin (<2 KB, <1 ms); @jupyterlab/coreutils stays in core; @lumino/signaling and @lumino/domutils AMD from core; output-widget keeps needing lumino. Stack: master <- feat: load pages faster by splitting the frontend into optional features #1235 <- perf: let minimal pages without an Output widget skip the Jupyter output renderers #1236 (output renderers + Vue 3 flags) <- lumino PR <- jquery PR, linked with gh stack link.
  • Q48: (Maarten, 2026-10-05): move the asset compression (compress each file once, gzip 9, opt-in brotli via solara[brotli]/solara-server[brotli]) out of feat: load pages faster by splitting the frontend into optional features #1235 into its own PR based on master. The feat: load pages faster by splitting the frontend into optional features #1235 benchmark table is then re-run in full (a ~60 min harness slot after the Grotto stack timing); the 14:10 20-min slot was given back.

Crossreview results

Caution

/crossreview was not run on this change as a command: the reviews ran as workflows instead. Six review rounds by Opus agents, then astra and gpt-6.1-sol on every later commit; see Validation.

The last rounds:

  • The removal of the Fragment special case: gpt-6.1-sol found 2 small problems (a test expectation and a docs sentence), both fixed in 17acfe7 and confirmed by the driver; astra found nothing.
  • Vuetify always on (831f49d): astra and gpt-6.1-sol both found that the commit deleted two theme browser tests that also covered full (MEDIUM). gpt-6.1-sol also found a docs sentence that left out roboto and vuetify-css (LOW). 60586ce restores both tests, now also on Vue 2, and fixes the sentence. astra and gpt-6.1-sol reviewed 60586ce again and found nothing.
  • Moving the asset compression out (9367d70): astra and gpt-6.1-sol reviewed it together with the new compression PR. They found nothing in this commit; their findings were in the compression code, and the new PR fixes them.
  • The CI change 0c31354 makes the pyinstaller job serve the bundle that the run built, not the published one. The pyinstaller test had failed now and then, because the published bundle lacks this PR's chunk files. astra and gpt-6.1-sol both found that on Windows the path got a trailing carriage return from print() (P1). 8ef03a4 fixes it with sys.stdout.write(), one line, and the driver confirmed it as a small fix.

🤖 Generated with Claude Code

maartenbreddels and others added 2 commits October 3, 2026 06:27
A fresh install (ipyvue 3, Vue 3) downloads about 1.4 MB gzip of JavaScript
and CSS before the first widget shows: all of Vuetify, KaTeX, the Jupyter
widget controls, the Output widget, two copies of sanitize-html, and
CodeMirror, React, Blueprint and Yjs that nothing uses. Every page paid for
all of it, and the browser fetched the jupyter-vue and jupyter-vuetify
extensions one after the other only after the websocket opened. The server
also gzipped each asset again on every request.

The app bundles are now split into webpack chunks per feature (vuetify, mdi,
material-icons, roboto, katex, jupyter-controls, jupyter-css, output-widget,
plus font-awesome, mermaid and vue-sfc). The server preloads the chunks of
the enabled features up front, so the default page behaves as before. A new
setting, --frontend / SOLARA_FRONTEND (presets full and minimal, with
+feature and -feature), lets an app leave features out. A widget that needs
a feature that was not preloaded still works: its chunk loads lazily and the
server and browser log one warning that names the flag to add. With vuetify
off, the page uses a small Vue shell and Solara's own layout components
render without Vuetify. Jupyter does not change.

Next to that, the default gets faster without any setting: the Vue 3 build
stops bundling CodeMirror/React/Yjs, CSS is minified and deduplicated,
assets are compressed once (brotli when available), require.js is served
minified, the two widget extensions are preloaded, KaTeX is loaded once, and
each kernel skips the ScriptMagics setup.

Validation: a new benchmark (tests/benchmark, also as a CI job) shows the
first widget on a cold load going from 1131 to 425 ms (Vue 3, hello world)
and 1217 to 834 ms (dashboard); 289 ms with --frontend minimal. Full mode
was compared with master pixel by pixel and by computed styles on Vue 3,
Vue 2, ipywidgets 7 and 8, and in the Jupyter popout.

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

On master the first solara.Markdown appended KaTeX's CSS to the end of <head>.
With KaTeX in the bundle, its CSS came before style.css, so
`.jp-RenderedHTMLCommon svg {height: auto}` won and the svg parts of math
(the radical of \sqrt, arrows, braces) collapsed in the default (full) mode.
Markdown now appends the KaTeX chunk CSS once, as master did. Measured: the svg
heights and a screenshot are identical to master on Vue 3 and Vue 2.

Without the vuetify feature, a Vuetify widget that first shows after the mount
(a date picker behind a condition) never rendered: requirejs maps
'jupyter-vuetify' to 'nbextensions/jupyter-vuetify/nodeps', so the
onResourceLoad hook never matched and addApp never ran.

With SOLARA_DEFAULT_CONTAINER=Fragment (the solara 2.0 CI run), every minimal
page loaded jupyter-controls for reacton's FragmentWidget (a VBox), which the
page never shows. Without jupyter-controls the default fragment now has an
ipyvue model.

CI fixes: read the golden page as UTF-8 (Windows), match exact CDN versions in
Windows paths (the immutable header was never sent there), and make the
gc.freeze test robust on Python 3.12, where every collection moves the
immortal objects (375) to the permanent generation, also after gc.unfreeze().
The frontend page test no longer gc.freezes the whole test process.

Also update the self-hosted docs on compression.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@maartenbreddels
maartenbreddels force-pushed the t3code/speed-page-and-kernel-load branch from 9a4327d to 079be9b Compare October 3, 2026 04:28
…gration CI working

The table in the new "Frontend features" docs section had a `|---|---|`
separator. The website splits a page on "---" to cut off its front matter,
so the extra "---" crashed the Solara server docs page with a traceback.
The table now uses `| - | - |` as other docs pages do, and the front matter
split stops after the first two "---", so a table or a horizontal rule in
a page body cannot break a page again.

A page that loads MathJax 2 itself (for example from assets/custom.js)
had its Markdown math typeset with MathJax before the frontend features.
The bundled KaTeX branch came first, so these pages switched to KaTeX.
Markdown now checks an existing renderMathInElement and MathJax first, as
before, in mounted() and in updated().

test_minimal_explicit_applayout failed on CI when a test that served the
solara website ran earlier in the same worker. The page template
environment is cached per process with the first app's template
directory, so later pages loaded the website template and its mailchimp
script, which throws on every other page. The frontend chunk tests now
clear that cache, so they always test the default template.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…pen pages working after a frontend change

With --frontend minimal (or any value without vuetify), Vuetify widgets
loaded on first use ignored solara.lab.theme: a custom primary color did
nothing, and even the default colors differed from full. The page root
is then an ipyvue.Html, so no ipyvuetify VuetifyView sets up the theme,
and the server never created the theme widgets. The server now creates
them when the page reports the Vuetify lazy load (with the themes the
page sent with run), and the widget manager lets the Vuetify chunk follow
each ThemeColorsModel. Full is unchanged.

A hot reload that changed solara.server.settings.main.frontend from
minimal to full left a blank page: the server built the full layout for
a shell without Vuetify. The page now sends the features it preloaded
with run, and the kernel keeps building widgets for that page until a
refresh.

A single "$" (a shell prompt in a code block, or a label such as
"Price ($)") made minimal pages download KaTeX and log a wrong +katex
warning. Math detection now needs a closing delimiter and skips code, as
KaTeX's auto-render does, so full renders the same math as before.

Also: the error for a removed feature now suggests a value that works
(all features that need it), and an explicit --log-level now applies to
the frontend warnings.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…h Vuetify that loads on first use

The first solara.Markdown of a page rendered math only when the server
found a delimiter in the HTML. Math written with character references
(&#36;x&#36;, &dollar;) has no "$" in the HTML, but the Vue template
compiler decodes it, so master rendered it and full did not. The first
Markdown now always renders math after it loads KaTeX, as on master, and
the server decodes character references before it looks for delimiters.

The new math check used regexes that scan to the end for each unclosed
"\(" or <code>. A chat app that shows user text with solara.Markdown
could then hold the GIL for seconds (6 s for 100 KB). The checks in
Python and in the widget manager now use plain string searches.

With minimal, the theme widgets got the theme.js colors of the page when
Vuetify loaded on first use, over the colors that the app had already
set. A new theme now starts from the theme of the page, and existing
theme widgets keep the app's changes, as in full.

The frontend value that a page sends with run had no size limit and goes
into process-wide warnings. A value that is longer than any page sends
is now ignored, so the server setting applies.

The integration tests also ignore one more console error that flask
gives for a closed websocket when a test leaves a page.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
maartenbreddels and others added 3 commits October 3, 2026 12:37
… block

A custom template can extend solara.html.j2 and replace the header block
without super(), for example with a copy of an older header. That page has
no link to the KaTeX chunk CSS, so Markdown did not put KaTeX's CSS at the
end of head. KaTeX's rules then lost to `.jp-RenderedHTMLCommon svg
{height: auto}` from style.css, and the svg parts of math (radicals,
arrows, braces) collapsed without an error. Before the frontend features,
Markdown always appended KaTeX's CSS last, whatever the template did.

Markdown now builds the chunk CSS URL from the app bundle script, which is
outside the header block, and falls back to the CDN katex.min.css as
before. In a post-release simulation of such a template, the svg heights
are the same as on master again, with 0 different pixels.

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

Vuetify's typography assumes the Roboto font. With --frontend minimal the
page did not load Roboto, but on Vue 2 Vuetify stays on (it is in the core
bundle), so text such as a "caption font-weight-light" span fell back to
another font, and no warning named roboto. Measured on a real Vue 2 app: 28
DOM diff blocks on one page with minimal, 0 with "minimal,+roboto". Vue 3
had the same gap when Vuetify loads on first use (minimal) or with
"minimal,+vuetify".

Roboto now comes with vuetify: the feature table adds it to every setting
with vuetify (also when the Vue 2 build forces Vuetify on), and a lazy
Vuetify on Vue 3 loads the Roboto CSS into its slot, the place of a
preloaded link. An app that uses its own font does not want Roboto, so
roboto is not a hard requirement as mdi is: "-roboto" leaves it out, for
example "minimal,-roboto", "full,-roboto" or "minimal,+vuetify,-roboto".
This also holds for a lazy Vuetify: the browser reads the page's spec.
The rendered full and legacy pages are byte-identical.

Tests: new unit and integration tests cover Roboto with Vuetify (Vue 2
minimal, Vue 3 lazy Vuetify, full) and the opt-out (no link, no font
faces, no lazy Roboto chunk). They fail without the fix.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The UMD wrapper calls its factory at once. V8 compiles a function in
parentheses eagerly, as part of the script compile that Chrome streams off
the main thread. A function without them compiles lazily, on the main
thread, when it is called. The published bundles pass the factory in
parentheses: "}(self,(()=>(()=>{". The frontend features build added
terser-webpack-plugin 5.6 with terser 5.51, and terser 5.43 turned the
wrap_func_args format option off by default. Our bundles had
"}(self,()=>(()=>{", so the whole core compiled lazily on the main thread:
about 65-80 ms at 4x CPU on a hello page, and about 80 ms on a real Vue 2
app. That ate part of the gain of the smaller core.

The production builds turn wrap_func_args on again (ascii_only stays),
which gives the terser output style of the published bundles. The
development builds are not minified, and webpack never wrapped their
factory (also not in the published bundles). A small webpack plugin adds
the parentheses there, and fails the build when the wrapper does not look
as expected.

Measured on a hello page, cold browser, 4x CPU, median of 5 runs (Chrome
trace, main thread): factory compile Vue 3 66 -> 0 ms, Vue 2 82 -> 0 ms;
run of the core script Vue 3 428 -> 343 ms, Vue 2 619 -> 511 ms.
Development builds (3 runs): factory compile Vue 3 130 -> 0 ms, Vue 2
148 -> 0 ms. The production cores grow by 0.4-0.5 KB gzip (the wrapped
callbacks, as in the published bundles). A unit test checks the wrapper
bytes of every built core.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
maartenbreddels and others added 3 commits October 4, 2026 13:01
The starlette extra, which pip install solara always pulls, required
brotli. Brotli has no wheels for Windows ARM64, Python 3.15, PyPy or
free-threaded CPython, so installing solara there failed without a C
compiler. The server already falls back to gzip when brotli is missing,
so brotli does not need to be a hard dependency.

Brotli now lives in its own solara-server[brotli] extra, with
solara[brotli] as the short form. gzip stays the default, as on master.
solara-server[all] includes the brotli extra, so the dev install and CI
still test the brotli path. A new unit test covers the gzip fallback
for clients that accept br on an install without brotli.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Vuetify lets an app build its own stylesheet, for example from Vuetify's
SASS with other variables. Such an app does not want Solara's copy of
Vuetify's CSS on the page: it is about 70 KB gzip for nothing, and its rules
fight the app's own rules. Before, there was no way to leave it out: on
Vue 3 it loaded with the vuetify chunk, and on Vue 2 it was part of the core
CSS (main{M}.css).

Vuetify's CSS is now the frontend feature vuetify-css. It comes with
vuetify, as Roboto does, so nothing changes by default, and "-vuetify-css"
leaves it out (for example "full,-vuetify-css" or
"minimal,+vuetify,-vuetify-css"), on Vue 2 and Vue 3, also when Vuetify
loads on first use. A page without Vuetify ("full,-vuetify") gets no
Vuetify CSS, as before.

The file keeps its name, main{M}.vuetify.css, and its place in the page:
on Vue 3 right after the core CSS, where the CSS of the vuetify chunk was,
and on Vue 2 right before the core CSS, where it was inside main{M}.css. On
Vue 3, Vuetify's components import their own CSS, so a small webpack plugin
moves the CSS of the vuetify chunk into the vuetify-css chunk in the same
order; the CSS files are byte-identical to the previous build.

Checked against the previous build in production mode with ipywidgets 8,
full mode, on a page with about 30 kinds of Vuetify widgets: 0 pixels
differ and the computed styles of every element are equal, on Vue 2 and
Vue 3. On Vue 2 the old main8.css and the new main8.vuetify.css plus
main8.css have the same rules in the same order. The Vue 3 full page only
gains "vuetify-css" in its feature list.

Not covered: the theme stylesheet that Vuetify makes in JavaScript stays,
and on Vue 3 the published ipyvuetify 3.0.0 still adds its own copy of
Vuetify's CSS (widgetti/ipyvuetify#346 removes it).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Making brotli opt-in took it out of the starlette extra, so solara[all] no
longer installed it. solara[all] means everything, so it pulls the new
brotli extra again.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
maartenbreddels and others added 7 commits October 4, 2026 14:05
The benchmark collected long animation frames into window.__loaf on every
visit, but nothing read them. It only added browser work to a timing run.
The comment pointed at a prototype that is not in the repository.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Nothing read DedupePackagesPlugin.count. It was left over from measuring
how many modules the plugin redirects.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The frontend features docs table now uses "| - | - |" separators, so the
page has no extra "---" and the parser fix is not needed for this change.
It and its test, which renders every docs page, can go in a separate PR.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The docs said the full preset preloads every feature. Mermaid is the
exception: it comes from its own package and loads when the first
solara.Markdown mounts, as on master.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
When the warm-up worked but every measured cold visit failed (for example
timeouts), the config had no numbers and was not marked failed. Its row
left the summary, a profile where every config failed lost its whole
table, and the command still exited with 0. CI could go green without one
measured number.

Now such a config is marked failed, so the command exits with 1 and the
row says why. A profile that ran always gets its table.

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

The codemirror chunk loads on the first fenced code block in an Output
widget's Markdown. When that request failed once (for example a network
hiccup), the rejected promise stayed cached, and no later code block could
get highlighting until a page refresh. On master CodeMirror was in the core
bundle, so this could not happen. The feature loader already forgets a
failed load in the same way.

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

A page without the vuetify feature tells the server when Vuetify loads, and
only then does the server create the theme widgets. After a soft-remount
the fresh kernel never heard that message. So when the app had changed a
theme color in the old kernel, the page kept that color, while in full
mode the fresh kernel resets it to the theme of the page.

The page now sends every feature that it loaded on first use again to the
fresh kernel. In full mode nothing loads on first use, so nothing changes
there.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
With SOLARA_DEFAULT_CONTAINER=Fragment and --frontend minimal, the page
loaded jupyter-controls, because reacton's FragmentWidget is an ipywidgets
VBox. The special case avoided that with an ipyvue model for the fragment
widget. It needed a component (_DefaultFragment), a widget class
(FragmentVue), a hook in the default container setup, a helper in the
frontend module and its own tests. That is too much code for a rare opt-in
combination.

Now this combination acts as any widget that needs a feature that the page
does not preload: the page loads jupyter-controls on first use, and the
server and the browser log one warning. Full mode does not change.

test_portal_without_layout_warns accepts that one warning when the default
container is Fragment, as in the "solara 2.0" unit test run in CI. The
server docs say that minimal loads jupyter-controls with this setting.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
maartenbreddels and others added 3 commits October 5, 2026 09:08
The previous commit made reacton's FragmentWidget load jupyter-controls in
minimal mode. With SOLARA_DEFAULT_CONTAINER=Fragment, the late date picker
app renders a button and a date picker as siblings, so reacton wraps them in
a FragmentWidget. The test then failed locally, because it expected only the
vuetify warning.

The docs said that minimal loads jupyter-controls on the first page with this
setting. That is not true for a page with one root element: reacton only
wraps two or more sibling elements. The docs now say when the load happens.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
On Vue 3, minimal could leave out Vuetify. That mode needed a Vue-only
twin class for every Solara template, a page shell and loaders without
Vuetify, its own CSS file, and theme fixes for a Vuetify that loads on
first use. It helps only Vue 3 apps that show no Vuetify widget at all.
Most Solara apps use a Button, an input, a Card or AppLayout, so Vuetify
loads anyway, and in this mode they lose the sidebar, the app bar and
dark mode.

Vuetify is now always on, as on Vue 2. The Vue 3 vuetify chunk stays a
separate file, and the page always preloads it. Full mode does not
change. Minimal on Vue 3 now preloads Vuetify, its CSS, mdi and Roboto.
When the vuetify chunk fails to load, the page breaks, as on master.

The dashboard_lite benchmark app goes too: it was the dashboard without
Vuetify widgets, made for this mode. The mode itself moves to a separate
experiment branch, to show what it entails.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The removal of the mode without Vuetify also deleted the two theme browser
tests, because their minimal case expected Vuetify to load on first use.
Their full case was the only browser test that checks that Vuetify gets
the colors of solara.lab.theme, follows a change the app makes, and that
the fresh kernel of a soft-remount resets the color. That behavior stays,
so the tests come back. Both presets now preload Vuetify and expect no
lazy loads, and the tests run on Vue 2 too, with ipyvuetify 1's default
primary color.

The docs said that minimal has only vuetify and mdi, but the page also
loads roboto and vuetify-css, which come with vuetify.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Compressing each static file once (with opt-in brotli) does not depend
on the frontend split. Its own PR on master makes both changes easier
to review, and lets it land on its own. This removes compress.py, the
SolaraGZipMiddleware, the brotli extras, the compression docs and the
compression tests from this branch.

The Windows fix for the immutable header of the CDN proxy stays here,
with its unit test, because it does not need the compression.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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>
…che path

On Windows, print() ends the line with CRLF, and bash command substitution
removes only the LF. The CR then became part of the cache path, Solara could
not create the folder and turned the assets proxy off, so the Windows job
still loaded the published bundle. sys.stdout.write() prints no line end.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
maartenbreddels added a commit that referenced this pull request Oct 6, 2026
* perf: compress each static file once instead of on every request

Master gzips every static asset again on every request, at level 9. The
measurement for #1235 put this at about 350 ms of server CPU per cold
Vue 3 page load, and every new visitor pays it again. The files do not
change while the server runs, so this work is wasted.

The static mounts (also the CDN proxy) now compress each file once per
path, mtime, size and encoding, and keep the result in a 64 MB memory
cache. The GZip middleware sends such a response as it is, so nothing
gets compressed twice. Other responses get gzip on each request, as
before.

Brotli gives smaller files, but it is opt-in with solara[brotli] or
solara-server[brotli]. It has no wheels for some platforms (Windows
ARM64, Python 3.15, PyPy), so a hard dependency would break
pip install solara there. Without brotli the server uses gzip.

Validation: tests/unit/static_cache_headers_test.py passes with brotli
installed (32 passed) and with brotli hidden (30 passed, 2 brotli tests
skipped).

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

* fix: respect the client's Accept-Encoding weights

With brotli installed, the server picked brotli for any client that
listed it, also when the client gave gzip or identity a higher q value,
such as "gzip;q=1, br;q=0.1". The client then got an encoding it said
it liked less. Now the highest q value wins, and brotli only wins a tie
with gzip.

Validation: tests/unit/static_cache_headers_test.py passes (32 passed).

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

* fix: serve the edited file in development, not a stale compressed copy

The compression cache found an entry by path, mtime and size. An edit
that keeps both the size and the mtime then got the old compressed
bytes. In development the ?v= hash comes from the current content, so
the new url got the old bytes with an immutable cache header, and the
browser kept them. Development mode already hashes the content on every
request for this reason. The cache key now holds that hash in
development. Production keeps the key without the hash, because there
the files do not change while the server runs.

Validation: tests/unit/static_cache_headers_test.py passes (33 passed).
The new test fails without this change.

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

* fix: give a 304 the same ETag and Vary as the compressed 200

A 200 for a compressed file has a weak ETag and Vary: Accept-Encoding,
because its body is not the file's bytes. The 304 for the same request
had the file's strong ETag and no Vary, because Starlette builds the 304
before the compression step runs. HTTP requires a 304 to carry the ETag
and Vary of the 200 it stands for (RFC 9110, section 15.4.5), and a cache
could tie the strong ETag of the plain file to the compressed bytes. The
static mounts now give such a 304 the same weak ETag and Vary.

Validation: tests/unit/static_cache_headers_test.py passes (33 passed).
The new assertions fail without this change.

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

* fix: compress from the cache whenever the GZip middleware would gzip

The q-weight fix could choose identity for a header such as
"gzip;q=0.1, identity;q=1". The GZip middleware runs after our choice
and gzips any response when "gzip" is in Accept-Encoding, whatever its
q value. That response then got compressed on every request, which is
the cost this change removes. A q value of NaN also passed as valid, so
"br;q=NaN, gzip;q=1" got brotli.

Now our choice follows the middleware: when "gzip" is in the header,
the server compresses, so it never sends identity there. It sends
brotli when brotli is installed and br has a q value above 0 and at
least the q value of gzip. Else it sends gzip, or identity when "gzip"
is not in the header. A q value counts only when it is a number from 0
to 1. Master's middleware also ignores q for gzip, so this is no
regression.

Validation: tests/unit/static_cache_headers_test.py passes with brotli
installed (43 passed) and with brotli hidden (34 passed, 9 brotli
tests skipped). The new tests go through the GZip middleware, and 4 of
them fail on the previous chooser.

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

* fix: check for gzip the same case-sensitive way as the GZip middleware

The encoding choice lowercased the whole header before it checked for
"gzip", but Starlette's GZipMiddleware checks the raw header. A client that
sent "GZIP;q=0, identity;q=1" refused gzip and still got it from us, while
the middleware would have left the response alone. Now only the q weights
use the lowercased names, and the gzip check matches the middleware's.

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

* fix: weigh gzip against brotli only when the GZip middleware would gzip

5aa73f2 made the gzip check case-sensitive, like the middleware, but the
brotli comparison still used the lowercased gzip weight. So
"GZIP;q=1, br;q=0.5, identity;q=0" lost brotli to a gzip that the middleware
would not send, and got the identity response the client refused. Now gzip
counts only when the middleware would gzip. A test runs 1,764 headers and
checks that the choice and the middleware always agree.

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

* fix: read q values by the HTTP grammar, and revalidate weak ETags on old Starlette

float() takes "1e0", "0_1" and "nan", so a header such as "br;q=1e0" got
brotli although HTTP does not allow that q value; master sends such a
client the file as it is. q values now follow RFC 9110's qvalue grammar.

Compressed responses carry a weak ETag. Starlette before 0.35 compares
If-None-Match exactly, so a browser that revalidated got the whole file
again instead of a 304. The static mounts now also compare the tags
without their W/ prefix, whatever the Starlette version.

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

* fix: match only the one weak ETag a browser sends back

The override split If-None-Match on commas to find our weak tag. With
Starlette before 0.35, whose ETag has no quotes, one quoted tag with the
hash between commas could then match and give a false 304. A browser sends
back exactly the ETag it got, so the override now matches only that single
W/ tag and leaves every other header to Starlette.

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…y's CSS

ipyvuetify 3.1.0 (widgetti/ipyvuetify#350) makes nodeps.js use the host's Vuetify, so -vuetify-css now removes all of Vuetify's stylesheet there. The sentence said this was true for ipyvuetify in general.

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

This branch had an error being deployed

1 failed deployment
t3code/speed-page-and-kernel-load - solara-stable PR #1235 — 3944ded9 Deployed Oct 6, 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