Skip to content

Commit b81a6d0

Browse files
authored
perf(react-ui): code-split bundle, speed up coverage suite (#10042)
* Curate the highlight.js build to ~29 languages (lib/core + the common set) instead of the full ~190-grammar default: -787 KB raw / -230 KB gz on the base bundle. * Code-split every route via React.lazy with a per-layout <Suspense> in App.jsx so the sidebar stays mounted on navigation. Initial entry chunk drops from 3194 KB raw / 887 KB gz to 397 KB / 122 KB (-87%). Warm chunks on sidebar hover/focus/touch via a preload registry so the click finds the chunk already in flight or cached. * Migrate Playwright coverage from istanbul (build-time counters) to native Chromium V8 coverage, with per-worker accumulation + conversion. Suite drops from 71s to 30s at 20 workers (~58%) at the non-instrumented floor. * Keep the coverage gate bundling-invariant: the coverage build inlines dynamic imports so every shipped source file lands in the denominator (otherwise untested page chunks silently drop out and inflate the percentage). Production builds stay code-split. * Add UI_TEST_WORKERS=N Makefile knob; tighten coverage tolerance to 0.8pp now that jitter sits near istanbul's ~0.5pp again. Assisted-by: Claude:claude-opus-4-7 [Claude Code] Signed-off-by: Richard Palethorpe <io@richiejp.com>
1 parent 0fd666e commit b81a6d0

15 files changed

Lines changed: 337 additions & 68 deletions

Makefile

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1313,6 +1313,13 @@ build-ui-test-server: build-mock-backend react-ui protogen-go
13131313
test-ui-e2e: build-ui-test-server
13141314
cd core/http/react-ui && npm install && npx playwright install --with-deps chromium && npx playwright test
13151315

1316+
## Optional Playwright worker count for the UI e2e targets below. Pass
1317+
## UI_TEST_WORKERS=N (e.g. `make test-ui-coverage UI_TEST_WORKERS=20`) to
1318+
## override Playwright's default (cores/2). Empty by default so Playwright
1319+
## picks its own worker count.
1320+
UI_TEST_WORKERS ?=
1321+
PLAYWRIGHT_WORKERS_FLAG = $(if $(UI_TEST_WORKERS),--workers=$(UI_TEST_WORKERS),)
1322+
13161323
## Fast Playwright e2e run used by the pre-commit hook on React UI changes.
13171324
## Force-rebuilds the (non-instrumented) dist so the suite tests the working
13181325
## tree — not a stale dist the `react-ui` skip-guard would leave — re-embeds
@@ -1322,22 +1329,24 @@ test-ui-e2e: build-ui-test-server
13221329
test-ui: build-mock-backend protogen-go
13231330
cd core/http/react-ui && bun install && bun run build
13241331
$(GOCMD) build -o tests/e2e-ui/ui-test-server ./tests/e2e-ui
1325-
cd core/http/react-ui && sh $(CURDIR)/scripts/ensure-playwright-browser.sh && bunx playwright test
1326-
1327-
## React UI code coverage from the Playwright e2e suite. Builds an
1328-
## istanbul-instrumented bundle (COVERAGE=true), re-embeds it into the
1329-
## ui-test-server (the dist is //go:embed'ed at compile time), runs the
1330-
## Playwright specs — which harvest window.__coverage__ via the coverage
1331-
## fixture — and writes an nyc report to core/http/react-ui/coverage/.
1332-
## Removes the instrumented dist afterwards so normal builds aren't served
1333-
## instrumented assets.
1332+
cd core/http/react-ui && sh $(CURDIR)/scripts/ensure-playwright-browser.sh && bunx playwright test $(PLAYWRIGHT_WORKERS_FLAG)
1333+
1334+
## React UI code coverage from the Playwright e2e suite. Builds a
1335+
## NON-instrumented bundle with source maps (COVERAGE_V8=true), re-embeds it
1336+
## into the ui-test-server (the dist is //go:embed'ed at compile time), runs the
1337+
## Playwright specs which collect native Chromium V8 coverage (PW_V8_COVERAGE=1)
1338+
## — far cheaper than istanbul's build-time counters (~40% faster end-to-end) —
1339+
## convert it to istanbul via v8-to-istanbul in the coverage fixture, and write
1340+
## an nyc report to core/http/react-ui/coverage/. Removes the dist afterwards so
1341+
## normal builds aren't served source-mapped assets. (The legacy istanbul path
1342+
## still exists: `bun run build:coverage` + unset PW_V8_COVERAGE.)
13341343
test-ui-coverage: build-mock-backend protogen-go
13351344
trap 'rm -rf "$(CURDIR)/core/http/react-ui/dist"' EXIT; \
1336-
( cd core/http/react-ui && bun install && bun run build:coverage ) && \
1345+
( cd core/http/react-ui && bun install && bun run build:coverage-v8 ) && \
13371346
$(GOCMD) build -o tests/e2e-ui/ui-test-server ./tests/e2e-ui && \
13381347
( cd core/http/react-ui && rm -rf .nyc_output coverage && \
13391348
sh $(CURDIR)/scripts/ensure-playwright-browser.sh && \
1340-
bunx playwright test && bun run coverage:report )
1349+
PW_V8_COVERAGE=1 bunx playwright test $(PLAYWRIGHT_WORKERS_FLAG) && bun run coverage:report )
13411350

