mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 16:41:08 +02:00
perf(invoices): lazy-load pdf-lib + swissqrbill, -516 KB on route
/(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) <noreply@anthropic.com>
This commit is contained in:
parent
596e5a7424
commit
4d5a96e21b
9 changed files with 250 additions and 46 deletions
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
35
apps/mana/apps/web/src/lib/modules/invoices/pdf/scor.ts
Normal file
35
apps/mana/apps/web/src/lib/modules/invoices/pdf/scor.ts
Normal file
|
|
@ -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}`;
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
137
scripts/audit-icon-usage.mjs
Normal file
137
scripts/audit-icon-usage.mjs
Normal file
|
|
@ -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
|
||||
* `<House weight="bold" />` still ships the other five paths.
|
||||
*
|
||||
* Result: the prod bundle has ~466 KB of icon path data across two
|
||||
* chunks (`chunks/*.js` with `<path d="M..." />` 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<name>
|
||||
const weightsUsed = new Map(); // name → Set<weight>
|
||||
|
||||
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();
|
||||
Loading…
Add table
Add a link
Reference in a new issue