Skip to content

Experiment: per-component Quasar chunks via async component resolution - #260

Draft
evnchn wants to merge 3 commits into
mainfrom
spike2/quasar-umd
Draft

Experiment: per-component Quasar chunks via async component resolution#260
evnchn wants to merge 3 commits into
mainfrom
spike2/quasar-umd

Conversation

@evnchn

@evnchn evnchn commented Jul 26, 2026

Copy link
Copy Markdown
Owner

The objection that killed this in round 1 is dissolved — but the scaling curve says don't ship it. Opening as a design conversation, not a merge candidate.

Vue.resolveComponent is synchronous, but the object it returns need not be: an unknown q-* tag is answered with Vue.defineAsyncComponent via a Proxy on app._context.components. Demonstrated with ui.knob(), q-linear-progress, q-circular-progress and ui.dialog() created ~1 s after mount, on a page whose initial HTML never names them — 0 page errors, all render.

Round 2 (treeshake-round2-2026-07-26). Boxing deliberately looser than round 1: up to 6 approaches, ~90 tool calls. Outcome works, 4 attempt(s).

Motivation

It works, and the open-tag-set objection is NOT fatal. I built per-component Quasar chunks from quasar/src with esbuild (123 components, 230 output files) and rendered real NiceGUI pages with them in Chromium — buttons, table, select popup, tabs, date picker, dialog, notify, and ui.element('q-chip') all render with zero page errors and zero console warnings.

The synchronous-resolveComponent problem dissolves once you notice that resolveComponent is sync but the object it returns need not be: an unknown q-* name is answered with Vue.defineAsyncComponent that fetches its own chunk. I install this as a Proxy on app._context.components, which also covers q-* tags appearing inside NiceGUI's own compiled Vue templates (where the call site is Vue's, not NiceGUI's). Verified concretely: ui.knob(), ui.element('q-linear-progress'), q-circular-progress and ui.dialog() created ~1s AFTER mount, on a page whose initial HTML mentions only QBtn/QLayout/QPage/QPageContainer, all render correctly.

For the initial paint the server avoids that round trip by scanning what it can see — element tags, slot templates, and the .js/.vue component sources of elements on the page (this is how <q-table> inside table.js is found) — and emitting static imports handed to app.use(Quasar, {components}).

Headline wire measurement (Playwright request capture, brotli -q 11 of the served bytes): hello world drops from 129,332 B in 1 request to 34,107 B in 26 requests (-74%); +notify 42,064 B. A 15-component page: 107,684 B in 97 requests (-17%). Loading the WHOLE library costs 211,927 B — worse than the 129,332 B monolith, because per-file brotli loses the shared dictionary.

Test suite run against a same-machine baseline of the unmodified base commit: baseline 9 failed/980 passed, spike 13 failed/976 passed. Only 4 spike-only failures, all understood: 2 assert the quasar.umd*.js script tag I removed, and 2 call Quasar.Notify.create() from user HTML (the cost of making Notify a lazily-loaded plugin).

Implementation

All in /Users/evnchn/worktrees/tb2-quasar-umd, commits ea3f3dc then f5671a0.

/Users/evnchn/worktrees/tb2-quasar-umd/build_quasar_chunks.mjs (new)
esbuild build from node_modules/quasar/src. Emits nicegui/static/quasar/{core.js, c/QBtn.js x123,
p/.js x5, s/.js shared chunks, manifest.json}. Core is built in its own non-splitting
pass (always loaded in full -> 1 request); components are split among themselves. vue stays external
and resolves through NiceGUI's existing importmap. Quasar's build-time defines (QUASAR_VERSION,
QUASAR_SSR*, Q_META) are provided. FAT_CORE=1 and SPLIT_CORE=1 switch the variants I measured.

/Users/evnchn/worktrees/tb2-quasar-umd/probe_corecontents.mjs (new) — per-module-group byte breakdown of the core.

nicegui/templates/index.html
<script defer src=.../quasar.umd.prod.js> -> <script type="module" src=.../quasar/core.js>.
New {{ quasar_imports }} slot rendered BEFORE {{ vue_config_script }} (ordering is load-bearing —
see the bug note), and installQuasarComponentResolver(app, ...) immediately before app.mount("#app").

