Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,6 @@ node_modules/
.playwright-mcp/
.claude/settings.local.json
CLAUDE.local.md

.spike/
.quasar-entries/
1 change: 1 addition & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ repos:
.*/package-lock.json|
nicegui/static/vue\..*|
nicegui/static/quasar\..*|
nicegui/static/quasar/.*|
nicegui/static/sass\..*|
nicegui/static/dompurify\..*|
nicegui/static/unocss/.*|
Expand Down
188 changes: 188 additions & 0 deletions build_quasar_chunks.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
#!/usr/bin/env node
// Build per-component Quasar chunks from quasar/src (NOT the pre-bundled dist).
//
// Output layout (nicegui/static/quasar/):
// core.js - installQuasar + directives + plugins + utils + composables, sets window.Quasar
// c/QBtn.js - one entry per Quasar component, default-exports it
// p/Notify.js - one entry per lazily-loaded Quasar plugin
// s/<hash>.js - shared chunks (esbuild code splitting)
// manifest.json - transitive output-file list per component tag / plugin
import * as esbuild from 'esbuild';
import fs from 'node:fs';
import path from 'node:path';
import zlib from 'node:zlib';

const ROOT = path.resolve('.');
const Q = path.join(ROOT, 'node_modules', 'quasar');
const QSRC = path.join(Q, 'src');
const VERSION = JSON.parse(fs.readFileSync(path.join(Q, 'package.json'))).version;
const OUT = path.join(ROOT, 'nicegui', 'static', 'quasar');
const TMP = path.join(ROOT, '.quasar-entries');

// Plugins kept out of the always-loaded core because they drag whole components in.
const LAZY_PLUGINS = process.env.FAT_CORE ? [] : ['Dialog', 'BottomSheet', 'Loading', 'LoadingBar', 'Notify'];

const kebab = (n) => n.replace(/([a-z0-9])([A-Z])/g, '$1-$2').replace(/([A-Z])([A-Z][a-z])/g, '$1-$2').toLowerCase();

// ---- discover component name -> source file -----------------------------
const comps = {};
for (const dir of fs.readdirSync(path.join(QSRC, 'components'))) {
const idx = path.join(QSRC, 'components', dir, 'index.js');
if (!fs.existsSync(idx)) continue;
const src = fs.readFileSync(idx, 'utf8');
const imported = {};
for (const m of src.matchAll(/^import\s+(\w+)\s+from\s+'(.+?)'/gm)) {
imported[m[1]] = path.resolve(path.join(QSRC, 'components', dir), m[2]);
}
const em = src.match(/export\s*\{([\s\S]*?)\}/);
for (const name of (em ? em[1].split(',').map((s) => s.trim()).filter(Boolean) : [])) {
if (imported[name]) comps[name] = imported[name];
}
}

// ---- discover plugin name -> source file --------------------------------
const pluginsSrc = fs.readFileSync(path.join(QSRC, 'plugins.js'), 'utf8');
const pluginFile = {};
for (const m of pluginsSrc.matchAll(/^import\s+(\w+)\s+from\s+'(.+?)'/gm)) {
pluginFile[m[1]] = path.resolve(QSRC, m[2]);
}

// ---- write entry stubs ---------------------------------------------------
fs.rmSync(TMP, { recursive: true, force: true });
fs.mkdirSync(TMP, { recursive: true });
const entryPoints = {};
for (const [name, file] of Object.entries(comps)) {
const p = path.join(TMP, `${name}.js`);
fs.writeFileSync(p, `export { default } from ${JSON.stringify(file)}\n`);
entryPoints[`c/${name}`] = p;
}
for (const name of LAZY_PLUGINS) {
const p = path.join(TMP, `plugin_${name}.js`);
fs.writeFileSync(p, `export { default } from ${JSON.stringify(pluginFile[name])}\n`);
entryPoints[`p/${name}`] = p;
}
const eagerPlugins = Object.keys(pluginFile).filter((n) => !LAZY_PLUGINS.includes(n));
const LEAN = !process.env.FAT_CORE;
// `utils` and `composables` are only reachable as `window.Quasar.*` -- nothing in NiceGUI calls them, and
// they drag in morph (15 kB) / date (12 kB) / QUploader (7 kB). Keep them out of the always-loaded core.
const coreEntry = path.join(TMP, '__core.js');
fs.writeFileSync(coreEntry, `
import installQuasar from ${JSON.stringify(path.join(QSRC, 'install-quasar.js'))}
import * as directives from ${JSON.stringify(path.join(QSRC, 'directives.js'))}
${LEAN ? '' : `import * as utils from ${JSON.stringify(path.join(QSRC, 'utils.js'))}
import * as composables from ${JSON.stringify(path.join(QSRC, 'composables.js'))}`}
${eagerPlugins.map((n) => `import ${n} from ${JSON.stringify(pluginFile[n])}`).join('\n')}

const plugins = { ${eagerPlugins.join(', ')} }
let pluginOpts
const pending = {}
const Quasar = {
version: ${JSON.stringify(VERSION)},
install (app, opts) {
// components come from window.__nicegui_quasar_components so that registering them stays tied to
// app.use(Quasar, ...) -- an app that replaces vue_config_script gets no Quasar at all, as before.
installQuasar(app, { components: window.__nicegui_quasar_components, directives, plugins, ...opts })
pluginOpts = { parentApp: app, $q: app.config.globalProperties.$q,
lang: opts.lang, iconSet: opts.iconSet, onSSRHydrated: [] }
Quasar.installed = true
},
// Load one of the plugins that were left out of the core, install it and expose it as Quasar.<name>.
loadPlugin (name) {
return (pending[name] ||= import(new URL('./p/' + name + '.js', import.meta.url).href).then(({ default: p }) => {
if (p.__installed !== true) { p.install(pluginOpts); p.__installed = true }
Quasar[name] = p
return p
}))
},
installed: false,
// the names the lazy resolver in nicegui.js is allowed to fetch a chunk for
componentNames: new Set(${JSON.stringify(Object.keys(comps).flatMap((n) => [n, kebab(n)]))}),
lang: Lang,
iconSet: IconSet,
...directives,
...plugins${LEAN ? '' : ',\n ...composables,\n ...utils'}
}
window.Quasar = Quasar
export default Quasar
`);
entryPoints.core = coreEntry;

