Repository navigation
feat: load pages faster by splitting the frontend into optional features - #1235
Draft
maartenbreddels wants to merge 26 commits into
Draft
maartenbreddels wants to merge 26 commits into
maartenbreddels wants to merge 26 commits into
Conversation
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
force-pushed
the
t3code/speed-page-and-kernel-load
branch
from
October 3, 2026 04:28
9a4327d to
079be9b
Compare
…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 ($x$, $) 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>
… 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>
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>
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>
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>
This was referenced Oct 5, 2026
maartenbreddels
added this pull request to stack #1242
October 5, 2026 10:42
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
On the default install (Vue 3), the first widget of a cold page load now shows 23 to 32% sooner, and a new
--frontendoption lets an app leave out KaTeX, the Jupyter widget controls, fonts and more.Problem
A fresh
pip install solaragets 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: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);jupyter-vueandjupyter-vuetifyextensions, 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:
require.jsis served minified from Solara itself.jupyter-vueandjupyter-vuetify, so they no longer wait for the websocket or for each other.Markdownuses the KaTeX in the bundle instead of loading a second copy from the CDN, which also makes it work offline.ScriptMagicssetup (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-awesomeandmermaidcome from their own packages, andvue-sfcfrom ipyvue:vuetifymdi; it bringsrobotoandvuetify-cssunless you add-robotoor-vuetify-cssvuetify-css-vuetify-csswhen the app ships its own Vuetify CSSmdi,material-icons,roboto,font-awesomejupyter-controlsjupyter-css)sanitize-htmlchunk of 64 KBjupyter-cssoutput-widgetjupyter-css, shares thesanitize-htmlchunk)katexMarkdownand widget descriptionsmermaidMarkdown; never preloaded: with the feature on, it loads on the firstMarkdownmount, as on mastervue-sfcfull) behaves as before.--frontend/SOLARA_FRONTEND/solara.server.settings.main.frontendpicks a preset (full,minimal) and adds or removes features, for example--frontend minimal,+katexor--frontend full,-mermaid.SOLARA_DEFAULT_CONTAINER=Fragment,minimalloadsjupyter-controlsthe first time a component renders more than one element without a container, and warns once, because reacton'sFragmentis an ipywidgetsVBox.robotoandvuetify-csscome withvuetify. 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 ofmain{7,8}.cssintomain{7,8}.vuetify.css, at the same place in the page.mdiCSS chunk with Vuetify; the other features are chunks.tests/benchmarkmeasures 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.
fullloads about 740 KB gzip (was 1391 KB).minimalloads 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:
nodeps.js326 to 46 KB gzip).nodeps.jsuses the host's Vuetify instead of a second copy (354 to 18 KB gzip).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.minimalcell compares with master'sfullcell of the same app.fullis 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 2fullis 107 ms [35, 165] (hello) and 307 ms [253, 385] (dashboard) faster.minimalis 60 ms faster thanfullfor hello on both Vue versions at normal CPU, and about 320 ms faster at 4x CPU.runtofinishedas the browser sees it) went up, for example 37 to 154 ms. That is not server time: the PR sendsrunabout 450 ms earlier, while the browser still loads chunks. A timer aroundload_app_widgetin the server shows 8 to 11 ms per session on both master and the PR.tests/unit/frontend*_test.py,tests/integration/frontend_chunks_test.py,tests/benchmark.gc.freezeunit 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..vuetemplates, solara 1.63.1 as the base, assets served the way production does, 20 cold loads per setup):fulland 384 ms inminimal,-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,-robotomakes the home page 666 ms faster at 4x CPU (-28%).fulland 428 ms inminimal,-roboto. With the core-js update in ipyvue and ipyvuetify 1.x, it is 833 ms (−31%) and 927 ms (−35%) faster.full, in Chromium at 3 widths and in Firefox.minimalon 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 aSidebar(CSS minifier), the firstIPython.display.Markdownoutput 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 ssgandstaticbuild, and a mount under aroot_path.fullpreloadsmermaid.Upgrade notes
solara.html.j2in yourtemplates/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, andfonts.cssno 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.@jupyterlab/coreutilscan no longer format times:momentis now an empty module, soTime.formatandTime.formatHumanthrow. Before, only the Vue 2 production build for ipywidgets 8 did this. We found no widget library that calls these functions.Gaps
@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.shchecks both again.--frontend minimal, a render error loadsjupyter-controlsand suggests+jupyter-controls, because reacton's error display usesipywidgets.HTML. Fixing it needs a hook in reacton.solara.FigurePlotly(anywidget) and other third-party widgets need their own extensions as before; widgets that needjupyter-csslook unstyled inminimaluntil you add it.-vuetify-csson 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).GridDraggableas 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
minimalpreset, refined by Q15, Q27.--frontend minimal,+katex,full,-mermaid.solara-, variables--solara-. No theme/dark mode there.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.--frontend,SOLARA_FRONTEND,solara.server.settings.main.frontend.jqueryandlumino, 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.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.Crossreview results
Caution
/crossreviewwas 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:
Fragmentspecial 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.full(MEDIUM). gpt-6.1-sol also found a docs sentence that left outrobotoandvuetify-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.print()(P1). 8ef03a4 fixes it withsys.stdout.write(), one line, and the driver confirmed it as a small fix.🤖 Generated with Claude Code