mirror of
https://github.com/forcedotcom/afv-library.git
synced 2026-07-30 03:09:50 +08:00
Compare commits
4 Commits
da93e66778
...
8f6e86b240
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f6e86b240 | ||
|
|
9c22002f57 | ||
|
|
a791b8b262 | ||
|
|
da0a822367 |
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@salesforce/afv-skills",
|
||||
"version": "1.13.0",
|
||||
"version": "1.27.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@salesforce/afv-skills",
|
||||
"version": "1.13.0",
|
||||
"version": "1.27.0",
|
||||
"license": "CC-BY-NC-4.0",
|
||||
"devDependencies": {
|
||||
"@salesforce/ui-bundle-template-app-react-sample-b2e": "^10.2.2",
|
||||
|
||||
68
skills/experience-ui-bundle-performance-optimize/README.md
Normal file
68
skills/experience-ui-bundle-performance-optimize/README.md
Normal file
@ -0,0 +1,68 @@
|
||||
# experience-ui-bundle-performance-optimize
|
||||
|
||||
Frontend performance auditing and optimization skill for Salesforce React UI Bundles. Measures Vite build output against size budgets, then applies route-level code splitting and vendor chunking to cut first-load JavaScript — without touching data layer, routing structure, or visual design.
|
||||
|
||||
## Features
|
||||
|
||||
- **Bundle Measurement**: Reads Vite's build report (or runs the bundled analyzer) against explicit size budgets for entry chunks, individual chunks, route chunks, and CSS
|
||||
- **Route-Level Code Splitting**: Converts eager page imports in `routes.tsx` to `React.lazy`, so each page ships its own chunk fetched on first navigation
|
||||
- **Vendor Chunking**: Configures `manualChunks` in `vite.config.ts` to separate rarely-changing dependencies (React, router, UI primitives, charts) into their own cacheable chunks
|
||||
- **Render-Waste Diagnosis**: Guidance for `memo`/`useMemo`/debouncing/virtualization — applied only against a reported symptom, not speculatively
|
||||
- **Verification Workflow**: Rebuild, re-measure, and click through every navigation entry to confirm lazy chunks load correctly
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Invoke the skill
|
||||
|
||||
```
|
||||
Skill: experience-ui-bundle-performance-optimize
|
||||
Request: "This UI bundle's build is throwing a chunk-size warning, can you fix it?"
|
||||
```
|
||||
|
||||
The skill also self-activates when a build prints Vite's `"Some chunks are larger than 500 kB"` warning, or when the user mentions slow load, bundle size, code splitting, or Core Web Vitals for a project under `uiBundles/*/src/`.
|
||||
|
||||
### 2. Run the analyzer directly
|
||||
|
||||
```bash
|
||||
node skills/experience-ui-bundle-performance-optimize/scripts/analyze-bundle.mjs <app-dir>/dist
|
||||
```
|
||||
|
||||
Add `--json` for machine-readable output. Exits `0` when every budget passes, `1` when any budget is exceeded (suitable for CI gating), `2` on usage/IO errors.
|
||||
|
||||
### 3. Typical use cases
|
||||
|
||||
- Diagnose why a UI bundle's `npm run build` prints a chunk-size warning
|
||||
- Reduce first-load JavaScript before deploying a UI bundle to an org
|
||||
- Add code splitting to a UI bundle app that has grown past its initial page set
|
||||
- Investigate reported UI sluggishness (typing lag, slow list scroll, tab-switch delay)
|
||||
|
||||
## Results on the Reference Sample
|
||||
|
||||
Applied to `samples/ui-bundle-template-app-react-sample-b2e` (Property Management app):
|
||||
|
||||
| | Before | After |
|
||||
|---|---|---|
|
||||
| Entry chunk | 1,104.74 kB (326.44 kB gzip) | 50.21 kB (16.28 kB gzip) |
|
||||
| Route chunks | none — all pages eager | 4 chunks, 9–16 kB each, loaded on navigation |
|
||||
| Vendor chunks | none | `react`, `router`, `radix`, `charts` — cacheable across deploys |
|
||||
| Vite size warning | yes | no |
|
||||
|
||||
Verified at runtime: the home route renders correctly, and navigating to each page fetches exactly that page's lazy chunk (confirmed via network trace), nothing more.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [SKILL.md](SKILL.md) — full workflow: measure → split routes → split vendors → fix render waste → verify, with size budgets and template-specific rules
|
||||
- [scripts/analyze-bundle.mjs](scripts/analyze-bundle.mjs) — standalone budget checker for `dist/` output
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Does not touch the GraphQL data layer, router library, or component library — that is a rewrite, not an optimization
|
||||
- Does not enable production sourcemaps to "measure" — uses the build report or the analyzer instead
|
||||
- Does not change `outDir`, `assetsDir`, or `base` in `vite.config.ts` — those are relied on by deployment
|
||||
|
||||
## Related Skills
|
||||
|
||||
- `experience-ui-bundle-frontend-generate` — for visual/styling changes to an existing UI bundle app
|
||||
- `experience-ui-bundle-app-coordinate` — for building a new UI bundle app from scratch
|
||||
- `experience-ui-bundle-deploy` — for deploying the optimized bundle to a Salesforce org
|
||||
- `experience-ui-bundle-salesforce-data-access` — for GraphQL/data-layer concerns (org latency, query shape) that this skill does not address
|
||||
135
skills/experience-ui-bundle-performance-optimize/SKILL.md
Normal file
135
skills/experience-ui-bundle-performance-optimize/SKILL.md
Normal file
@ -0,0 +1,135 @@
|
||||
---
|
||||
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 9–16 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) | 9–16 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`.
|
||||
@ -0,0 +1,125 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* UI Bundle build-output analyzer.
|
||||
*
|
||||
* Reads a Vite dist/ directory and reports every JS/CSS asset against the
|
||||
* skill's size budgets, flagging what to split first.
|
||||
*
|
||||
* Usage: node analyze-bundle.mjs <path-to-dist> [--json]
|
||||
*
|
||||
* Exit code: 0 when all budgets pass, 1 when any budget is exceeded,
|
||||
* 2 on usage/IO errors — so it can gate CI.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const BUDGETS = {
|
||||
entryChunkKb: 300, // index-*.js
|
||||
anyChunkKb: 500,
|
||||
routeChunkKb: 100, // lazy page chunks
|
||||
cssTotalKb: 150,
|
||||
};
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const asJson = args.includes('--json');
|
||||
const distDir = args.find((a) => !a.startsWith('--'));
|
||||
|
||||
if (!distDir) {
|
||||
console.error('Usage: node analyze-bundle.mjs <path-to-dist> [--json]');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const assetsDir = path.join(distDir, 'assets');
|
||||
if (!fs.existsSync(assetsDir)) {
|
||||
console.error(`No assets directory at ${assetsDir} — run the app's \`npm run build\` first.`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const kb = (bytes) => Math.round((bytes / 1024) * 100) / 100;
|
||||
|
||||
const assets = fs
|
||||
.readdirSync(assetsDir)
|
||||
.filter((f) => f.endsWith('.js') || f.endsWith('.css'))
|
||||
.map((f) => {
|
||||
const size = fs.statSync(path.join(assetsDir, f)).size;
|
||||
const isJs = f.endsWith('.js');
|
||||
const isEntry = isJs && /^index-/.test(f);
|
||||
// Vite names lazy chunks after their source module (e.g. PropertySearch-<hash>.js)
|
||||
const isRoute = isJs && !isEntry && /^[A-Z]/.test(f);
|
||||
return { file: f, sizeKb: kb(size), type: isJs ? 'js' : 'css', isEntry, isRoute };
|
||||
})
|
||||
.sort((a, b) => b.sizeKb - a.sizeKb);
|
||||
|
||||
const findings = [];
|
||||
|
||||
for (const a of assets.filter((x) => x.type === 'js')) {
|
||||
if (a.isEntry && a.sizeKb > BUDGETS.entryChunkKb) {
|
||||
findings.push({
|
||||
severity: 'error',
|
||||
file: a.file,
|
||||
sizeKb: a.sizeKb,
|
||||
budgetKb: BUDGETS.entryChunkKb,
|
||||
message: 'Entry chunk over budget — apply route-level code splitting (SKILL.md §2), then vendor chunking (§3).',
|
||||
});
|
||||
} else if (a.sizeKb > BUDGETS.anyChunkKb) {
|
||||
findings.push({
|
||||
severity: 'error',
|
||||
file: a.file,
|
||||
sizeKb: a.sizeKb,
|
||||
budgetKb: BUDGETS.anyChunkKb,
|
||||
message: 'Chunk over budget — split its largest dependency into its own manualChunks entry (§3). Check for substring matches absorbing extra packages.',
|
||||
});
|
||||
} else if (a.isRoute && a.sizeKb > BUDGETS.routeChunkKb) {
|
||||
findings.push({
|
||||
severity: 'warning',
|
||||
file: a.file,
|
||||
sizeKb: a.sizeKb,
|
||||
budgetKb: BUDGETS.routeChunkKb,
|
||||
message: 'Route chunk is heavy — lazy-load this page\'s heavy dependencies or move shared code up.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const cssTotal = kb(assets.filter((a) => a.type === 'css').reduce((s, a) => s + a.sizeKb * 1024, 0));
|
||||
if (cssTotal > BUDGETS.cssTotalKb) {
|
||||
findings.push({
|
||||
severity: 'warning',
|
||||
file: '(all css)',
|
||||
sizeKb: cssTotal,
|
||||
budgetKb: BUDGETS.cssTotalKb,
|
||||
message: 'CSS total over budget — check Tailwind content globs for over-broad patterns.',
|
||||
});
|
||||
}
|
||||
|
||||
const hasVendorSplit = assets.some((a) => /^(react|router|vendor|charts|radix|icons)-/.test(a.file));
|
||||
const errors = findings.filter((f) => f.severity === 'error');
|
||||
|
||||
const result = {
|
||||
distDir,
|
||||
budgets: BUDGETS,
|
||||
vendorChunksDetected: hasVendorSplit,
|
||||
assets,
|
||||
findings,
|
||||
pass: errors.length === 0,
|
||||
};
|
||||
|
||||
if (asJson) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
console.log(`Assets in ${assetsDir} (largest first):\n`);
|
||||
for (const a of assets) {
|
||||
const flag = findings.find((f) => f.file === a.file);
|
||||
const mark = flag ? (flag.severity === 'error' ? '✗' : '⚠') : '✓';
|
||||
console.log(` ${mark} ${a.sizeKb.toString().padStart(9)} kB ${a.file}`);
|
||||
}
|
||||
console.log(`\nVendor chunks detected: ${hasVendorSplit ? 'yes' : 'no (see SKILL.md §3)'}`);
|
||||
if (findings.length) {
|
||||
console.log(`\n${findings.length} finding(s):`);
|
||||
for (const f of findings) console.log(` [${f.severity}] ${f.file} (${f.sizeKb} kB > ${f.budgetKb} kB): ${f.message}`);
|
||||
} else {
|
||||
console.log('\nAll budgets pass.');
|
||||
}
|
||||
}
|
||||
|
||||
process.exit(result.pass ? 0 : 1);
|
||||
Loading…
Reference in New Issue
Block a user