const SPLIT_CORE = !!process.env.SPLIT_CORE;
const common = {
bundle: true,
format: 'esm',
outdir: OUT,
chunkNames: 's/[hash]',
entryNames: '[dir]/[name]',
minify: true,
external: ['vue'],
metafile: true,
legalComments: 'none',
define: {
__QUASAR_VERSION__: JSON.stringify(VERSION),
__QUASAR_SSR__: 'false',
__QUASAR_SSR_SERVER__: 'false',
__QUASAR_SSR_CLIENT__: 'false',
__QUASAR_SSR_PWA__: 'false',
__Q_META__: 'false',
},
};
let result;
if (SPLIT_CORE) {
result = await esbuild.build({ ...common, entryPoints, splitting: true });
} else {
// core is always loaded in full, so bundle it as ONE file (no splitting -> one request);
// components are split among themselves and may duplicate a little of what core holds.
const { core, ...rest } = entryPoints;
const a = await esbuild.build({ ...common, entryPoints: { core }, splitting: false });
const b = await esbuild.build({ ...common, entryPoints: rest, splitting: true });
result = { metafile: { inputs: { ...a.metafile.inputs, ...b.metafile.inputs },
outputs: { ...a.metafile.outputs, ...b.metafile.outputs } } };
}
fs.rmSync(TMP, { recursive: true, force: true });

// ---- manifest ------------------------------------------------------------
const meta = result.metafile;
const entryOf = {};
for (const [file, o] of Object.entries(meta.outputs)) {
if (o.entryPoint) entryOf[path.relative(OUT, path.resolve(file)).replace(/\.js$/, '')] = path.resolve(file);
}
function closure(file, seen = new Set()) {
const rel = path.relative(ROOT, file);
if (seen.has(file) || !meta.outputs[rel]) return seen;
seen.add(file);
for (const i of meta.outputs[rel].imports) {
if (i.kind === 'import-statement') closure(path.resolve(i.path), seen);
}
return seen;
}
const files = (key) => [...closure(entryOf[key])].map((f) => path.relative(OUT, f));
const manifest = { core: files('core'), components: {}, plugins: {} };
for (const name of Object.keys(comps)) manifest.components[kebab(name)] = { entry: `c/${name}.js`, files: files(`c/${name}`) };
for (const name of LAZY_PLUGINS) manifest.plugins[name] = { entry: `p/${name}.js`, files: files(`p/${name}`) };
fs.writeFileSync(path.join(OUT, 'manifest.json'), JSON.stringify(manifest));

