nexus/.planning/phases/26-pwa-performance/26-01-PLAN.md

206 lines
9 KiB
Markdown

---
phase: 26-pwa-performance
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- ui/src/App.tsx
- ui/vite.config.ts
autonomous: true
requirements:
- PERF-01
- PERF-05
must_haves:
truths:
- "All page components in App.tsx are loaded via React.lazy"
- "Route navigation renders a skeleton fallback during chunk load"
- "Vite build produces separate vendor chunks for heavy libraries"
- "Main entry chunk is significantly smaller than the pre-split 1.4 MB"
artifacts:
- path: "ui/src/App.tsx"
provides: "Lazy-loaded page routes with Suspense"
contains: "lazy("
- path: "ui/vite.config.ts"
provides: "Manual chunk splitting for vendor libraries"
contains: "manualChunks"
key_links:
- from: "ui/src/App.tsx"
to: "ui/src/pages/*"
via: "React.lazy(() => import('./pages/...'))"
pattern: "lazy\\("
- from: "ui/vite.config.ts"
to: "node_modules"
via: "manualChunks vendor splitting"
pattern: "manualChunks"
---
<objective>
Convert all eager page imports in App.tsx to React.lazy with Suspense, and add manual chunk splitting in vite.config.ts to extract heavy vendor libraries into separate bundles.
Purpose: Reduces the main bundle from ~1.4 MB to ~200-400 KB, achieving PERF-01 (initial load < 2s broadband, < 5s on 3G) and contributing to PERF-05 (cached load < 1s with smaller chunks to cache).
Output: Lazy-loaded App.tsx, vendor-chunked vite.config.ts, verified build output.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/26-pwa-performance/26-RESEARCH.md
@ui/src/App.tsx
@ui/vite.config.ts
</context>
<tasks>
<task type="auto">
<name>Task 1: Convert App.tsx page imports to React.lazy with Suspense</name>
<files>ui/src/App.tsx</files>
<read_first>
- ui/src/App.tsx
- ui/src/components/ui/skeleton.tsx
</read_first>
<action>
1. Add `lazy, Suspense` to the React import at the top of App.tsx (import from "react").
2. Convert ALL page component imports (lines 9-47 in current file) from eager to lazy. Each page import becomes:
```
const Dashboard = lazy(() => import("./pages/Dashboard"));
const Companies = lazy(() => import("./pages/Companies"));
const Agents = lazy(() => import("./pages/Agents"));
const AgentDetail = lazy(() => import("./pages/AgentDetail"));
const Projects = lazy(() => import("./pages/Projects"));
const ProjectDetail = lazy(() => import("./pages/ProjectDetail"));
const ProjectWorkspaceDetail = lazy(() => import("./pages/ProjectWorkspaceDetail"));
const Issues = lazy(() => import("./pages/Issues"));
const IssueDetail = lazy(() => import("./pages/IssueDetail"));
const Routines = lazy(() => import("./pages/Routines"));
const RoutineDetail = lazy(() => import("./pages/RoutineDetail"));
const ExecutionWorkspaceDetail = lazy(() => import("./pages/ExecutionWorkspaceDetail"));
const Goals = lazy(() => import("./pages/Goals"));
const GoalDetail = lazy(() => import("./pages/GoalDetail"));
const Approvals = lazy(() => import("./pages/Approvals"));
const ApprovalDetail = lazy(() => import("./pages/ApprovalDetail"));
const Costs = lazy(() => import("./pages/Costs"));
const Activity = lazy(() => import("./pages/Activity"));
const Inbox = lazy(() => import("./pages/Inbox"));
const CompanySettings = lazy(() => import("./pages/CompanySettings"));
const SkillBrowser = lazy(() => import("./pages/SkillBrowser"));
const SkillDetail = lazy(() => import("./pages/SkillDetail"));
const CompanyExport = lazy(() => import("./pages/CompanyExport"));
const CompanyImport = lazy(() => import("./pages/CompanyImport"));
const DesignGuide = lazy(() => import("./pages/DesignGuide"));
const InstanceGeneralSettings = lazy(() => import("./pages/InstanceGeneralSettings"));
const InstanceSettings = lazy(() => import("./pages/InstanceSettings"));
const InstanceExperimentalSettings = lazy(() => import("./pages/InstanceExperimentalSettings"));
const PluginManager = lazy(() => import("./pages/PluginManager"));
const PluginSettings = lazy(() => import("./pages/PluginSettings"));
const PluginPage = lazy(() => import("./pages/PluginPage"));
const RunTranscriptUxLab = lazy(() => import("./pages/RunTranscriptUxLab"));
const OrgChart = lazy(() => import("./pages/OrgChart"));
const NewAgent = lazy(() => import("./pages/NewAgent"));
const AuthPage = lazy(() => import("./pages/Auth"));
const BoardClaimPage = lazy(() => import("./pages/BoardClaim"));
const CliAuthPage = lazy(() => import("./pages/CliAuth"));
const InviteLandingPage = lazy(() => import("./pages/InviteLanding"));
const NotFoundPage = lazy(() => import("./pages/NotFound"));
```
3. Keep ALL non-page imports as eager (Layout, OnboardingWizard, authApi, healthApi, queryKeys, context hooks, lib utils). These are needed for the app shell and should not be lazy.
4. Ensure each page module uses `export default` — check if pages use named exports. If they use `export function PageName`, the lazy import syntax needs: `lazy(() => import("./pages/PageName").then(m => ({ default: m.PageName })))`. Check the actual export style and adjust accordingly.
5. Wrap the `<Routes>` block inside the `App()` function with a `<Suspense>` boundary:
```tsx
<Suspense fallback={<div className="flex items-center justify-center h-full"><Skeleton className="h-8 w-48" /></div>}>
<Routes>
{/* existing routes unchanged */}
</Routes>
</Suspense>
```
Import `Skeleton` from `@/components/ui/skeleton`.
6. Do NOT lazy-load `OnboardingWizard` — it renders outside Routes and is always needed.
</action>
<verify>
<automated>grep -c "lazy(" ui/src/App.tsx && grep -q "Suspense" ui/src/App.tsx && echo "PASS"</automated>
</verify>
<acceptance_criteria>
- `grep -c "lazy(" ui/src/App.tsx` returns 39 or more (one per page component)
- `grep "Suspense" ui/src/App.tsx` shows Suspense wrapper
- `grep "Skeleton" ui/src/App.tsx` shows skeleton import and usage in fallback
- No eager `import { Dashboard }` style page imports remain
- Non-page imports (Layout, OnboardingWizard, authApi, etc.) remain eager
- `pnpm --filter @paperclipai/ui build` succeeds without errors
</acceptance_criteria>
<done>All page imports converted to React.lazy. Suspense boundary wraps Routes with Skeleton fallback. Build succeeds.</done>
</task>
<task type="auto">
<name>Task 2: Add manual vendor chunk splitting to Vite config</name>
<files>ui/vite.config.ts</files>
<read_first>
- ui/vite.config.ts
- .planning/phases/26-pwa-performance/26-RESEARCH.md
</read_first>
<action>
1. Add `build.rollupOptions.output.manualChunks` to `vite.config.ts`:
```typescript
build: {
rollupOptions: {
output: {
manualChunks: {
"vendor-react": ["react", "react-dom"],
"vendor-router": ["react-router-dom"],
"vendor-query": ["@tanstack/react-query"],
"vendor-markdown": ["react-markdown", "remark-gfm"],
},
},
},
},
```
2. Add chunks one at a time conceptually — but write them all at once. The key concern per RESEARCH Pitfall 5 is circular dependency errors. Start with the safest boundaries (react, react-dom are always clean).
3. Do NOT include `@mdxeditor/editor` in manualChunks — it has complex internal imports that may cause circular dependency errors. Let Vite's default splitting handle it.
4. Do NOT include `rehype-highlight` — it was replaced by manual highlight.js usage in Phase 25.
5. Run `pnpm --filter @paperclipai/ui build` after writing the config to verify no circular dependency errors.
6. If the build fails with circular dependency errors on any specific chunk, remove that chunk from `manualChunks` and retry. Document which chunks were removed and why in the summary.
</action>
<verify>
<automated>pnpm --filter @paperclipai/ui build 2>&1 | tail -5 && grep -q "manualChunks" ui/vite.config.ts && echo "PASS"</automated>
</verify>
<acceptance_criteria>
- `grep "manualChunks" ui/vite.config.ts` shows the configuration
- `grep "vendor-react" ui/vite.config.ts` shows react chunk
- `pnpm --filter @paperclipai/ui build` succeeds without errors
- Build output shows multiple vendor chunk files in dist/assets/
</acceptance_criteria>
<done>Vite config has manualChunks for vendor splitting. Build succeeds. Multiple vendor chunks produced in dist/assets/.</done>
</task>
</tasks>
<verification>
- `pnpm --filter @paperclipai/ui build` succeeds
- `grep -c "lazy(" ui/src/App.tsx` returns 39+
- `grep "manualChunks" ui/vite.config.ts` shows configuration
- Build output in `ui/dist/assets/` shows separate vendor-*.js chunks
</verification>
<success_criteria>
All page imports lazy-loaded. Vendor chunks extracted. Build succeeds. Main entry bundle significantly reduced from ~1.4 MB baseline.
</success_criteria>
<output>
After completion, create `.planning/phases/26-pwa-performance/26-01-SUMMARY.md`
</output>