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

5.7 KiB
Raw Blame History

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-iconsOPEN

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

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.