afv-library/skills/experience-ui-bundle-performance-optimize/SKILL.md
Claude 0934e64daa
feat: add experience-ui-bundle-performance-optimize skill
Adds a UI-focused skill that fills a measurable gap in the library: no
existing skill covers bundle-size analysis, code splitting, lazy route
loading, or render-waste diagnosis for React UI bundles (0 mentions of
vitest/web vitals/code splitting across all 87 skills).

The workflow is grounded in this repo's own samples: the Property
Management sample builds a single 1,104 kB entry chunk (Vite warns), and
applying the skill's route-splitting + manualChunks steps reduces it to a
50 kB entry with 9-16 kB lazy route chunks, verified end-to-end at
runtime. Includes scripts/analyze-bundle.mjs, a budget checker that exits
non-zero for CI gating, and documents the substring-matching pitfall where
id.includes('react') silently absorbs lucide-react into the react chunk.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EyvKMp1jFRs38dP1EYj458
2026-07-03 05:08:29 +00:00

136 lines
7.8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
name: experience-ui-bundle-performance-optimize
description: "MUST activate when a UI bundle build prints chunk-size warnings (\"Some chunks are larger than 500 kB\"), when the user mentions slow load, bundle size, code splitting, lazy loading, re-render, or Core Web Vitals for an app under uiBundles/*/src/, or when auditing frontend performance of a React UI bundle. Use this skill to measure bundle output, split oversized chunks, lazy-load routes, and fix render waste. Do NOT use for visual/styling changes (use experience-ui-bundle-frontend-generate), for creating new apps (use experience-ui-bundle-app-coordinate), or for Apex/server performance (use platform-apex-logs-debug)."
compatibility: UI Bundle React template (Vite 5+, React 18/19, react-router 7)
metadata:
version: "1.0"
relatedSkills: experience-ui-bundle-frontend-generate, experience-ui-bundle-app-coordinate, experience-ui-bundle-deploy
---
# UI Bundle Performance Optimization
Measure first, then optimize. Every change in this skill is driven by build output and verified by rebuilding — never apply techniques speculatively.
UI bundles run inside the Salesforce platform, so first-load JavaScript is the dominant cost: the bundle is fetched through the org's CDN/proxy and shares the page with platform chrome. A default template app that imports charts, Radix primitives, and all pages eagerly ships a single ~1.1 MB entry chunk (~326 kB gzip). The workflow below reduced that entry to ~50 kB (16 kB gzip) in the sample Property Management app with two localized changes.
## Workflow
1. **Measure** — build and read the chunk report (or run `scripts/analyze-bundle.mjs`).
2. **Split routes** — lazy-load every non-index page with `React.lazy`.
3. **Split vendors** — separate rarely-changing dependencies with `manualChunks`.
4. **Fix render waste** — only if the user reports interaction jank, not by default.
5. **Verify** — rebuild, compare the report, and click through the app in dev/preview.
---
## 1. Measure the Current Bundle
```bash
npm run build
```
Read the `dist/assets/*.js` lines Vite prints. Or run the bundled analyzer for budgets and JSON output:
```bash
node <skill-dir>/scripts/analyze-bundle.mjs <app-dir>/dist
```
Budgets (raw, minified — not gzip):
| Asset | Budget | Action when exceeded |
|---|---|---|
| Entry chunk (`index-*.js`) | ≤ 300 kB | Split routes (§2), then vendors (§3) |
| Any single chunk | ≤ 500 kB | Split that chunk's largest dependency |
| Route (lazy) chunk | ≤ 100 kB | Move heavy deps inside the page behind `lazy` |
| CSS total | ≤ 150 kB | Check Tailwind content globs; purge unused |
If the entry chunk is already within budget, stop — report the numbers and make no changes.
## 2. Route-Level Code Splitting
`routes.tsx` is the single routing source of truth in this template. Convert every page except the index route and `NotFound` to `React.lazy`:
```tsx
import { lazy, Suspense } from 'react';
import AppLayout from './appLayout';
import Home from './pages/Home'; // eager: first paint
import NotFound from './pages/NotFound'; // eager: tiny, avoids flash on bad URLs
const PropertySearch = lazy(() => import('./pages/PropertySearch'));
const suspend = (el: React.ReactNode) => <Suspense fallback={null}>{el}</Suspense>;
// in the RouteObject tree:
{ path: 'properties', element: suspend(<PropertySearch />), handle: { showInNavigation: true, label: 'Properties' } }
```
Rules:
- Keep the index route eager — lazy-loading it delays first paint instead of improving it.
- Keep `handle` metadata exactly as-is; navigation in `appLayout.tsx` is generated from it.
- Use `fallback={null}` or a lightweight skeleton — never a spinner that flashes on fast networks.
- Do not lazy-load `appLayout.tsx`; it wraps every route.
- Each page becomes its own chunk (typically 916 kB in this template) fetched on first navigation.
## 3. Vendor Chunking with `manualChunks`
In `vite.config.ts`, add `rollupOptions.output.manualChunks` inside the existing `build` block. Match specific packages before generic ones:
```ts
build: {
// ...existing outDir/assetsDir/sourcemap...
rollupOptions: {
output: {
manualChunks(id) {
if (!id.includes('node_modules')) return;
if (id.includes('recharts') || id.includes('d3-')) return 'charts';
if (id.includes('radix-ui')) return 'radix';
if (id.includes('lucide-react')) return 'icons'; // BEFORE the react check
if (id.includes('react-router')) return 'router';
if (id.includes('react')) return 'react';
return 'vendor';
},
},
},
},
```
**Pitfall — substring matching:** `id.includes('react')` also matches `lucide-react`, `react-day-picker`, and anything else with "react" in its path. Match the specific packages *first* (as above) or the react chunk silently absorbs them and stays oversized. After any `manualChunks` change, confirm chunk contents shifted the way you expected — sizes in the build report are the evidence.
Why vendor splitting matters even when total bytes stay the same: vendor chunks change rarely, so their hashed filenames survive app-code deploys and stay in the browser cache; and heavy optional deps (charts) stop blocking pages that never render a chart.
## 4. Render Waste (only when interaction is slow)
Apply only with a reported symptom (typing lag, slow list scroll, sluggish tab switch):
- Wrap list-row components in `React.memo` when a parent re-renders frequently with unchanged row props.
- Hoist object/array literals and inline handlers out of JSX in hot paths (`useMemo`/`useCallback`) — but only where profiling shows re-renders, otherwise it is noise.
- Paginate or virtualize lists over ~200 rows; this template already ships `PaginationControls` — prefer it over rendering full result sets.
- Debounce search inputs that fire a GraphQL query per keystroke (300 ms is a good default).
- Never fetch in a component body; keep data access in the existing hooks/api layer so React Query-style caching (or memoized hooks) prevents duplicate requests.
Diagnose with React DevTools Profiler (in dev mode) before changing code; state the suspected wasteful component and confirm it in the flame graph.
## 5. Verify
1. `npm run build` — compare against the §1 baseline. Expect: entry chunk sharply smaller, one chunk per lazy page, named vendor chunks, and no Vite size warning.
2. Run the analyzer again — it exits non-zero if any budget is still exceeded.
3. `npm run dev` (or `npm run preview` for the built output) and click every navigation entry. Watch the network panel: each first visit to a lazy route should fetch exactly that route's chunk, and revisits should fetch nothing.
4. `npm test` if the project has tests; route conversion to `lazy` must not change rendered output.
Report to the user: before/after entry size, chunk count, and which techniques were applied. Example from the sample Property Management app:
| | Before | After (§2 + §3) |
|---|---|---|
| Entry chunk | 1,104.74 kB (326 kB gzip) | 50.21 kB (16.3 kB gzip) |
| Route chunks | none (all eager) | 916 kB each, loaded on navigation |
| Vendor chunks | none | react / router / radix / charts, cached across deploys |
| Vite size warning | yes | no |
## Boundaries
- **Do not** enable `sourcemap: true` in production builds to "measure" — use the build report or the analyzer script.
- **Do not** remove or rename the existing `outDir`/`assetsDir`/`base: './'` settings; deployment (`experience-ui-bundle-deploy`) depends on them.
- **Do not** replace the GraphQL data layer, router, or component library in the name of performance — that is a rewrite, not an optimization; confirm scope with the user first.
- Salesforce org latency (GraphQL 401s locally, API response times) is not addressable from the bundle; direct data-layer concerns to `experience-ui-bundle-salesforce-data-access`.