nicegui/static/nicegui.js
registerQuasarComponent(c) collects statically imported components into window.__nicegui_quasar_components.
installQuasarComponentResolver(app, prefix) wraps app._context.components in a Proxy whose get trap
returns Vue.defineAsyncComponent(() => import(prefix + Pascal + '.js')) for a q-* name that is not
registered — gated on window.Quasar.installed and on Quasar.componentNames (the build bakes the real
123-name set into core.js) so a non-Quasar q--prefixed tag is not turned into a 404ing async component,
and skipped when the PascalCase form IS registered so the async stub cannot shadow the real component.
notify: handler is now async (msg) => (await Quasar.loadPlugin("Notify")).create(msg).

nicegui/elements/notification.js — awaits Quasar.loadPlugin("Notify"); update/dismiss chain off that promise.

nicegui/dependencies.py — _quasar_tags(elements) scans element tags, slot templates, element component
sources (functools.cache'd) and registered .vue component HTML; emits import+registerQuasarComponent
lines as a new 7th return value, and adds the transitive chunk list to js_imports_urls for modulepreload.

nicegui/client.py — threads quasar_imports into the template context.

.pre-commit-config.yaml — excludes the generated nicegui/static/quasar/ tree from codespell.

Why it still shouldn't merge — the curve inverts:

page brotli requests vs 129,332 B monolith
hello world 34,107 26 −95,225 (−74%)
+ notify 42,064 34 −87,268
15 components 107,684 97 −21,648 (−17%)
whole library 211,927 229 +82,595 (WORSE)

Per-file brotli loses the shared dictionary — concatenating the same files and compressing once gives 136,540 B. So the realistic figure is −17%, not −74%, decaying to a regression as an app gets richer. Add: 97 requests over HTTP/1.1's 6-way parallelism with no wall-clock measurement at all, a genuine break of window.Quasar.Notify / Quasar.date / Quasar.colors (NiceGUI's own suite proves users depend on these), an async fallback that degrades silently to a correct-looking page with empty $refs, and 230 files / 1.2 MB added to the wheel on top of the 502,975 B UMD still in the tree.

Measured evidence

BUILD (node build_quasar_chunks.mjs, brotli via node zlib; "cat-br" = the files concatenated then compressed once):
components: 123, lazy plugins: Dialog,BottomSheet,Loading,LoadingBar,Notify
core only files= 1 raw= 69164 sep-br= 19379 cat-br= 19379
hello world (q-btn) files= 17 raw= 90043 sep-br= 28182 cat-br= 25048
realistic (33 comps) files=125 raw= 325012 sep-br=117312 cat-br= 80154
EVERYTHING files=229 raw= 582771 sep-br=211927 cat-br=136540
baseline quasar.umd.prod.js raw=502975 br=129332

CORE COMPOSITION (probe_corecontents.mjs, esbuild metafile bytesInOutput) — why the core was fat:
fat core (all plugins+utils+composables) raw=133209: utils/morph 15100, utils/date 11942,
plugins/notify 7252, COMPONENT:btn 7173, COMPONENT:uploader 6910, utils/colors 3991 ...
lean core (no utils/composables, 5 lazy plugins) raw=65336 -> shipped core.js is 69164 B raw / 19379 br
Individually: utils br=25407, directives br=11766, install-quasar br=5729,
plugin dialog br=26844, notify br=12620, bottom-sheet br=13543, loading br=6701, loading-bar br=6014

REAL WIRE BYTES (.spike/wire.py — Playwright requestfinished capture, brotli -c -q 11 on the served files,
fresh browser context per page; server log line confirms TEMPLATES ['/Users/evnchn/worktrees/tb2-quasar-umd/nicegui/templates']):
/ initial 26 requests 34107 B brotli
/ incl. notify 34 requests 42064 B brotli
/rich initial 97 requests 107684 B brotli
BASELINE quasar.umd.prod.js 1 requests 129332 B brotli
warm cache (manifest closure diff): /rich after / costs only 72 files / 73747 B

RENDER PROOF (.spike/render.py, Chromium):
===== / ===== pageerrors: [] console errors/warnings: []
buttons: 1 notify visible: 1 label text: hello world|CLICK ME
===== /rich ===== pageerrors: [] console errors/warnings: []
buttons: 37 q-chip: 1 q-table rows: 2 q-date: 1 q-select: 1
tab panel text: panel one -> after tab switch: panel two select menu open: 1
(full-page screenshot inspected: card/input/select/checkbox/slider, table, tabs, date picker all correct)

