# 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