// ---- report --------------------------------------------------------------
const abs = (rel) => path.join(OUT, rel);
const cost = (tags, plugins = []) => {
const s = new Set(manifest.core);
for (const t of tags) for (const f of manifest.components[t].files) s.add(f);
for (const p of plugins) for (const f of manifest.plugins[p].files) s.add(f);
const fl = [...s];
const raw = fl.reduce((a, f) => a + fs.statSync(abs(f)).size, 0);
const sep = fl.reduce((a, f) => a + zlib.brotliCompressSync(fs.readFileSync(abs(f))).length, 0);
const cat = zlib.brotliCompressSync(Buffer.concat(fl.map((f) => fs.readFileSync(abs(f))))).length;
return `files=${String(fl.length).padStart(3)} raw=${String(raw).padStart(7)} sep-br=${String(sep).padStart(6)} cat-br=${String(cat).padStart(6)}`;
};
console.log('components:', Object.keys(comps).length, ' lazy plugins:', LAZY_PLUGINS.join(',') || '(none)');
console.log('core only ', cost([]));
console.log('hello world (q-btn) ', cost(['q-btn']));
const realistic = ['q-btn', 'q-icon', 'q-input', 'q-select', 'q-table', 'q-th', 'q-tr', 'q-td', 'q-card',
'q-card-section', 'q-dialog', 'q-checkbox', 'q-toggle', 'q-slider', 'q-tabs', 'q-tab', 'q-tab-panels',
'q-tab-panel', 'q-menu', 'q-list', 'q-item', 'q-item-section', 'q-separator', 'q-spinner', 'q-tooltip',
'q-badge', 'q-avatar', 'q-drawer', 'q-header', 'q-toolbar', 'q-layout', 'q-page', 'q-page-container'];
console.log('realistic (33 comps) ', cost(realistic, ['Dialog', 'Notify']));
console.log('EVERYTHING ', cost(Object.keys(manifest.components), LAZY_PLUGINS));
const umd = path.join(ROOT, 'nicegui', 'static', 'quasar.umd.prod.js');
console.log('baseline quasar.umd.prod.js raw=' + fs.statSync(umd).size,
'br=' + zlib.brotliCompressSync(fs.readFileSync(umd)).length);
3 changes: 2 additions & 1 deletion nicegui/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ def build_response(self, request: Request, status_code: int = 200) -> Response:
'next_message_id': self.outbox.next_message_id,
'implicit_handshake': not _is_prefetch(request),
}
vue_html, vue_styles, vue_scripts, imports, js_imports, js_imports_urls = \
vue_html, vue_styles, vue_scripts, imports, js_imports, js_imports_urls, quasar_imports = \
generate_resources(prefix, self.elements.values())
html_lang = self.page.resolve_language()
language = html_lang or 'en-US'
Expand All @@ -229,6 +229,7 @@ def build_response(self, request: Request, status_code: int = 200) -> Response:
'vue_scripts': '\n'.join(vue_scripts),
'imports': json.dumps(imports),
'js_imports': '\n'.join(js_imports),
'quasar_imports': '\n'.join(quasar_imports),
'js_imports_urls': js_imports_urls,
'vue_config': json.dumps(quasar_config),
'vue_config_script': core.app.config.vue_config_script,
Expand Down
51 changes: 50 additions & 1 deletion nicegui/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import functools
import importlib
import json
import re
import sys
from collections.abc import Callable, Iterable
from dataclasses import dataclass
Expand Down Expand Up @@ -219,11 +221,47 @@ def _get_name(path: Path) -> str:
return path.name.split('.', 1)[0]


QUASAR_STATIC = Path(__file__).parent / 'static' / 'quasar'
QUASAR_TAG_PATTERN = re.compile(r'<(q-[a-z0-9-]+)')


@functools.cache
def _quasar_manifest() -> dict:
return json.loads((QUASAR_STATIC / 'manifest.json').read_text())


@functools.cache
def _quasar_tags_in_file(path: Path) -> frozenset[str]:
return frozenset(QUASAR_TAG_PATTERN.findall(path.read_text(encoding='utf-8', errors='ignore')))


def _quasar_tags(elements: Iterable[Element]) -> list[str]:
"""Collect the Quasar component tags a page needs before its first render.

Anything missed here still works: `nicegui.js` falls back to an async component that fetches its own
chunk. This scan only avoids that extra round trip for the tags the server can see up front.
"""
known = _quasar_manifest()['components']
tags: set[str] = set()
for element in elements:
if element.tag.startswith('q-'):
tags.add(element.tag)
for slot in element.slots.values():
if slot.template:
tags.update(QUASAR_TAG_PATTERN.findall(slot.template))
if element.component:
tags.update(_quasar_tags_in_file(element.component.path))
for vue_component in vue_components.values():
tags.update(QUASAR_TAG_PATTERN.findall(vue_component.html))
return sorted(tag for tag in tags if tag in known)


