|
| 1 | +# TBB Performance Audit |
| 2 | + |
| 3 | +**Date:** March 28, 2026 |
| 4 | +**Tool:** Chrome DevTools MCP (Lighthouse + Performance Trace) |
| 5 | +**Device:** Desktop |
| 6 | + |
| 7 | +## Audit 1: Empty State (No Data Loaded) |
| 8 | + |
| 9 | +**URL:** `http://localhost:5174/tbb/?period=2026-02` |
| 10 | + |
| 11 | +### Lighthouse Scores |
| 12 | + |
| 13 | +| Category | Score | |
| 14 | +|----------|-------| |
| 15 | +| Accessibility | ✅ 100 | |
| 16 | +| Best Practices | ⚠️ 96 | |
| 17 | +| SEO | ✅ 100 | |
| 18 | + |
| 19 | +### Failed Audit: `errors-in-console` |
| 20 | + |
| 21 | +``` |
| 22 | +ReactDOMClient.createRoot() on a container that has already been passed to createRoot() before. |
| 23 | +``` |
| 24 | + |
| 25 | +React strict mode double-render issue in `main.tsx`. Likely calling `createRoot()` twice due to HMR or Vite's dev server re-executing the module. Non-critical in production but drops Best Practices to 96. |
| 26 | + |
| 27 | +### Core Web Vitals (Lab) |
| 28 | + |
| 29 | +| Metric | Value | Verdict | |
| 30 | +|--------|-------|---------| |
| 31 | +| LCP | 491ms | 🟢 Good | |
| 32 | +| CLS | 0.00 | 🟢 Perfect | |
| 33 | +| TTFB | 4ms | 🟢 Excellent (localhost) | |
| 34 | + |
| 35 | +### LCP Breakdown |
| 36 | + |
| 37 | +| Phase | Duration | % of LCP | |
| 38 | +|-------|----------|----------| |
| 39 | +| TTFB | 4ms | 0.8% | |
| 40 | +| Render Delay | 487ms | 99.2% | |
| 41 | + |
| 42 | +The LCP element is text (`<p>` tag), not an image. 99% of the LCP time is render delay caused by the JS/CSS waterfall blocking first paint. |
| 43 | + |
| 44 | +### Critical Rendering Path (6 levels deep) |
| 45 | + |
| 46 | +``` |
| 47 | +index.html (11ms) |
| 48 | + └─ main.tsx (13ms) |
| 49 | + └─ App.tsx (21ms) |
| 50 | + └─ ReportPageLayout.tsx (42ms) |
| 51 | + └─ TimeSeriesChart.tsx (96ms) |
| 52 | + └─ highcharts.js (160ms) ← longest chain endpoint |
| 53 | +``` |
| 54 | + |
| 55 | +**Max critical path latency: 160ms** on localhost. |
| 56 | + |
| 57 | +## Audit 2: Loaded State (Data with Charts) |
| 58 | + |
| 59 | +**URL:** `http://localhost:5174/tbb/?groupBy=sku&period=2026-02` |
| 60 | +**Data:** 30,541 rows across 22 SKU groups (Feb 1 to Mar 28, 2026) |
| 61 | + |
| 62 | +### Lighthouse Scores |
| 63 | + |
| 64 | +| Category | Score | Delta vs Empty | |
| 65 | +|----------|-------|----------------| |
| 66 | +| Accessibility | ⚠️ 98 | ↓2 | |
| 67 | +| Best Practices | ⚠️ 96 | — | |
| 68 | +| SEO | ✅ 100 | — | |
| 69 | + |
| 70 | +### Failed Audits |
| 71 | + |
| 72 | +1. **`errors-in-console`** (same as empty state) |
| 73 | +2. **`heading-order`** (NEW): `<h3>` used for hero cards ("Gross amount", "Net amount", etc.) directly after `<h1>` "Usage Report", skipping `<h2>`. Accessibility issue: heading levels should descend sequentially. |
| 74 | + |
| 75 | +### Core Web Vitals (Lab) |
| 76 | + |
| 77 | +| Metric | Value | Delta vs Empty | Verdict | |
| 78 | +|--------|-------|----------------|---------| |
| 79 | +| LCP | 660ms | ↑169ms (+34%) | 🟡 Slower | |
| 80 | +| CLS | 0.01 | ↑0.01 | 🟢 Still good | |
| 81 | +| TTFB | 6ms | ↑2ms | 🟢 Fine | |
| 82 | +| FCP | 620ms | N/A (new) | 🟡 Moderate | |
| 83 | + |
| 84 | +### LCP Breakdown |
| 85 | + |
| 86 | +| Phase | Duration | % of LCP | |
| 87 | +|-------|----------|----------| |
| 88 | +| TTFB | 6ms | 0.9% | |
| 89 | +| Render Delay | 654ms | 99.1% | |
| 90 | + |
| 91 | +LCP element: `<span>` in ActionList sidebar (Primer React component). Still text, not an image. The 654ms render delay is the cost of parsing CSVs, computing aggregations, and rendering 4 Highcharts instances. |
| 92 | + |
| 93 | +### 🚨 Critical: Forced Reflows from Highcharts (256ms total) |
| 94 | + |
| 95 | +Highcharts is causing **256ms of forced synchronous layout** during chart rendering. The biggest offenders: |
| 96 | + |
| 97 | +| Function | Time | Source | |
| 98 | +|----------|------|--------| |
| 99 | +| `getStyle()` | 151ms | `highcharts.js:1755` | |
| 100 | +| `t()` (layout calc) | 60ms | `highcharts.js:292` | |
| 101 | +| `offset()` | 50ms | `highcharts.js:351` | |
| 102 | +| `getContainer()` | 36ms | `highcharts.js:6432` | |
| 103 | +| `getBBox()` | 34ms | `highcharts.js:1706` | |
| 104 | +| `f()` | 25ms | `highcharts.js:2469` | |
| 105 | +| `updateTransform()` | 17ms | `highcharts.js:2941` | |
| 106 | +| `htmlGetBBox()` | 10ms | `highcharts.js:2898` | |
| 107 | + |
| 108 | +This is Highcharts measuring SVG element dimensions (getBBox, getStyle, offset) which forces the browser to do synchronous layout. Each chart instance triggers this. With 4 charts on the page, it compounds. |
| 109 | + |
| 110 | +### DOM & Memory Profile |
| 111 | + |
| 112 | +| Metric | Value | |
| 113 | +|--------|-------| |
| 114 | +| Total DOM nodes | 992 (after scroll) | |
| 115 | +| SVG elements | 559 (56% of DOM!) | |
| 116 | +| Highcharts instances | 4 | |
| 117 | +| SVG paths | 318 | |
| 118 | +| SVG rects | 39 | |
| 119 | +| Stylesheets loaded | 117 | |
| 120 | +| JS heap memory | 92 MB | |
| 121 | +| Page height | 2,148px (2.3 viewports) | |
| 122 | + |
| 123 | +**56% of the DOM is SVG elements from Highcharts.** This is the primary contributor to layout/style recalculation costs. |
| 124 | + |
| 125 | +### Style Recalculation |
| 126 | + |
| 127 | +One large style recalculation event: **60ms affecting 309 elements.** Triggered during chart rendering. |
| 128 | + |
| 129 | +### Critical Path Changed: Google Fonts is the Bottleneck |
| 130 | + |
| 131 | +``` |
| 132 | +index.html (240ms) |
| 133 | + └─ fonts.googleapis.com/css2 (237ms) |
| 134 | + └─ fonts.gstatic.com/.../inter.woff2 (1,389ms) ← NEW longest chain! |
| 135 | +``` |
| 136 | + |
| 137 | +**Max critical path latency: 1,389ms** (vs 160ms in empty state). The font file download became the longest chain because the loaded state takes longer, giving more time for the font waterfall to become visible in the trace. |
| 138 | + |
| 139 | +### Layout Shifts |
| 140 | + |
| 141 | +CLS score: 0.01 (within "good" threshold of ≤0.1). One layout shift cluster at 3,073ms, no identified root cause. Likely triggered by chart rendering completing after initial paint. |
| 142 | + |
| 143 | +### Missing Character Set Header |
| 144 | + |
| 145 | +Vite dev server doesn't set `charset=utf-8` in the `Content-Type` HTTP header. The `<meta charset="UTF-8">` tag exists in `index.html` (line 4), so this is a dev-only issue. Non-issue in production if hosting sets the header. |
| 146 | + |
| 147 | +## Shared Findings (Both States) |
| 148 | + |
| 149 | +### CSS Request Explosion |
| 150 | + |
| 151 | +**117 stylesheets** loaded, including **103+ individual CSS files** from `@primer/react/dist/`. Primer React uses granular per-component CSS files, all loaded eagerly. |
| 152 | + |
| 153 | +Fine in dev (Vite serves individually with HMR), but worth verifying the production build bundles them properly. |
| 154 | + |
| 155 | +### Heaviest Dependencies (Dev Bundles) |
| 156 | + |
| 157 | +| File | Size | |
| 158 | +|------|------| |
| 159 | +| `client-rRt5c1ua.js` (React DOM internals) | 802 KB | |
| 160 | +| `@primer_react.js` | 703 KB | |
| 161 | +| `@primer_octicons-react.js` | 496 KB | |
| 162 | +| `highcharts.js` | 365 KB | |
| 163 | +| `SkeletonText-DzI7JYx5.js` | 305 KB | |
| 164 | +| `@primer_react_experimental.js` | 117 KB | |
| 165 | +| `@tanstack/react-table.js` | 116 KB | |
| 166 | +| `fflate.js` | 61 KB | |
| 167 | +| **Total** | **~3+ MB** | |
| 168 | + |
| 169 | +### Third Parties |
| 170 | + |
| 171 | +Google Fonts (`Inter`): 10 KB CSS transfer, render-blocking. The `.woff2` font file download can take up to 1,389ms and dominates the critical path. |
| 172 | + |
| 173 | +### Document Latency |
| 174 | + |
| 175 | +Server responded quickly, no redirects. **Compression was not applied** (Vite dev default). |
| 176 | + |
| 177 | +## Prioritized Action Plan |
| 178 | + |
| 179 | +### P0: Fix Heading Order (a11y) |
| 180 | + |
| 181 | +Hero cards use `<h3>` directly after `<h1>`, skipping `<h2>`. Either change to `<h2>` or restructure the heading hierarchy. Restores Accessibility to 100. |
| 182 | + |
| 183 | +### P1: Fix the `createRoot()` Bug |
| 184 | + |
| 185 | +Guard against double-invocation in `main.tsx`. Cleans up the console error, gets Best Practices to 100. |
| 186 | + |
| 187 | +### P2: Reduce Highcharts Forced Reflows (256ms) |
| 188 | + |
| 189 | +This is the biggest runtime performance issue. Options: |
| 190 | +- Render charts in a `requestAnimationFrame` or `requestIdleCallback` to avoid blocking the main thread |
| 191 | +- Use `chart.reflow()` sparingly and batch DOM reads/writes |
| 192 | +- Consider setting explicit chart dimensions (`width`/`height` in options) to avoid Highcharts measuring container size |
| 193 | +- Lazy render below-the-fold charts (the Sankey and second time series chart) with IntersectionObserver |
| 194 | + |
| 195 | +### P3: Lazy Load Chart Dependencies |
| 196 | + |
| 197 | +`highcharts.js` (365 KB) + `highcharts/modules/sankey` (23 KB) sit at the end of the critical rendering path. Charts are only needed when data is loaded. `LazyChart.tsx` exists but chart components still eagerly import Highcharts. |
| 198 | + |
| 199 | +### P4: Self-Host Google Fonts |
| 200 | + |
| 201 | +Google Fonts is the #1 critical path bottleneck at **1,389ms**. The waterfall is: HTML → Google CSS → Google WOFF2 (3 hops). Self-hosting the Inter font eliminates 2 network hops and removes the render-blocking external CSS. |
| 202 | + |
| 203 | +### P5: Audit Primer React Imports |
| 204 | + |
| 205 | +703 KB for `@primer_react.js` + 496 KB for octicons. 117 stylesheets is excessive. Check barrel imports (`import { Button } from '@primer/react'`) vs. deep imports. Verify tree-shaking actually works in production. |
| 206 | + |
| 207 | +### P6: Lazy Load File Processing Libs |
| 208 | + |
| 209 | +`fflate` (61 KB), `lz-string` (14 KB), `papaparse` (25 KB) are only needed when a user drops a file or shares a URL. Dynamic `import()` on demand. |
| 210 | + |
| 211 | +### P7: Virtualize SVG Elements |
| 212 | + |
| 213 | +56% of the DOM (559 elements) is SVG from Highcharts. For charts with many data points, consider: |
| 214 | +- Enabling Highcharts boost module for large datasets |
| 215 | +- Using `turboThreshold` to limit rendered points |
| 216 | +- Lazy rendering below-the-fold charts with IntersectionObserver |
| 217 | + |
| 218 | +### P8: Production Build Audit |
| 219 | + |
| 220 | +Run `npx vite build --report` to analyze actual production bundle sizes. Dev-mode numbers are inflated. Key questions: does tree-shaking eliminate unused Primer components? Are the 117 CSS files bundled? |
| 221 | + |
| 222 | +### P9: Enable Compression |
| 223 | + |
| 224 | +Document response had no compression. For production, ensure gzip/brotli is enabled on hosting (automatic on Vercel/GitHub Pages). |
0 commit comments