OPEN-TAG-SET PROOF (.spike/dynrender.py) — components added AFTER mount, absent from initial HTML:
initial page component imports: ['QBtn', 'QLayout', 'QPage', 'QPageContainer']
pageerrors: [] console err/warn: []
.q-knob: 1 .q-linear-progress: 1 .q-circular-progress: 2 .q-dialog: 1
dialog text: in dialog

SERVER-SIDE TAG SCAN (.spike/probe.py, on /rich):
15 components resolved statically incl. QTable (found by scanning nicegui/elements/table.js, whose
element tag is nicegui-table, not q-table) and QChip (from ui.element('q-chip'))

TEST SUITE — both runs on this machine, pytest -q -rs -p no:randomly, PYTHONPATH pinned:
BASELINE (base commit 64e78ad, extracted via git archive to /tmp/ngbase):
9 failed, 980 passed, 3 skipped, 2 xfailed in 974.83s
SPIKE (f5671a0):
13 failed, 976 passed, 3 skipped, 2 xfailed, 2 errors in 987.78s
SKIPPED reasons (-rs): tests/test_run.py "needs a platform with a fork default (e.g. Linux)" x3 — same both runs.
SET DIFFERENCE (comm on the failure header lists) — spike-only:
test_dev_mode, test_prod_mode <- assert //script[@src=".../quasar.umd.js"] exists; I removed that tag
test_sanitize, test_text_vs_html (+2 teardown errors)
<- both do ui.html(''),
i.e. window.Quasar.Notify must exist synchronously
Pre-existing on this machine (identical in both): test_dark_mode[None|mini|wind3|wind4],
test_module_access_does_not_import_others (that one shells out to bare python3: "No module named 'socketio'").

TWO BUGS FOUND AND FIXED, both of which rendered a page that LOOKED fine:

  1. Registering with app.component() instead of through app.use(Quasar,{components}) broke test_element_plus:
    "TypeError: Cannot read properties of undefined (reading 'screen') at setup (.../QLayout.js)" — that test
    replaces vue_config_script, so app.use(Quasar) never runs and $q is undefined. With the UMD build no Quasar
    components were registered either, so nothing rendered and nothing crashed.
  2. Emitting the register calls into js_imports (which the template renders AFTER vue_config_script) meant
    window.__nicegui_quasar_components was empty at install time, so NOTHING was registered and every element
    silently fell through the async fallback. The page rendered correctly but:
    registered: ['nicegui-notification'] refs: ['r0'] (baseline: refs r0..r5)
    Empty $refs breaks run_method, so ui.notification.dismiss() did nothing. Fixed with a quasar_imports
    template slot emitted before vue_config_script:
    registered: ['QBtn','QLayout','QPage','QPageContainer','nicegui-notification'] refs: r0..r5, dismiss works.

METHODOLOGY TRAP WORTH FLAGGING: the pre-commit ruff --fix hook reordered import sys / sys.path.insert()
in my probe script, so two verification runs silently exercised the CANONICAL /Users/evnchn/nicegui checkout
instead of the worktree (they reported "0 quasar chunk requests" while claiming success). All numbers above
were re-taken with PYTHONPATH pinned and each server printing its resolved template dir as proof.

Test results

Full suite run twice, pytest -q -rs -p no:randomly (with -rs so skips are visible), same machine, same session, PYTHONPATH pinned to the tree under test.
SPIKE (f5671a0): 13 failed, 976 passed, 3 skipped, 2 xfailed, 2 errors in 987.78s
BASELINE (64e78ad, git-archived to /tmp/ngbase): 9 failed, 980 passed, 3 skipped, 2 xfailed in 974.83s
Spike-only delta = 4 failures + 2 teardown errors, all diagnosed:

  • test_dev_mode, test_prod_mode: assert the quasar.umd.js / quasar.umd.prod.js script tags, which this
    change removes. Expected; but they do encode a real gap (no unminified dev build of the chunks).
  • test_sanitize, test_text_vs_html (+their teardown errors): call Quasar.Notify.create(...) from user HTML.
    Genuine API break from making Notify a lazily-loaded plugin, not a chunking bug.
    The 9 baseline failures (test_dark_mode x4, test_module_access_does_not_import_others, counted twice each in the
    header scan) are pre-existing on this macOS box and identical in both runs.
    Skip reasons read: all 3 are tests/test_run.py "needs a platform with a fork default (e.g. Linux)" — unrelated to
    this change and identical in both runs, so nothing covering this work was silently skipped.