def generate_resources(prefix: str, elements: Iterable[Element]) -> tuple[list[str],
list[str],
list[str],
dict[str, str],
list[str],
list[str],
list[str]]:
"""Generate the resources required by the elements to be sent to the client."""
done_libraries: set[str] = set()
Expand Down Expand Up @@ -277,4 +315,15 @@ def generate_resources(prefix: str, elements: Iterable[Element]) -> tuple[list[s
js_imports_urls.append(url)
done_components.add(js_component.key)

return vue_html, vue_styles, vue_scripts, imports, js_imports, js_imports_urls
# statically import the Quasar components this page is known to need; these must run before
# `app.use(Quasar, ...)`, which is what actually registers them
manifest = _quasar_manifest()
quasar_imports: list[str] = []
for i, tag in enumerate(_quasar_tags(elements)):
url = f'{prefix}/_nicegui/{__version__}/static/quasar/{manifest["components"][tag]["entry"]}'
quasar_imports.append(f'import {{ default as Q{i} }} from "{url}";')
quasar_imports.append(f'registerQuasarComponent(Q{i});')
js_imports_urls.extend(f'{prefix}/_nicegui/{__version__}/static/quasar/{file}'
for file in manifest['components'][tag]['files'])

return vue_html, vue_styles, vue_scripts, imports, js_imports, js_imports_urls, quasar_imports
2 changes: 1 addition & 1 deletion nicegui/elements/date_input.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ def __init__(self,
with self.add_slot('append'):
with button(icon='edit_calendar', color=None).props('flat round') as self.button:
with menu() as self.menu:
self.picker = date().props('no-parent-event').props('range' if range_input else '')
self.picker = date().props('range' if range_input else '')

self.picker.bind_value(self,
forward=lambda v: self._picker_to_input_value(v) if self._range_input else v,
Expand Down
8 changes: 5 additions & 3 deletions nicegui/elements/notification.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,16 @@ import { convertDynamicProperties } from "../../static/utils/dynamic_properties.

export default {
mounted() {
this.notification = Quasar.Notify.create(this.convertedOptions);
this.ready = Quasar.loadPlugin("Notify").then((Notify) => {
this.notification = Notify.create(this.convertedOptions);
});
},
methods: {
update_notification() {
this.notification(this.convertedOptions);
this.ready.then(() => this.notification(this.convertedOptions));
},
dismiss() {
this.notification();
this.ready.then(() => this.notification());
},
},
computed: {
Expand Down
36 changes: 35 additions & 1 deletion nicegui/static/nicegui.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,40 @@ const None = undefined;

let app = undefined;
let mounted_app = undefined;
let quasar_chunk_prefix = undefined;

// Lazily resolve Quasar components that were not statically registered for this page.
// `Vue.resolveComponent` is synchronous, but the component object it hands back may be async --
// so an unknown `q-*` tag becomes a `defineAsyncComponent` that fetches its own chunk.
const quasar_async_components = {};
const quasar_components = (window.__nicegui_quasar_components = {});

// Quasar components the server knows the page needs are imported statically and handed to
// `app.use(Quasar, ...)`, which registers them under their own name (e.g. "QBtn").
function registerQuasarComponent(component) {
quasar_components[component.name] = component;
}

function quasarComponentName(name) {
if (typeof name !== "string" || !/^[qQ][-A-Z]/.test(name)) return undefined;
const pascal = name.replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase()).replace(/^q/, "Q");
return window.Quasar?.componentNames?.has(name) ? pascal : undefined;
}

function installQuasarComponentResolver(app, prefix) {
quasar_chunk_prefix = prefix;
app._context.components = new Proxy(app._context.components, {
get(target, name) {
if (typeof name !== "string" || name in target) return target[name];
const pascal = quasarComponentName(name);
// `pascal in target` means Vue's own camelize/capitalize fallback will find the registered one
if (!pascal || pascal in target || !window.Quasar?.installed) return undefined;
return (quasar_async_components[pascal] ||= Vue.defineAsyncComponent(() =>
import(`${quasar_chunk_prefix}${pascal}.js`),
));
},
});
}

function initUnoCss() {
if (window.__unocss_runtime === undefined) return;
Expand Down Expand Up @@ -544,7 +578,7 @@ function createApp(elements, options) {
window.open(url, target);
},
download: (msg) => download(msg.src, msg.filename, msg.media_type, options.prefix),
notify: (msg) => Quasar.Notify.create(msg),
notify: async (msg) => (await Quasar.loadPlugin("Notify")).create(msg),
};
const socketMessageQueue = [];
let isProcessingSocketMessage = false;
Expand Down
1 change: 1 addition & 0 deletions nicegui/static/quasar/c/QAjaxBar.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import{a as e}from"../s/XMTSDW5L.js";import"../s/NMWLTGPF.js";import"../s/N5XILCAW.js";export{e as default};
1 change: 1 addition & 0 deletions nicegui/static/quasar/c/QAvatar.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import{a as e}from"../s/NT547WUY.js";import"../s/HCEQ36B6.js";import"../s/VHPZE4I5.js";import"../s/N4MYZB23.js";import"../s/N5XILCAW.js";export{e as default};
1 change: 1 addition & 0 deletions nicegui/static/quasar/c/QBadge.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions nicegui/static/quasar/c/QBanner.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading