managarten/docs/optimizable/bundle-analysis.md
Till JS 4d5a96e21b 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>
2026-04-22 18:03:53 +02:00

125 lines
5.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Bundle Analysis
**Snapshot 2026-04-22.** Run `pnpm run audit:bundle` after any `pnpm --filter @mana/web build` for live numbers.
## Snapshot
| Category | Size | % | Notes |
|---|---|---|---|
| entry | 92 KB | 0% | app.js + start.js — first JS a cold browser loads |
| nodes | 2.77 MB | 9% | per-route layout/page bundles (230 files) |
| chunks | 5.59 MB | 18% | shared code-split modules (711 files) |
| workers | 22.31 MB | 70% | transformers.js ONNX WASM — lazy until LLM/STT opened |
| assets | 1.04 MB | 3% | CSS, fonts, images |
| **total** | **31.80 MB** | | 1147 files |
Entry is tiny (92 KB). That's healthy. The 22 MB in `workers/` is
`ort-wasm-simd-threaded.asyncify-*.wasm` — the ONNX Runtime for
transformers.js. It's only fetched when the browser actually
instantiates the Web Worker (first use of `@mana/local-llm` or
`@mana/local-stt`). Most users never hit it.
## Biggest shared chunks
Top entries in `chunks/` ≥ 200 KB, with what's actually inside:
| Size | File | Content | Verdict |
|---|---|---|---|
| 797 KB | SDMVbHi1 | `@zxcvbn-ts/language-de` German dictionary | ✓ **lazy** — dynamic import in `PasswordStrength.svelte`, only on register / recovery |
| 454 KB | bdamX4EN | `@zxcvbn-ts/language-common` keyboard adjacency graphs | ✓ **lazy** — same import path |
| 317 KB | DtX-t1si | `@mana/shared-icons` (Phosphor SVG paths) | ⚠ **partly eager** — imported by many routes; see notes |
| 220 KB | BbeX9yAb | Vite `__vite__mapDeps` import-graph | ✓ **metadata only**, not real code |
| 162 KB | Bqmpszdn | (unknown) | below threshold |
## Biggest route bundles
Routes loaded per navigation (not eagerly):
| Size | Route | Note |
|---|---|---|
| 534 KB | `/(app)/invoices/[id]` | **heaviest route.** Likely swissqrbill + pdf-lib. Investigate — could split the QR-bill generator behind `await import()` so preview/list pages stay small. |
| 380 KB | `/(app)/broadcasts/[id]/edit` | Tiptap editor — unavoidable, tiptap is ~250 KB baseline |
| 260 KB | node 2 (root `(app)` layout) | All app-wide chrome: shell, stores, auth guard |
| 95 KB | `/(app)/calendar` | Acceptable; rrule is shared |
| 85 KB | `/(app)/todo` | Acceptable |
## Priority improvements
### 1. `/invoices/[id]` code-split — ✅ **SHIPPED 2026-04-22**
**Before:** 534 KB route bundle. **After:** 18.6 KB.
`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
- Entry bundle is 92 KB — no bloat in the critical path.
- 22 MB of WASM (transformers.js) is correctly behind Web Workers —
cold visitors don't pay for it.
- zxcvbn (1.25 MB German dict + keyboard graphs) is correctly behind a
dynamic import in PasswordStrength.svelte — only register / recovery
/ password-change surfaces load it.
- Route-level code-splitting is working: 230 separate route bundles,
median ~12 KB.
## Usage
```bash
pnpm --filter @mana/web build # prerequisite
pnpm run audit:bundle # full report
pnpm run audit:bundle --top 30 # show top N chunks
pnpm run audit:bundle --summary # category totals only
```
Heuristic rules: chunks in `chunks/` with ≥ 200 KB get a ⚠ flag.
Route bundles (`nodes/`) and worker bundles (`workers/`) are
always-lazy and don't get flagged. The content-hint regex knows about
transformers.js, zxcvbn, tiptap, pdf-lib, swissqrbill, rrule, suncalc,
dexie, marked, pako, date-fns, zod, svelte-dnd-action, svelte-i18n,
WASM, Phosphor icons, and Vite's own mapDeps metadata.