Blockers / risk

Blockers: No blocker on the mechanism — it works end to end. What remains is cost/benefit, and three specific costs:

  1. REQUEST COUNT. 26 requests for hello world, 97 for a 15-component page, vs 1 today. I emit for the whole transitive closure so there is no discovery waterfall, and shared chunks are content-hashed and immutable (a second page costs only its delta: /rich after / is 72 files instead of 98). But on HTTP/1.1 (uvicorn's default, i.e. ui.run() with no proxy) that is 6-way parallelism. I did not measure wall-clock load time — only bytes and request counts. That measurement is the main thing missing before anyone should trust this as a latency win rather than only a bytes win.

  2. THE WHOLE-LIBRARY CASE IS A REGRESSION. 229 files / 211,927 B vs 129,332 B for the monolith. Per-file brotli loses cross-file redundancy: concatenating the same files and compressing once gives 136,540 B. So a page that touches most of Quasar is worse off. The ideal architecture is a per-tag-set server-side bundle (hello world would be 25,048 B in 1 request instead of 34,107 B in 26) but that needs a bundler at request time, which I did not attempt.

  3. window.Quasar.* SHRINKS — and NiceGUI's own test suite proves users depend on it. Making Notify lazy is what broke test_sanitize and test_text_vs_html. Dropping the utils/composables namespaces likewise removes Quasar.date, Quasar.colors, Quasar.morph, Quasar.scroll, etc. from the global. A Quasar.loadPlugin(name) promise exists, but that is a breaking change for anything calling Quasar.Notify.create / Quasar.Dialog.create from run_javascript or from HTML. Fixable with a deferred-façade Proxy on window.Quasar (Notify.create can return a queuing handle) — I did not build it, so I cannot claim it works.

Also unresolved: there is no dev (unminified) variant of the chunks, so the prod_js=False path loses debuggable Quasar (this is what test_dev_mode/test_prod_mode really encode); and the build adds 230 files / 1.2 MB to the package, which needs a packaging/wheel decision I did not check.

Risk if shipped: Medium-high as a shipping change; low as a spike, since it is isolated in its own worktree and the mechanism is verified.

Behavioural risks, in order:

  • The async fallback DEGRADES SILENTLY. When registration was broken, every page still rendered and looked correct while $refs was empty and run_method quietly did nothing. Any future regression in the static-registration path will therefore not announce itself. If this is pursued, it needs a test that asserts the static path is actually used (e.g. mounted_app.$refs is fully populated, or that a hello-world page makes no post-mount chunk request), not just that the page renders.
  • window.Quasar.<Plugin> and window.Quasar.<util> are public surface that real code uses — NiceGUI's own test suite calls Quasar.Notify.create from user HTML. Any lean-core variant needs either a deferred façade or an explicit breaking-change note.
  • 97 requests on a cold visit to a moderately rich page is a real latency risk on HTTP/1.1 that I measured in bytes but not in wall-clock time.
  • The Proxy on app._context.components is an internals-level hook (_context); it is stable across Vue 3.x today but is not public API.
  • 230 generated files / 1.2 MB now live in the repo and would need a build-artifact story (they are currently committed, like quasar.umd.prod.js is).
  • Not reviewed by a second lineage (no Codex pass) — the boxing budget went to the two full test-suite runs instead.

Progress

  • The PR title is a short phrase starting with a verb.
  • The implementation is complete.
  • This PR does not address a security issue.
  • Pytests added/updated — see the test-results fold.
  • Documentation — see blockers if a contract changed.
  • No breaking changes to the public API, except where called out above.

evnchn and others added 3 commits July 25, 2026 00:14
DateInput applied .props('no-parent-event') to the inner QDate, but
Quasar 2.18.5's QDate has no such prop (noParentEvent belongs to the
anchor-props mixin behind QMenu/QTooltip). It was an inert no-op
attribute; removing it changes no behavior (open/close is driven by the
parent QMenu).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
@evnchn evnchn added experimental Experiment/POC - preserve for reference banger-source Source of strong ideas worth mining even if PR itself does not land treeshake-round2-2026-07-26 Round 2: dynamic element loading, plotly rollup, the untouched giants labels Jul 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

banger-source Source of strong ideas worth mining even if PR itself does not land experimental Experiment/POC - preserve for reference treeshake-round2-2026-07-26 Round 2: dynamic element loading, plotly rollup, the untouched giants

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant