From c271e95100f6089cceddcd3cf6ab6db7e6a1790a Mon Sep 17 00:00:00 2001 From: byGalax Date: Sat, 16 May 2026 23:16:42 +0200 Subject: [PATCH] docs(P6): Phase 6 performance pack plan (Groups A/B/C + deferred channel pooling) --- .../plans/2026-05-16-phase6-performance.md | 260 ++++++++++++++++++ 1 file changed, 260 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-16-phase6-performance.md diff --git a/docs/superpowers/plans/2026-05-16-phase6-performance.md b/docs/superpowers/plans/2026-05-16-phase6-performance.md new file mode 100644 index 0000000..577c428 --- /dev/null +++ b/docs/superpowers/plans/2026-05-16-phase6-performance.md @@ -0,0 +1,260 @@ +# 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-perf` → `888ed1b` (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 ``. + +**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 `` with `` defs + ``). 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 `` 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 (`/.bin`), generate a 320×320 max-dim WebP thumb via `.toBlob({ type: 'image/webp', quality: 0.7 })`, encrypt with the SAME per-attachment key, upload to `/-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 ``. +- Configure: `data={messages}`, `itemContent={(_, msg) => }`, `followOutput="smooth"` for auto-scroll on new messages, `initialTopMostItemIndex={messages.length - 1}` to start at bottom. +- If date-day headers exist: switch to `` 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).