13421351
## UI coverage baseline (committed) and the strict gate that compares against
13431352
## it — the React mirror of test-coverage-baseline / test-coverage-check.

core/http/react-ui/bun.lock

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
30.66
1+
38.29

core/http/react-ui/e2e/coverage-fixtures.js

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,41 @@ import { randomUUID } from 'node:crypto'
1515
import path from 'node:path'
1616

1717
const COVERAGE_DIR = path.resolve(process.cwd(), '.nyc_output')
18+
const V8_COVERAGE = process.env.PW_V8_COVERAGE === '1'
19+
20+
const withCoverage = base.extend({
21+
// Worker-scoped V8 coverage accumulator: collects every test's native
22+
// Chromium coverage and converts it to istanbul ONCE at worker teardown
23+
// (conversion is expensive; see e2e/v8-coverage.js). null when V8 mode is off.
24+
_v8acc: [
25+
async ({}, use) => {
26+
if (!V8_COVERAGE) {
27+
await use(null)
28+
return
29+
}
30+
const { createAccumulator } = await import('./v8-coverage.js')
31+
const acc = createAccumulator()
32+
await use(acc)
33+
await acc.flush()
34+
},
35+
{ scope: 'worker' },
36+
],
37+
38+
page: async ({ page, _v8acc }, use) => {
39+
// V8 coverage path: collect native Chromium coverage (cheap), hand it to the
40+
// worker accumulator on teardown. Avoids running an instrumented bundle.
41+
if (V8_COVERAGE) {
42+
const { startV8 } = await import('./v8-coverage.js')
43+
await startV8(page)
44+
await use(page)
45+
try {
46+
_v8acc.add(await page.coverage.stopJSCoverage())
47+
} catch {
48+
// page already closed — nothing to collect
49+
}
50+
return
51+
}
1852

19-
export const test = base.extend({
20-
page: async ({ page }, use) => {
2153
await use(page)
2254

2355
let coverage
@@ -37,4 +69,5 @@ export const test = base.extend({
3769
},
3870
})
3971

72+
export const test = withCoverage
4073
export { expect }
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
// V8 -> istanbul coverage harvest for the Playwright suite.
2+
//
3+
// When PW_V8_COVERAGE=1 the suite runs against a NON-instrumented build (built
4+
// with COVERAGE_V8=true, which only adds source maps). Chromium collects native
5+
// V8 coverage with near-zero runtime overhead; we convert it back to per-source
6+
// istanbul data via v8-to-istanbul (using the on-disk source maps), filter to
7+
// src/**, and write the same .nyc_output/*.json the istanbul path produced — so
8+
// `nyc report` and the strict baseline gate are unchanged.
9+
//
10+
// Conversion (v8-to-istanbul load() parses the large bundle source map) is the
11+
// expensive part, so we do NOT convert per test. Instead each worker collects
12+
// raw V8 coverage from every test, merges it with @bcoe/v8-coverage (which sums
13+
// counts and reconciles overlapping ranges correctly — applyCoverage can't be
14+
// called repeatedly, it pushes/overwrites), and converts ONCE at worker
15+
// teardown. That cuts conversions from ~152 (per test) to ~1 per worker.
16+
import v8toIstanbul from 'v8-to-istanbul'
17+
import libCoverage from 'istanbul-lib-coverage'
18+
import { mergeProcessCovs } from '@bcoe/v8-coverage'
19+
import { mkdirSync, writeFileSync, existsSync } from 'node:fs'
20+
import { randomUUID } from 'node:crypto'
21+
import path from 'node:path'
22+
23+
const COVERAGE_DIR = path.resolve(process.cwd(), '.nyc_output')
24+
const DIST_ASSETS = path.resolve(process.cwd(), 'dist', 'assets')
25+
// Absolute app source dir. Match on this (not a bare "/src/" substring) — the
26+
// repo itself lives under .../go/src/..., so a substring check would collide.
27+
const SRC_DIR = path.resolve(process.cwd(), 'src') + path.sep
28+
// Only our own bundle chunks under /assets/*.js carry app source maps.
29+
const APP_CHUNK = /\/assets\/([^/?]+\.js)(\?|$)/
30+
31+
export async function startV8(page) {
32+
// resetOnNavigation:false so hard navigations (goto) within a test accumulate.
33+
await page.coverage.startJSCoverage({ resetOnNavigation: false })
34+
}
35+
36+
// One accumulator per worker (created by the worker-scoped fixture).
37+
export function createAccumulator() {
38+
const processCovs = []
39+
40+
return {
41+
// Called on each test teardown with that test's V8 coverage entries.
42+
add(entries) {
43+
const result = entries
44+
.filter((e) => APP_CHUNK.test(e.url))
45+
// Keep only structural fields (drop the ~1MB `source` per entry — it's
46+
// re-read from disk at convert time — to bound per-worker memory).
47+
.map((e) => ({ scriptId: e.scriptId || e.url, url: e.url, functions: e.functions }))
48+
if (result.length) processCovs.push({ result })
49+
},
50+
51+
// Called once at worker teardown: merge all tests' coverage, convert, write.
52+
async flush() {
53+
if (processCovs.length === 0) return
54+
const merged = mergeProcessCovs(processCovs)
55+
const map = libCoverage.createCoverageMap({})
56+
57+
for (const script of merged.result) {
58+
const m = APP_CHUNK.exec(script.url)
59+
if (!m) continue
60+
const diskPath = path.join(DIST_ASSETS, m[1])
61+
if (!existsSync(diskPath)) continue
62+
63+
// v8-to-istanbul auto-loads source + sibling .map from disk; the served
64+
// bytes match dist, so the V8 ranges line up.
65+
const converter = v8toIstanbul(diskPath, 0)
66+
try {
67+
await converter.load()
68+
converter.applyCoverage(script.functions)
69+
const data = converter.toIstanbul()
70+
for (const [key, fileCov] of Object.entries(data)) {
71+
// v8-to-istanbul keys are already absolute; keep only app sources.
72+
if (!key.startsWith(SRC_DIR) || key.includes(`${path.sep}node_modules${path.sep}`)) continue
73+
map.merge({ [key]: fileCov })
74+
}
75+
} catch {
76+
// skip a chunk we couldn't convert
77+
} finally {
78+
converter.destroy()
79+
}
80+
}
81+
82+
const json = map.toJSON()
83+
if (Object.keys(json).length === 0) return
84+
mkdirSync(COVERAGE_DIR, { recursive: true })
85+
writeFileSync(path.join(COVERAGE_DIR, `v8-${randomUUID()}.json`), JSON.stringify(json))
86+
},
87+
}
88+
}

core/http/react-ui/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"test:e2e": "playwright test",
1313
"test:e2e:ui": "playwright test --ui",
1414
"build:coverage": "COVERAGE=true vite build",
15+
"build:coverage-v8": "COVERAGE_V8=true vite build",
1516
"coverage:report": "nyc report"
1617
},
1718
"dependencies": {
@@ -42,6 +43,7 @@
4243
"yaml": "^2.8.3"
4344
},
4445
"devDependencies": {
46+
"@bcoe/v8-coverage": "^1.0.2",
4547
"@eslint/js": "^9.27.0",
4648
"@playwright/test": "1.58.2",
4749
"@vitejs/plugin-react": "^6.0.2",
@@ -51,6 +53,7 @@
5153
"globals": "^16.1.0",
5254
"i18next-parser": "^9.4.0",
5355
"nyc": "^18.0.0",
56+
"v8-to-istanbul": "^9.3.0",
5457
"vite": "^8.0.14",
5558
"vite-plugin-istanbul": "^9.0.0"
5659
}

core/http/react-ui/src/App.jsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useState, useEffect, useRef } from 'react'
1+
import { useState, useEffect, useRef, Suspense } from 'react'
22
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
33
import { useTranslation } from 'react-i18next'
44
import Sidebar from './components/Sidebar'
@@ -122,7 +122,14 @@ export default function App() {
122122
</header>
123123
<div className="main-content-inner">
124124
<div className="page-transition" key={location.pathname}>
125-
<Outlet context={{ addToast }} />
125+
{/* Per-route Suspense catches React.lazy chunk loads (router.jsx)
126+
here, inside the App layout. Without it, suspension would bubble
127+
up to main.jsx's outer boundary and unmount the sidebar/header
128+
on every navigation. fallback={null} keeps the shell stable; the
129+
page-content area briefly blanks while the chunk arrives. */}
130+
<Suspense fallback={null}>
131+
<Outlet context={{ addToast }} />
132+
</Suspense>
126133
</div>
127134
</div>
128135
{!isChatRoute && (

core/http/react-ui/src/components/CanvasPanel.jsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { getArtifactIcon } from '../utils/artifacts'
44
import { safeHref } from '../utils/url'
55
import { copyToClipboard } from '../utils/clipboard'
66
import DOMPurify from 'dompurify'
7-
import hljs from 'highlight.js'
7+
import hljs from '../utils/hljs'
88

99
export default function CanvasPanel({ artifacts, selectedId, onSelect, onClose }) {
1010
const [showPreview, setShowPreview] = useState(true)

core/http/react-ui/src/components/Sidebar.jsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import LanguageSwitcher from './LanguageSwitcher'
66
import { useAuth } from '../context/AuthContext'
77
import { useBranding } from '../contexts/BrandingContext'
88
import { apiUrl } from '../utils/basePath'
9+
import { preloadRoute } from '../router'
910

1011
const COLLAPSED_KEY = 'localai_sidebar_collapsed'
1112
const SECTIONS_KEY = 'localai_sidebar_sections'
@@ -85,6 +86,10 @@ const sections = [
8586
function NavItem({ item, onClose, collapsed }) {
8687
const { t } = useTranslation('nav')
8788
const label = t(item.labelKey)
89+
// Warm the route's lazy chunk before the user clicks. Touch fires ~150ms
90+
// before the synthetic click on mobile; mouseenter/focus cover desktop and
91+
// keyboard. The underlying import() is memoised so multiple triggers are free.
92+
const preload = () => preloadRoute(item.path)
8893
return (
8994
<NavLink
9095
to={item.path}
@@ -93,6 +98,9 @@ function NavItem({ item, onClose, collapsed }) {
9398
`nav-item ${isActive ? 'active' : ''}`
9499
}
95100
onClick={onClose}
101+
onMouseEnter={preload}
102+
onFocus={preload}
103+
onTouchStart={preload}
96104
title={collapsed ? label : undefined}
97105
>
98106
<i className={`${item.icon} nav-icon`} />
@@ -296,6 +304,9 @@ export default function Sidebar({ isOpen, onClose }) {
296304
<button
297305
className="sidebar-user-link"
298306
onClick={() => { navigate('/app/account'); onClose?.() }}
307+
onMouseEnter={() => preloadRoute('/app/account')}
308+
onFocus={() => preloadRoute('/app/account')}
309+
onTouchStart={() => preloadRoute('/app/account')}
299310
title={t('accountSettings')}
300311
>
301312
{user.avatarUrl ? (

0 commit comments

Comments
 (0)