docs: add frontend performance optimization design spec
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
# Frontend Performance Optimization — Design Spec
|
||||
|
||||
**Date:** 2026-05-24
|
||||
**Author:** opencode
|
||||
**Status:** Draft
|
||||
|
||||
## Problem
|
||||
|
||||
Lighthouse performance score: **0.51**
|
||||
|
||||
| Metric | Value | Score |
|
||||
|--------|-------|-------|
|
||||
| FCP | 5.5s | 0 |
|
||||
| LCP | 10.8s | 0 |
|
||||
| TBT | 10ms | 1 |
|
||||
| CLS | 0.122 | 0.84 |
|
||||
|
||||
**Root causes:**
|
||||
- 9.9 MB total transfer size (dev mode, 368 requests)
|
||||
- maplibre-gl (1.3 MB) loaded on every page, used only in About + AddressMapPicker
|
||||
- 18 @dicebear packages (~2 MB) statically imported, never code-split
|
||||
- TipTap (116 KB) always loaded, used only in Messages/Reviews
|
||||
- Only 2 of 14+ routes lazy-loaded
|
||||
- Zero React.memo usage — uncontrolled re-renders
|
||||
|
||||
## Architecture
|
||||
|
||||
### 1. Code-split maplibre-gl
|
||||
|
||||
**Files affected:**
|
||||
- `client/src/main.tsx` — remove global `import 'maplibre-gl/dist/maplibre-gl.css'`
|
||||
- `client/src/features/address-map-picker/ui/AddressMapPicker.tsx` — dynamic import
|
||||
- `client/src/pages/about/ui/AboutPage.tsx` — dynamic import
|
||||
|
||||
**Implementation:**
|
||||
- Remove CSS import from `main.tsx`
|
||||
- In components that use map, load CSS + JS via `import()` inside `useEffect` or `React.lazy`
|
||||
- Show loading skeleton while map loads
|
||||
|
||||
### 2. Code-split TipTap
|
||||
|
||||
**Files affected:**
|
||||
- `client/src/shared/ui/RichTextMessageContent.tsx` — lazy wrapper
|
||||
- `client/src/shared/ui/RichTextMessageEditor.tsx` — lazy wrapper
|
||||
- `client/src/features/order-chat/` — add Suspense boundary
|
||||
- `client/src/widgets/reviews-block/` — add Suspense boundary
|
||||
|
||||
**Implementation:**
|
||||
- Create lazy exports in `shared/ui/index.ts`
|
||||
- Wrap TipTap-dependent components in `<Suspense fallback={<Skeleton>}>`
|
||||
|
||||
### 3. Code-split dicebear avatar styles
|
||||
|
||||
**Files affected:**
|
||||
- `client/src/shared/lib/avatar-styles.ts` — dynamic import map
|
||||
|
||||
**Implementation:**
|
||||
- Replace static imports with factory map: `{ styleName: () => import('@dicebear/style') }`
|
||||
- Cache loaded styles in `Map<string, Style>`
|
||||
- Load style on first use, not at module initialization
|
||||
|
||||
### 4. Lazy-load all public routes
|
||||
|
||||
**Files affected:**
|
||||
- `client/src/app/routes/index.tsx` — convert all routes to lazy + Suspense
|
||||
|
||||
**Routes to lazy-load:**
|
||||
- `/` (HomePage)
|
||||
- `/auth` (AuthPage)
|
||||
- `/auth/callback` (AuthCallbackPage)
|
||||
- `/cart` (CartPage)
|
||||
- `/checkout` (CheckoutPage)
|
||||
- `/about` (AboutPage)
|
||||
- `/info/*` (InfoPage)
|
||||
- `/privacy` (PrivacyPolicyPage)
|
||||
- `/terms` (TermsPage)
|
||||
- `/products/:id` (ProductPage)
|
||||
|
||||
**Exception:** 404 route stays synchronous (lightweight).
|
||||
|
||||
**Fallback:** `SkeletonPage` for consistency with existing admin/me lazy routes.
|
||||
|
||||
### 5. React.memo on key components
|
||||
|
||||
**Files affected:**
|
||||
- `client/src/entities/product/ui/ProductCard.tsx`
|
||||
- `client/src/shared/ui/OptimizedImage.tsx`
|
||||
- `client/src/shared/ui/UserAvatar.tsx`
|
||||
- `client/src/app/layout/AppHeader.tsx`
|
||||
|
||||
**Implementation:**
|
||||
- `ProductCard`: memo with custom comparator comparing `product.id` and `onClick` reference
|
||||
- `OptimizedImage`: memo comparing `src`, `alt`, `priority`
|
||||
- `UserAvatar`: memo comparing `userId`, `style`
|
||||
- `AppHeader`: memo (default shallow comparison sufficient)
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
User navigates → Router loads lazy route chunk → Component mounts
|
||||
↓
|
||||
Dynamic imports (maplibre/TipTap) fire
|
||||
↓
|
||||
Suspense shows fallback → Content renders
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
- All `React.lazy` components wrapped in `<Suspense>` with fallback
|
||||
- Dynamic imports wrapped in try/catch with error boundary fallback
|
||||
- Dicebear style loading: fallback to default style if dynamic import fails
|
||||
|
||||
## Testing
|
||||
|
||||
- Verify routes still work after lazy conversion (manual navigation)
|
||||
- Verify map loads correctly in AboutPage and AddressMapPicker
|
||||
- Verify TipTap renders in Messages and Reviews
|
||||
- Verify avatars render with correct styles
|
||||
- Run `npm run lint` and `npm test` after changes
|
||||
- Run `npm run build` to verify production bundle
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- Initial bundle size reduced by ~3-4 MB (dev mode)
|
||||
- FCP improved from 5.5s to < 2.5s
|
||||
- LCP improved from 10.8s to < 4.0s
|
||||
- No regressions in functionality
|
||||
- All tests pass
|
||||
- Lint passes
|
||||
Reference in New Issue
Block a user