diff --git a/docs/performance-patterns.skill.md b/docs/performance-patterns.skill.md new file mode 100644 index 0000000..88d1699 --- /dev/null +++ b/docs/performance-patterns.skill.md @@ -0,0 +1,1029 @@ +# Performance & UX Patterns from lawn (Theo's Video Review Platform) + +> A comprehensive catalog of patterns that make lawn feel instant. Extracted from the actual codebase for reuse in other projects. + +--- + +## Table of Contents + +1. [Intent-Based Route Prewarming](#1-intent-based-route-prewarming) +2. [Two-Stage Data Prefetching](#2-two-stage-data-prefetching) +3. [Multi-Layer Network Prefetching](#3-multi-layer-network-prefetching) +4. [Conditional Query Skipping (Waterfall Prevention)](#4-conditional-query-skipping-waterfall-prevention) +5. [HLS Runtime Lazy-Load with Prefetch](#5-hls-runtime-lazy-load-with-prefetch) +6. [Theme Flash Prevention](#6-theme-flash-prevention) +7. [SPA Shell with Prerendered Marketing Pages](#7-spa-shell-with-prerendered-marketing-pages) +8. [Upload Manager with Rolling Speed Metrics](#8-upload-manager-with-rolling-speed-metrics) +9. [Presence System with sendBeacon Disconnect](#9-presence-system-with-sendbeacon-disconnect) +10. [Video Player State Architecture](#10-video-player-state-architecture) +11. [Composable Authorization Guards](#11-composable-authorization-guards) +12. [Separated Route Data Files](#12-separated-route-data-files) +13. [Memoization Discipline](#13-memoization-discipline) +14. [Keyboard-First Video Controls](#14-keyboard-first-video-controls) +15. [Architecture Overview](#15-architecture-overview) + +--- + +## 1. Intent-Based Route Prewarming + +**The single biggest reason lawn feels instant.** When a user hovers over a link, the app starts fetching data for the destination route *before* they click. By the time they click, the data is already there. + +### The Hook + +```typescript +// useRoutePrewarmIntent.ts +type PrewarmFn = () => void | Promise; + +export function useRoutePrewarmIntent( + prewarmFn: PrewarmFn, + options: { debounceMs?: number } = {}, +): RoutePrewarmIntentHandlers { + const prewarmRef = useRef(prewarmFn); + prewarmRef.current = prewarmFn; + + const controller = useMemo( + () => createRoutePrewarmIntent(() => prewarmRef.current(), options), + [options.debounceMs], + ); + + useEffect(() => () => controller.cancel(), [controller]); + + return controller.handlers; +} + +function createRoutePrewarmIntent(prewarmFn, options = {}) { + const debounceMs = options.debounceMs ?? 120; // 120ms debounce + let timer; + + const cancel = () => { clearTimeout(timer); timer = undefined; }; + const schedule = () => { + if (timer) return; + timer = setTimeout(() => { + timer = undefined; + Promise.resolve(prewarmFn()).catch(console.warn); + }, debounceMs); + }; + + return { + handlers: { + onMouseEnter: schedule, + onFocus: schedule, + onTouchStart: schedule, // Mobile support + onMouseLeave: cancel, + onBlur: cancel, + }, + cancel, + }; +} +``` + +### Usage + +```tsx +function VideoCard({ video, teamSlug, projectId }) { + const convex = useConvex(); + + const prewarmHandlers = useRoutePrewarmIntent(() => { + // Prewarm the route data + prewarmVideo(convex, { teamSlug, projectId, videoId: video._id }); + // Also prefetch the video player runtime + prefetchHlsRuntime(); + // And the video manifest + if (video.muxPlaybackId) { + prefetchMuxPlaybackManifest(video.muxPlaybackId); + } + }); + + return ( +
+ {/* card content */} +
+ ); +} +``` + +### Why This Works + +- **120ms debounce** prevents prefetching on accidental mouse passes +- **Cancel on leave** prevents wasted requests when the user moves away +- **Touch support** makes it work on mobile (fires on first tap in touch events) +- **Non-blocking** — all prewarm failures are caught and logged, never block navigation + +### When to Use + +Apply to any interactive element (cards, links, buttons) that navigates to a data-heavy page. The heavier the destination page, the bigger the win. + +--- + +## 2. Two-Stage Data Prefetching + +Not all data for a route can be fetched in parallel. Some queries depend on the result of others. lawn solves this with a two-stage prewarm. + +### The Pattern + +```typescript +// -team.data.ts +export async function prewarmTeam(convex, params: { teamSlug: string }) { + // STAGE 1: Fire the essential query immediately + prewarmSpecs(convex, getTeamEssentialSpecs(params)); + + try { + // STAGE 2: Await the result, then prewarm dependent queries + const context = await convex.query(api.workspace.resolveContext, { + teamSlug: params.teamSlug, + }); + + if (!context?.team?._id) return; + + prewarmSpecs(convex, [ + makeRouteQuerySpec(api.projects.list, { teamId: context.team._id }), + makeRouteQuerySpec(api.billing.getTeamBilling, { teamId: context.team._id }), + ]); + } catch (error) { + console.warn("Team dependent prewarm failed", error); + } +} +``` + +### The Deduplication Layer + +```typescript +const PREWARM_DEBOUNCE_MS = 120; // Debounce intent triggers +const PREWARM_EXTEND_MS = 8_000; // Keep subscription alive 8s after prewarm +const PREWARM_DEDUPE_MS = 3_000; // Don't re-prewarm within 3s window + +const lastPrewarmedAt = new Map(); + +export function prewarmSpecs(convex, specs, options = {}) { + const dedupeMs = options.dedupeMs ?? PREWARM_DEDUPE_MS; + const now = Date.now(); + + for (const spec of specs) { + const previous = lastPrewarmedAt.get(spec.key); + if (previous !== undefined && now - previous < dedupeMs) { + continue; // Already prewarmed recently, skip + } + lastPrewarmedAt.set(spec.key, now); + + try { + convex.prewarmQuery({ + query: spec.query, + args: spec.args, + extendSubscriptionFor: options.extendSubscriptionFor ?? PREWARM_EXTEND_MS, + }); + } catch (error) { + console.warn("Prewarm failed", { key: spec.key, error }); + } + } +} +``` + +### Key Insight + +The `extendSubscriptionFor: 8_000` is critical. It tells Convex "keep this subscription alive for 8 more seconds even if no component is using it yet." This bridges the gap between hover-prewarm and the actual navigation mounting the component. + +### Adapting for React Query / SWR + +```typescript +// React Query equivalent +async function prewarmTeam(queryClient, params) { + // Stage 1 + queryClient.prefetchQuery({ + queryKey: ['workspace', params.teamSlug], + queryFn: () => fetchWorkspace(params.teamSlug), + staleTime: 8_000, + }); + + // Stage 2 + try { + const context = await queryClient.fetchQuery({ + queryKey: ['workspace', params.teamSlug], + queryFn: () => fetchWorkspace(params.teamSlug), + }); + + if (!context?.team?._id) return; + + queryClient.prefetchQuery({ + queryKey: ['projects', context.team._id], + queryFn: () => fetchProjects(context.team._id), + staleTime: 8_000, + }); + } catch {} +} +``` + +--- + +## 3. Multi-Layer Network Prefetching + +lawn prefetches at 4 distinct layers, each solving a different latency bottleneck: + +### Layer 1: DNS Prefetch + Preconnect (HTML head, on page load) + +```typescript +// __root.tsx — head links +links: [ + { rel: "preconnect", href: "https://stream.mux.com", crossOrigin: "anonymous" }, + { rel: "preconnect", href: "https://image.mux.com", crossOrigin: "anonymous" }, + { rel: "dns-prefetch", href: "//stream.mux.com" }, + { rel: "dns-prefetch", href: "//image.mux.com" }, +] +``` + +**Why both?** `preconnect` does DNS + TCP + TLS. `dns-prefetch` is a fallback for browsers that don't support preconnect. Costs nothing, saves 100-300ms on first media request. + +### Layer 2: HLS Runtime Prefetch (on hover, before playback) + +```typescript +let hlsRuntimePrefetched = false; + +export function prefetchHlsRuntime() { + if (typeof window === "undefined") return; + if (hlsRuntimePrefetched) return; + hlsRuntimePrefetched = true; + + // Dynamic import fires the chunk download but doesn't block anything + import("hls.js").catch(() => {}); +} +``` + +The hls.js library is ~200KB. By prefetching it on hover, the video player doesn't wait for a cold dynamic import. + +### Layer 3: Video Manifest Prefetch (on hover, per video) + +```typescript +const prefetchedPlaybackIds = new Set(); + +export function prefetchMuxPlaybackManifest(playbackId: string) { + if (typeof window === "undefined") return; + if (prefetchedPlaybackIds.has(playbackId)) return; + prefetchedPlaybackIds.add(playbackId); + + const url = `https://stream.mux.com/${playbackId}.m3u8`; + fetch(url, { + method: "GET", + mode: "cors", + credentials: "omit", + cache: "force-cache", // Browser caches the response + }).catch(() => {}); +} +``` + +### Layer 4: Route Data Prewarm (on hover, via Convex) + +See patterns #1 and #2 above. + +### The Combined Effect on Hover + +When a user hovers a video card, all 4 layers fire simultaneously: +1. DNS/TCP is already warm (from page load) +2. hls.js chunk starts downloading +3. `.m3u8` manifest starts downloading +4. Video metadata + comments start loading from Convex + +By the time they click, navigate, and the video player mounts — everything is ready. + +--- + +## 4. Conditional Query Skipping (Waterfall Prevention) + +lawn uses a `"skip"` pattern to handle dependent queries without waterfalls: + +```typescript +export function useVideoData(params) { + // Query 1: Always fires immediately + const context = useQuery(api.workspace.resolveContext, { + teamSlug: params.teamSlug, + projectId: params.projectId, + videoId: params.videoId, + }); + + const resolvedVideoId = context?.video?._id; + + // Queries 2-4: Skip until context resolves, then fire in parallel + const video = useQuery( + api.videos.get, + resolvedVideoId ? { videoId: resolvedVideoId } : "skip", + ); + const comments = useQuery( + api.comments.list, + resolvedVideoId ? { videoId: resolvedVideoId } : "skip", + ); + const commentsThreaded = useQuery( + api.comments.getThreaded, + resolvedVideoId ? { videoId: resolvedVideoId } : "skip", + ); + + return { context, video, comments, commentsThreaded }; +} +``` + +### Why This Matters + +Without `"skip"`, you'd either: +- Fetch everything sequentially (waterfall) +- Fetch with potentially stale IDs (bugs) + +With `"skip"`, dependent queries stay dormant until their input is available, then fire in parallel. React Query equivalent: `enabled: !!resolvedVideoId`. + +--- + +## 5. HLS Runtime Lazy-Load with Prefetch + +The video player dynamically imports hls.js only when needed, with graceful fallback: + +```typescript +const attachSource = async () => { + // Clean up previous HLS instance + if (hlsRef.current) { + hlsRef.current.destroy(); + hlsRef.current = null; + } + + if (isHlsSource(src)) { + // Dynamic import — already cached if prefetchHlsRuntime() ran + const { default: Hls } = await import("hls.js"); + if (cancelled) return; + + if (Hls.isSupported()) { + const hls = new Hls({ enableWorker: true }); // Offload parsing to Web Worker + hlsRef.current = hls; + hls.loadSource(src); + hls.attachMedia(video); + + hls.on(Hls.Events.MANIFEST_PARSED, () => { + // Build quality options from manifest levels + const dedupedByHeight = new Map(); + hls.levels.forEach((levelInfo, levelIndex) => { + const height = levelInfo.height; + if (!height) return; + const existing = dedupedByHeight.get(height); + if (!existing || levelInfo.bitrate >= existing.bitrate) { + dedupedByHeight.set(height, { level: levelIndex, bitrate: levelInfo.bitrate }); + } + }); + setQualityOptions(/* sorted array */); + }); + } else { + // Safari native HLS fallback + video.src = src; + } + } else { + video.src = src; + } +}; + +// Outer try-catch for the entire flow +attachSource().catch(() => { + video.src = src; // Ultimate fallback +}); +``` + +### Key Details + +- `enableWorker: true` — HLS manifest parsing happens in a Web Worker, keeping the main thread free +- Quality deduplication by height — prevents showing "720p" twice when there are multiple 720p bitrate variants +- Triple fallback: HLS.js → native HLS (Safari) → direct src +- The `cancelled` flag prevents race conditions when the component unmounts during the async import + +--- + +## 6. Theme Flash Prevention + +lawn prevents the "flash of wrong theme" with an inline script that runs before React hydrates: + +```typescript +function RootDocument({ children }) { + const themeInitScript = ` + (() => { + try { + const stored = localStorage.getItem("lawn-theme"); + if (stored === "light" || stored === "dark") { + document.documentElement.setAttribute("data-theme", stored); + return; + } + const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; + if (prefersDark) { + document.documentElement.setAttribute("data-theme", "dark"); + } + } catch {} + })(); + `; + + return ( + + + +