Files
ChatApp/docs/superpowers/plans/2026-05-16-phase6-performance.md
T

14 KiB
Raw Blame History

Phase 6 — Performance Pack

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. Steps use checkbox (- [ ]) syntax. This plan is grouped into 3 risk tiers — Group A is parallel-safe quick wins, Group B is medium-scope, Group C is audits.

Goal: A focused performance pass after the fifteen-features initiative shipped. Faster startup, smoother long chats, smaller bundle, less main-thread blocking on PIN-unlock, no UX regressions.

Rollback anchor: tag pre-phase6-perf888ed1b (already pushed to origin).

Strategy: ship Group A first (5 tiny safe wins ≈ 5h), pause + smoke-test, then B (medium ≈ 2-3 days), then C (audits ≈ 1 day). No release between groups; one combined release at the very end.


Group A — Safe quick wins (~5h, low risk)

T1: Lazy-load four fat modals

What: Convert eager imports of WhiteboardModal, WatchTogetherModal, ImageAnnotator, GameModal to React.lazy(() => import(...)) inside ConversationPage.tsx. Wrap each conditional render in <Suspense fallback={null}>.

Why: These modals total ~200-300 KB (canvas-confetti dep, IFrame player loader, ImageAnnotator's full op-stack, etc.) and render in <1 % of sessions. Initial bundle drops by that amount → faster cold load.

Files: apps/desktop/src/pages/ConversationPage.tsx only.

Risk: trivial. Suspense with fallback={null} means a few ms blank flicker the first time each modal opens (chunk download). Acceptable.

Effort: ~30 min.


T2: prefers-reduced-motion global rule

What:

  • Global CSS rule in apps/desktop/src/index.css (or wherever global styles live): @media (prefers-reduced-motion: reduce) { *, *::before, *::after { transition: none !important; animation: none !important; } }.
  • Gate the confetti burst in GameModal.tsx behind window.matchMedia('(prefers-reduced-motion: reduce)').matches.

Why: Accessibility + CPU savings for users who set the OS preference. Confetti is the most visible offender.

Risk: very low. Tailwind already respects motion-reduce variants in some classes; this is the global default.

Effort: ~30 min.


T3: Memoize MessageBubble + audit callback stability

What:

  • Wrap MessageBubble export in React.memo with shallow equality (default).
  • Audit the message-list render site (ConversationPage or a MessagesList component) — every callback prop passed into the row (onReply, onPin, onDelete, …) must be useCallback-stable with no per-render closures. Replace anonymous () => doX(message.id) patterns with stable handlers that receive the id at call time.

Why: Typing in the composer currently re-runs the entire messages.map(...) and re-renders every bubble. With memoization + stable callbacks, only the new bubble appears; existing rows stay mounted. Big win on long chats.

Risk: medium-low. Possible bugs if a callback captures stale state (e.g. closure over pinnedSet that doesn't update). Mitigation: pass volatile state as props on the bubble and let React.memo handle the diff.

Effort: ~2h.


T4: Memoize icon components (pragmatic "sprite-sheet" alternative)

What: Original idea was a real SVG sprite-sheet (single <svg> with <symbol> defs + <use href="#name">). Pragmatic alternative: wrap every icon component in React.memo. They're pure functions of className/...props so memoization is free, and 90 % of the perf win (avoiding React reconciliation on identical icon trees) comes from this without the sprite refactor risk.

Files: apps/desktop/src/components/icons.tsx (or icons/ folder — whichever the codebase uses).

Why: Real sprite-sheet is invasive (refactor 60+ icon usages, change className/fill inheritance). Memoizing achieves the bulk of the win at <30 min effort. Real sprite-sheet stays available as a follow-up if bundle-analyzer (T11) shows icons are a top-3 bundle hog.

Risk: none — React.memo is purely a perf hint.

Effort: ~30 min.


T5: Pre-warm Supabase + avatar loading hints

What:

  • In AuthContext.tsx, fire one trivial query early (e.g. supabase.from('profiles').select('id').limit(1)) so the connection is warm by the time the user does anything.
  • Audit <img> tags for avatars: add loading="lazy" to off-screen ones (chat list rows below the fold, deep history) and keep loading="eager" only for above-the-fold (current conv header, top of chat list).

Why: First real query after login currently pays cold-connection latency (~100-200 ms). Pre-warm hides it. Lazy avatars stop the browser from hammering Supabase Storage on initial render.

Risk: none.

Effort: ~1h.


Group A final gate

  • pnpm --filter @chat-app/shared typecheck && pnpm --filter @chat-app/desktop typecheck
  • pnpm --filter @chat-app/shared test -- --run (still 71/71)
  • User smoke-test: cold-start the app, send a few messages, type in composer, open one of the 4 modals — verify nothing broke + the visible improvements (faster initial render, smoother typing in long chats).
  • Tag phase6a-done for incremental rollback granularity if Group B introduces issues.

Group B — Medium scope (~2-3 days, moderate risk)

T6: Web-Worker for Argon2 + crypto_box_open (PIN-unlock path)

What:

  • Create apps/desktop/src/lib/workers/crypto.worker.ts that imports libsodium-wrappers and exposes a postMessage RPC: { op: 'unsealUserKey', sealedKey, pin, salt, kdfParams }{ privateKey: Uint8Array } (transferred).
  • Build with Vite's worker syntax: new Worker(new URL('./workers/crypto.worker.ts', import.meta.url), { type: 'module' }).
  • Refactor apps/desktop/src/lib/userIdentity.ts's unlockUserKey (and any other hot Argon2 callers) to call the worker instead of the inline crypto backend.

Why: PIN-unlock currently runs Argon2id (~1-2 sec on mid hardware) on the main thread → UI freeze during login. Worker offloads it, login screen stays responsive.

Risk: medium. libsodium-wrappers needs to be initialized in both contexts. structured-clone transfers Uint8Array cleanly. The risk is that libsodium-wrappers might ship a bigger worker bundle than expected (we accept the trade-off because the main bundle gets smaller too).

Effort: ~1 day. Includes typing the postMessage RPC + ensuring the existing PIN-unlock flow keeps its error semantics (wrong PIN, etc.).


T7: WebP thumbnails for image attachments

What:

  • When sending an image attachment: in addition to encrypting+uploading the full image (<convId>/<attachmentId>.bin), generate a 320×320 max-dim WebP thumb via <canvas>.toBlob({ type: 'image/webp', quality: 0.7 }), encrypt with the SAME per-attachment key, upload to <convId>/<attachmentId>-thumb.bin.
  • MessageBubble image render: try downloading the thumb first; fall back to full image on 404 (graceful for pre-Phase-6 attachments).
  • Click-to-expand: fetch the full image.

Why: A 5 MB image in the chat scroll loads 5 MB even off-screen. Thumb is ~10-30 KB. Scroll is silky, bandwidth drops 99 %.

Files: packages/shared/src/chat/attachments.ts (extend encryptAndUploadAttachment to optionally generate+upload thumb), apps/desktop/src/components/MessageBubble.tsx (try-thumb-first logic), maybe Lightbox.tsx (full image on click).

Schema: none — naming-convention based, 404-fallback preserves backward compat.

Risk: low-medium. Edge cases: very small images (thumb is bigger than full → skip thumb gen), animated GIFs (don't generate static-frame thumb, just use full).

Effort: ~½ day.


T8: Virtual-scroll for message list

What:

  • pnpm --filter @chat-app/desktop add react-virtuoso
  • Replace the message-list .map(...) in (likely) ConversationPage.tsx / MessagesList.tsx with <Virtuoso>.
  • Configure: data={messages}, itemContent={(_, msg) => <MessageBubble ... />}, followOutput="smooth" for auto-scroll on new messages, initialTopMostItemIndex={messages.length - 1} to start at bottom.
  • If date-day headers exist: switch to <GroupedVirtuoso> with groupCounts + groupContent.

Why: Long conversations (1000+ messages) currently render all rows → scroll jank, layout thrashing. Virtuoso renders only visible rows + a small overscan buffer.

Risk: medium-high. Things that can go wrong:

  • Scroll-anchor preservation when Pinned-Messages panel opens.
  • Auto-scroll-to-bottom on send.
  • Smooth-scroll-to-message when clicking a pin or a reply.
  • Image-load reflow (Virtuoso handles this but needs proper height detection).

Mitigation: thorough manual smoke-test before commit. Keep the old render behind a feature flag for one release if jitters appear.

Effort: ~½ day to 1 day depending on edge cases.


T9: PIN-Idle-Auto-Lock

What:

  • Settings → Sicherheit: new toggle "Auto-Lock nach Inaktivität" + dropdown (5 / 15 / 30 / 60 Minuten). Default OFF.
  • localStorage key chatapp.autoLockMinutes (or similar) — added to PRESERVE_LOCAL_STORAGE so memory-wipe doesn't disable the setting silently (same pattern as wipe-on-close toggle).
  • In AuthContext (or a new top-level hook): listen on keydown / mousedown / pointermove, reset a timer on each event. When the timer fires: wipeLocalState(uid) + navigate to /device (the PIN-unlock screen).

Why: Spec mentioned this as polish + a Security win — laptop left unattended, auto-locks after X min, attacker can't read messages without PIN.

Risk: low. The wipe-on-close infrastructure (P1.T12-T13) already handles all the local-state clearing — same call site.

Effort: ~½ day.


T10: i18next tree-shake audit

What:

  • pnpm --filter @chat-app/desktop add -D i18next-parser
  • Configure it to scan apps/desktop/src/**/*.{ts,tsx} for t('app:...') calls + extract used keys.
  • Diff against apps/desktop/locales/de/app.json (or wherever the resource files live). List dead keys.
  • Prune them. Verify nothing visible regresses.

Why: Resource files accumulate keys from removed/redesigned features. Smaller resource bundle = faster app start (in-memory JSON parse).

Risk: low — t() always falls back to defaultValue if a key is missing, so even an accidental over-prune doesn't crash the UI; it just shows the German default.

Effort: ~2h (mostly looking at the diff + judgment calls).


Group B final gate

  • Both typechecks green
  • All shared tests green
  • User smoke-test: cold start (Argon2 worker), open a long chat (virtual scroll), send an image (thumb generation), idle 5+min (auto-lock if enabled), check console for noise.
  • Tag phase6b-done.

Group C — Audits + judgment calls (~1 day)

T11: Bundle-analyzer audit + targeted dep swaps

What:

  • pnpm dlx vite-bundle-visualizer against the desktop build → outputs HTML report.
  • Review the treemap. Common offenders to check:
    • Full lodash vs lodash-es (or no lodash at all if only a few utils)
    • Moment.js vs date-fns / native Intl.DateTimeFormat
    • Multiple realtime/socket clients
    • Icon libs pulling all icons
    • Dev-only deps accidentally in prod bundle
  • Apply targeted swaps (max ~3-5) based on the worst findings.

Why: Shrinks bundle further beyond T1's lazy-load. Each ~50 KB shaved is a real cold-start win.

Files: apps/desktop/package.json, the consumer files that import from swapped deps.

Risk: variable per swap. A Moment-to-date-fns swap touches many call sites. Cap at the 3 biggest offenders to keep risk bounded.

Effort: ~2h audit + variable fixes (estimate 2-3h additional).


T12: Optimistic-UI audit + targeted gap fills

What:

  • Audit each user-write action across the app:
    • send (message) → likely already optimistic; verify
    • editEncryptedMessage → likely already optimistic
    • Pin / unpin
    • Add / remove reaction (doesn't exist yet — skip)
    • Vote on poll
    • Revoke device
    • Toggle mentions-only
    • Toggle mute
  • For each action that currently waits for the server roundtrip before updating local state: add optimistic-update with rollback on error.

Why: Perceived latency drops to ~0 ms for most clicks. Server roundtrip happens silently.

Risk: medium. Each optimistic-update is its own potential rollback bug. Mitigation: only touch actions where rollback is straightforward (e.g. a toggle's previous state is trivially recoverable). Skip if rollback is hairy.

Effort: ~1 day total (each action is ~30-60 min including verification).


Group C final gate

  • Both typechecks green, all shared tests green
  • Bundle size measured before/after (note in report)
  • User smoke-test of any actions that gained optimistic UI

Deferred / skipped (with reasoning)

Realtime-Channel-Pooling

Skipped for now. The current UI keeps only one conversation actively open at a time. Concurrent channels at steady state are typically 5-8 (auth-self, conversations-list, current conv messages, current conv typing, mentions, maybe whiteboard / game / watch). Pooling into a single multiplexed channel would require a manager singleton + per-call-site refactor (~15-20 sites), with a meaningful risk of subtle realtime bugs during the transition.

Reconsider when: sustained active channel count exceeds 15, or Supabase invoices a noticeable channel-quota line item. Then a focused 1-day refactor with thorough realtime smoke testing makes sense.


Release strategy

  • No pnpm release between Group A/B/C — single combined release after Group C (or earlier if Group B+C get deferred).
  • Suggested version when releasing: 0.20.0 (combines unreleased Phase 5 + 5C + Phase 6).
  • Rollback at any commit boundary via git reset --hard pre-phase6-perf (Group A) or git reset --hard phase6a-done / phase6b-done (per-group).