From 4d5a96e21b1377f3ba9f740ba5afdd4d1f61359a Mon Sep 17 00:00:00 2001 From: Till JS Date: Wed, 22 Apr 2026 18:03:53 +0200 Subject: [PATCH] perf(invoices): lazy-load pdf-lib + swissqrbill, -516 KB on route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /(app)/invoices/[id] route bundle drops from **534 KB → 18.6 KB** by moving PDF rendering behind dynamic imports. Changes: - views/DetailView.svelte: `await import('../pdf/renderer')` inside renderPdf() + downloadPdf(), cached in a module-local ref. - components/SendModal.svelte: same for openAndDownload(). - pdf/scor.ts (new): generateSCORReference extracted so the invoices store can derive a reference string without pulling swissqrbill/svg + pdf-lib into the list-view bundle. - pdf/qr-bill.ts: re-exports generateSCORReference from scor.ts for backward compatibility. - stores/invoices.svelte.ts: imports from ../pdf/scor (light) instead of ../pdf/qr-bill (heavy). - index.ts: drop re-export of the PDF renderer from the module barrel so `import ... from '$lib/modules/invoices'` never drags pdf-lib in. The heavy chunk (pdf-lib + swissqrbill, ~576 KB) now only loads when a user actually opens an invoice detail — list views, create flow, and all other routes stay lean. 20/20 qr-bill tests pass; svelte-check clean. Bonus: scripts/audit-icon-usage.mjs (+ pnpm run audit:icon-usage) audits @mana/shared-icons imports. Reveals 204 distinct icons across the codebase, 199 of them at default weight but paying for all 6 Phosphor weights. Biggest offender: app-registry/apps.ts with 69 static icon imports accounting for ~290 KB of the shared 466 KB icon chunk. Migration path for that is documented in docs/optimizable/bundle-analysis.md §2 — next session's work. docs/optimizable/bundle-analysis.md also updated with the root (app) layout (260 KB) investigation notes (start/stop lifecycle hooks to defer via idleCallback). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../invoices/components/SendModal.svelte | 5 +- .../web/src/lib/modules/invoices/index.ts | 8 +- .../src/lib/modules/invoices/pdf/qr-bill.ts | 32 +--- .../web/src/lib/modules/invoices/pdf/scor.ts | 35 +++++ .../invoices/stores/invoices.svelte.ts | 2 +- .../modules/invoices/views/DetailView.svelte | 12 +- docs/optimizable/bundle-analysis.md | 64 ++++++-- package.json | 1 + scripts/audit-icon-usage.mjs | 137 ++++++++++++++++++ 9 files changed, 250 insertions(+), 46 deletions(-) create mode 100644 apps/mana/apps/web/src/lib/modules/invoices/pdf/scor.ts create mode 100644 scripts/audit-icon-usage.mjs diff --git a/apps/mana/apps/web/src/lib/modules/invoices/components/SendModal.svelte b/apps/mana/apps/web/src/lib/modules/invoices/components/SendModal.svelte index 595af93bf..2810243d8 100644 --- a/apps/mana/apps/web/src/lib/modules/invoices/components/SendModal.svelte +++ b/apps/mana/apps/web/src/lib/modules/invoices/components/SendModal.svelte @@ -19,7 +19,6 @@ import { untrack } from 'svelte'; import type { Invoice, InvoiceSettings } from '../types'; import { buildInvoiceMailDraft, mailDraftToMailto, looksLikeEmail } from '../mail-template'; - import { renderInvoicePdfBlob } from '../pdf/renderer'; import { invoicesStore } from '../stores/invoices.svelte'; interface Props { @@ -49,6 +48,10 @@ // 1. Download the PDF so the user can attach it. We don't wait for // them to confirm download — the browser's download toast is // visible in parallel with the mail compose window. + // Dynamic import keeps pdf-lib + swissqrbill out of the route bundle; + // by the time SendModal is mounted the user has already accepted a + // small lazy-load delay on the parent DetailView. + const { renderInvoicePdfBlob } = await import('../pdf/renderer'); const blob = await renderInvoicePdfBlob(invoice, settings); const pdfUrl = URL.createObjectURL(blob); const a = document.createElement('a'); diff --git a/apps/mana/apps/web/src/lib/modules/invoices/index.ts b/apps/mana/apps/web/src/lib/modules/invoices/index.ts index 808dd4110..7bd986901 100644 --- a/apps/mana/apps/web/src/lib/modules/invoices/index.ts +++ b/apps/mana/apps/web/src/lib/modules/invoices/index.ts @@ -34,8 +34,12 @@ export { export { computeLineTotal, computeInvoiceTotals, EMPTY_TOTALS } from './totals'; -export { renderInvoicePdf, renderInvoicePdfBlob, qrBillStatus } from './pdf/renderer'; -export { generateSCORReference, QRBillError } from './pdf/qr-bill'; +// Deliberately NOT re-exporting the PDF renderer from here: it pulls +// pdf-lib + swissqrbill/svg (~350 KB) and would force every consumer of +// the module's barrel into a fat bundle. Use `await import( +// '$lib/modules/invoices/pdf/renderer')` at the call site instead. +export { generateSCORReference } from './pdf/scor'; +export { QRBillError } from './pdf/qr-bill'; export { invoicesStore } from './stores/invoices.svelte'; export { invoiceSettingsStore, ensureSettings } from './stores/settings.svelte'; diff --git a/apps/mana/apps/web/src/lib/modules/invoices/pdf/qr-bill.ts b/apps/mana/apps/web/src/lib/modules/invoices/pdf/qr-bill.ts index eeeb66c2e..a73056ed2 100644 --- a/apps/mana/apps/web/src/lib/modules/invoices/pdf/qr-bill.ts +++ b/apps/mana/apps/web/src/lib/modules/invoices/pdf/qr-bill.ts @@ -41,11 +41,16 @@ import type { PDFDocument } from 'pdf-lib'; import { SwissQRBill } from 'swissqrbill/svg'; -import { isIBANValid, calculateSCORReferenceChecksum } from 'swissqrbill/utils'; +import { isIBANValid } from 'swissqrbill/utils'; import type { Data } from 'swissqrbill/types'; import type { Invoice, InvoiceSettings } from '../types'; import { CURRENCIES } from '../constants'; import { A4, mm } from './templates/default'; +// Re-export so existing callers keep working, but prefer importing from +// './scor' directly (keeps swissqrbill/svg + pdf-lib out of the callers' +// bundle when they only need the reference string). +import { generateSCORReference } from './scor'; +export { generateSCORReference }; export class QRBillError extends Error { constructor( @@ -63,31 +68,6 @@ export class QRBillError extends Error { } } -// ─── SCOR reference ────────────────────────────────────── - -/** - * Generate an ISO 11649 Creditor Reference (SCOR) for the invoice. Uses - * the invoice number as payload so the reference is stable across re- - * renders. Format: `RF{check}{payload}`. - * - * invoice.number "2026-0042" → payload "20260042" → RF{check}20260042 - * - * Non-alphanumerics are stripped (the spec allows only [0-9A-Z] in the - * payload). The payload is truncated to 21 chars (SCOR max). - */ -export function generateSCORReference(invoiceNumber: string): string { - const payload = invoiceNumber - .replace(/[^0-9A-Za-z]/g, '') - .toUpperCase() - .slice(0, 21); - if (!payload) { - // Degenerate input (e.g. all dashes) — fall back to a literal. - return `RF${calculateSCORReferenceChecksum('INVOICE')}INVOICE`; - } - const checksum = calculateSCORReferenceChecksum(payload); - return `RF${checksum}${payload}`; -} - // ─── Address parsing ───────────────────────────────────── interface StructuredAddress { diff --git a/apps/mana/apps/web/src/lib/modules/invoices/pdf/scor.ts b/apps/mana/apps/web/src/lib/modules/invoices/pdf/scor.ts new file mode 100644 index 000000000..09bd2f923 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/invoices/pdf/scor.ts @@ -0,0 +1,35 @@ +/** + * ISO 11649 Creditor Reference (SCOR) generator — extracted from + * qr-bill.ts so the invoice store can derive a reference number when a + * new invoice is created, without statically pulling in swissqrbill/svg + * (which drags pdf-lib + the whole SVG renderer into the list-view + * bundle). + * + * qr-bill.ts re-exports `generateSCORReference` for backwards compat; + * new callers should import from here. + */ + +import { calculateSCORReferenceChecksum } from 'swissqrbill/utils'; + +/** + * Generate an ISO 11649 Creditor Reference (SCOR) for the invoice. Uses + * the invoice number as payload so the reference is stable across re- + * renders. Format: `RF{check}{payload}`. + * + * invoice.number "2026-0042" → payload "20260042" → RF{check}20260042 + * + * Non-alphanumerics are stripped (the spec allows only [0-9A-Z] in the + * payload). The payload is truncated to 21 chars (SCOR max). + */ +export function generateSCORReference(invoiceNumber: string): string { + const payload = invoiceNumber + .replace(/[^0-9A-Za-z]/g, '') + .toUpperCase() + .slice(0, 21); + if (!payload) { + // Degenerate input (e.g. all dashes) — fall back to a literal. + return `RF${calculateSCORReferenceChecksum('INVOICE')}INVOICE`; + } + const checksum = calculateSCORReferenceChecksum(payload); + return `RF${checksum}${payload}`; +} diff --git a/apps/mana/apps/web/src/lib/modules/invoices/stores/invoices.svelte.ts b/apps/mana/apps/web/src/lib/modules/invoices/stores/invoices.svelte.ts index c4f0ecad5..d71cb08c8 100644 --- a/apps/mana/apps/web/src/lib/modules/invoices/stores/invoices.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/invoices/stores/invoices.svelte.ts @@ -17,7 +17,7 @@ import { encryptRecord } from '$lib/data/crypto'; import { emitDomainEvent } from '$lib/data/events'; import { invoiceTable } from '../collections'; import { computeInvoiceTotals } from '../totals'; -import { generateSCORReference } from '../pdf/qr-bill'; +import { generateSCORReference } from '../pdf/scor'; import { financeStore } from '$lib/modules/finance/stores/finance.svelte'; import { CURRENCIES } from '../constants'; import type { diff --git a/apps/mana/apps/web/src/lib/modules/invoices/views/DetailView.svelte b/apps/mana/apps/web/src/lib/modules/invoices/views/DetailView.svelte index ef6056e8b..3558653c9 100644 --- a/apps/mana/apps/web/src/lib/modules/invoices/views/DetailView.svelte +++ b/apps/mana/apps/web/src/lib/modules/invoices/views/DetailView.svelte @@ -8,11 +8,19 @@ import SendModal from '../components/SendModal.svelte'; import { invoicesStore } from '../stores/invoices.svelte'; import { invoiceSettingsStore } from '../stores/settings.svelte'; - import { renderInvoicePdfBlob, qrBillStatus } from '../pdf/renderer'; import { formatAmount } from '../queries'; import type { Invoice, InvoiceSettings } from '../types'; import { STATUS_LABELS } from '../constants'; + // Dynamic import — pdf-lib + swissqrbill together are ~350 KB, only needed + // when a user actually opens an invoice (or sends one). Lazy-loading them + // drops the /invoices/[id] route bundle from 534 KB to ~180 KB. + let rendererMod: typeof import('../pdf/renderer') | null = null; + async function getRenderer() { + rendererMod ??= await import('../pdf/renderer'); + return rendererMod; + } + interface Props { invoice: Invoice; } @@ -40,6 +48,7 @@ try { const settings: InvoiceSettings = await invoiceSettingsStore.get(); settingsCache = settings; + const { renderInvoicePdfBlob, qrBillStatus } = await getRenderer(); // Compute QR-Bill eligibility first so we can show a warning even // if the rest of the PDF renders fine. The renderer will silently // omit the Zahlteil when not eligible. @@ -79,6 +88,7 @@ async function downloadPdf() { try { const settings: InvoiceSettings = await invoiceSettingsStore.get(); + const { renderInvoicePdfBlob } = await getRenderer(); const blob = await renderInvoicePdfBlob(invoice, settings); const url = URL.createObjectURL(blob); const a = document.createElement('a'); diff --git a/docs/optimizable/bundle-analysis.md b/docs/optimizable/bundle-analysis.md index 9f6768cbc..8553f1f80 100644 --- a/docs/optimizable/bundle-analysis.md +++ b/docs/optimizable/bundle-analysis.md @@ -45,23 +45,57 @@ Routes loaded per navigation (not eagerly): ## Priority improvements -1. **`/invoices/[id]` code-split** — 534 KB is large. `swissqrbill` - and `pdf-lib` are probably both eagerly imported. Wrap the PDF/QR - generation path with `await import('swissqrbill')` so the route - bundle drops to ~150 KB for the list/display case. Real win for - Swiss-bill users on 3G / slow laptops. +### 1. `/invoices/[id]` code-split — ✅ **SHIPPED 2026-04-22** -2. **`@mana/shared-icons` chunking** — 317 + 149 KB of Phosphor SVG - paths across two chunks. Phosphor doesn't tree-shake well because - its icons are named exports of a single module. Either: - - migrate to tree-shakable per-icon imports, OR - - lazy-load rarely-used icons in the module that imports them - instead of the shared chunk. +**Before:** 534 KB route bundle. **After:** 18.6 KB. -3. **Root `(app)` layout (260 KB)** — on the high side for "just the - shell". Investigate whether any module-specific stores / AI pipeline - bits are leaking into the shared layout when they could be - route-scoped. +`DetailView.svelte` + `SendModal.svelte` now import `./pdf/renderer` +dynamically (`await import(...)`) so pdf-lib + swissqrbill/svg (~576 KB +combined) move into a separate chunk that only loads when the user +actually opens an invoice detail. Also split `generateSCORReference` +into its own `./pdf/scor.ts` so the invoices store can compute a +reference on create without pulling the heavy renderer graph. + +### 2. `@mana/shared-icons` — **OPEN** + +466 KB of Phosphor SVG paths across 2 chunks. Root cause from +`audit:icon-usage` report (2026-04-22): + +- **204 distinct icons** imported across the codebase. +- **199 use the default "regular" weight** — but Phosphor ships all + 6 weights per icon regardless. +- Single worst offender: `app-registry/apps.ts` imports **69 icons** + in one file (the module-name → icon-component map), pulled into the + shared layout chunk → 69 × 6 weights × ~0.7 KB ≈ 290 KB on every + cold load. + +**Migration paths** (pick one, sized to follow-up sessions): + +1. Rewrite `app-registry/apps.ts` so each module's icon is a string + name, with a lazy `getIconComponent(name)` helper backed by + per-path dynamic imports (`() => import('phosphor-svelte/House')`). + Saves ~290 KB from the initial layout chunk. Biggest single win. +2. Drop `export * from 'phosphor-svelte'` in + `packages/shared-icons/src/index.ts` and re-export only the 204 + icons actually used. Defends against future barrel-broadening. +3. Longest-term: build a custom icon set that only ships the weights + actually used (most icons only need "regular"). + +Run `pnpm run audit:icon-usage --top 30` for the current inventory. + +### 3. Root `(app)` layout (260 KB) — **OPEN** + +`routes/(app)/+layout.svelte` statically imports ~15 start/stop +lifecycle hooks (mission tick, server-iteration executor, event store, +event bridge, streak tracker, goal tracker, byok init, tools init, +articles-from-news migration, reminder scheduler, llm queue). Each +pulls its own dependency graph into the shared layout chunk. + +**Recommended approach:** wrap the non-critical ones in `queueMicrotask` +or `requestIdleCallback`-deferred dynamic imports — the layout finishes +hydrating, then the heavy lifecycle code streams in. The one-shot +`runArticlesFromNewsMigration` in particular is a prime candidate since +it's executed only once per user per session. ## What's already good diff --git a/package.json b/package.json index 25e7cc3d8..a5d0170cd 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "audit:port-drift": "node scripts/audit-port-drift.mjs", "audit:test-coverage": "node scripts/audit-test-coverage.mjs", "audit:bundle": "node scripts/audit-bundle.mjs", + "audit:icon-usage": "node scripts/audit-icon-usage.mjs", "audit:all": "pnpm run audit:port-drift && pnpm run audit:i18n-coverage --summary && pnpm run audit:test-coverage --summary", "generate:dockerfiles": "node scripts/generate-dockerfiles.mjs", "setup:env": "node scripts/generate-env.mjs", diff --git a/scripts/audit-icon-usage.mjs b/scripts/audit-icon-usage.mjs new file mode 100644 index 000000000..bc4a4cda7 --- /dev/null +++ b/scripts/audit-icon-usage.mjs @@ -0,0 +1,137 @@ +#!/usr/bin/env node +/** + * Audit @mana/shared-icons usage to inform the tree-shaking migration. + * + * The shared-icons package re-exports phosphor-svelte via a barrel + * (`export * from 'phosphor-svelte'`). Each phosphor-svelte component + * inlines ALL SIX weight variants (thin / light / regular / bold / + * fill / duotone) because the weight is a runtime prop. So importing + * `` still ships the other five paths. + * + * Result: the prod bundle has ~466 KB of icon path data across two + * chunks (`chunks/*.js` with `` bodies). + * + * This audit reports: + * - Which icons are actually used, sorted by frequency (top N). + * - Which weights each icon uses (to gauge how much of the 6x + * weight-per-icon cost is actually exercised at runtime). + * - Files with the most icon imports (worth splitting first if they + * end up in the shared chunk). + * + * Usage: + * node scripts/audit-icon-usage.mjs + * node scripts/audit-icon-usage.mjs --top 30 + */ + +import { readdirSync, readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = join(__dirname, '..'); +const WEB_SRC = join(REPO_ROOT, 'apps/mana/apps/web/src'); + +const args = process.argv.slice(2); +const TOP_IDX = args.indexOf('--top'); +const TOP_N = TOP_IDX >= 0 ? Number(args[TOP_IDX + 1] || 30) : 30; + +const SKIP_DIRS = new Set(['node_modules', 'dist', 'build', '.svelte-kit']); + +function walk(dir, collect) { + for (const ent of readdirSync(dir, { withFileTypes: true })) { + if (SKIP_DIRS.has(ent.name)) continue; + const p = join(dir, ent.name); + if (ent.isDirectory()) walk(p, collect); + else if (ent.isFile() && /\.(svelte|ts|tsx|js)$/.test(ent.name)) collect(p); + } +} + +function audit() { + const files = []; + walk(WEB_SRC, (p) => files.push(p)); + + const iconFrequency = new Map(); // name → count + const perFile = new Map(); // file → Set + const weightsUsed = new Map(); // name → Set + + const importRe = /import\s*\{([^}]+)\}\s*from\s*['"]@mana\/shared-icons['"]/g; + const weightRe = /<\s*([A-Z]\w+)[^>]*\bweight\s*=\s*["']?(\w+)["']?/g; + + for (const file of files) { + const src = readFileSync(file, 'utf8'); + + let m; + importRe.lastIndex = 0; + while ((m = importRe.exec(src)) !== null) { + const names = m[1] + .split(',') + .map((n) => n.trim().split(/\s+as\s+/)[0]) + .filter(Boolean); + for (const n of names) { + iconFrequency.set(n, (iconFrequency.get(n) ?? 0) + 1); + const rel = file.slice(REPO_ROOT.length + 1); + if (!perFile.has(rel)) perFile.set(rel, new Set()); + perFile.get(rel).add(n); + } + } + + weightRe.lastIndex = 0; + while ((m = weightRe.exec(src)) !== null) { + const [, name, weight] = m; + if (!weightsUsed.has(name)) weightsUsed.set(name, new Set()); + weightsUsed.get(name).add(weight); + } + } + + const ranked = [...iconFrequency.entries()].sort((a, b) => b[1] - a[1]); + + console.log(`\n── Icon usage audit ───────────────────────────────────\n`); + console.log(`Distinct icons imported: ${iconFrequency.size}`); + console.log(`Files importing icons: ${perFile.size}`); + console.log( + `Bundle cost (estimated): ${(iconFrequency.size * 6 * 0.7).toFixed(0)} KB ` + + `(${iconFrequency.size} icons × 6 weights × ~0.7 KB each path)` + ); + console.log(''); + + console.log(`Top ${Math.min(TOP_N, ranked.length)} icons by import count:\n`); + for (const [name, count] of ranked.slice(0, TOP_N)) { + const weights = weightsUsed.get(name); + const wStr = weights && weights.size > 0 ? `weights: ${[...weights].sort().join(', ')}` : '—'; + console.log(` ${String(count).padStart(4)}× ${name.padEnd(28)} ${wStr}`); + } + console.log(''); + + // Weight distribution (how many icons use each weight at all) + const weightCounts = new Map(); + for (const [, weights] of weightsUsed) { + for (const w of weights) weightCounts.set(w, (weightCounts.get(w) ?? 0) + 1); + } + console.log(`Weights actually used across the codebase:\n`); + for (const [weight, count] of [...weightCounts.entries()].sort((a, b) => b[1] - a[1])) { + console.log(` ${weight.padEnd(10)} ${count} icon(s)`); + } + const defaultWeight = iconFrequency.size - (weightCounts.get('regular') ?? 0); + console.log(` (unset/default) ~${defaultWeight} icon(s) use the default "regular" weight`); + console.log(''); + + const topFiles = [...perFile.entries()].sort((a, b) => b[1].size - a[1].size).slice(0, 10); + console.log(`Top 10 files by distinct-icon count:\n`); + for (const [file, icons] of topFiles) { + console.log(` ${String(icons.size).padStart(3)} icons ${file}`); + } + console.log(''); + + console.log( + `Migration path to reduce the 466 KB icon chunks:\n\n` + + ` 1. Change @mana/shared-icons/src/index.ts to drop \`export * from\n` + + ` 'phosphor-svelte'\` — require per-icon re-exports of only the\n` + + ` ${iconFrequency.size} icons actually used.\n\n` + + ` 2. OR migrate callers to import directly from phosphor-svelte's\n` + + ` per-icon paths: \`import House from 'phosphor-svelte/House'\`.\n\n` + + ` 3. Longest-term: build a custom icon set that only ships the\n` + + ` weights actually used (most icons only use "regular" or "bold").\n` + ); +} + +audit();