From b81a6d01b35c36f3b1731230a8a6ba11bca5f06f Mon Sep 17 00:00:00 2001 From: Richard Palethorpe Date: Thu, 28 May 2026 12:43:15 +0100 Subject: [PATCH] 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 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 --- Makefile | 29 +++-- core/http/react-ui/bun.lock | 10 ++ core/http/react-ui/coverage-baseline.txt | 2 +- core/http/react-ui/e2e/coverage-fixtures.js | 37 +++++- core/http/react-ui/e2e/v8-coverage.js | 88 ++++++++++++++ core/http/react-ui/package.json | 3 + core/http/react-ui/src/App.jsx | 11 +- .../react-ui/src/components/CanvasPanel.jsx | 2 +- core/http/react-ui/src/components/Sidebar.jsx | 11 ++ core/http/react-ui/src/router.jsx | 113 +++++++++++------- core/http/react-ui/src/utils/artifacts.js | 11 +- core/http/react-ui/src/utils/hljs.js | 53 ++++++++ core/http/react-ui/src/utils/markdown.js | 2 +- core/http/react-ui/vite.config.js | 20 ++++ scripts/ui-coverage-check.sh | 11 +- 15 files changed, 336 insertions(+), 67 deletions(-) create mode 100644 core/http/react-ui/e2e/v8-coverage.js create mode 100644 core/http/react-ui/src/utils/hljs.js diff --git a/Makefile b/Makefile index 831db8a06..5a50405ae 100644 --- a/Makefile +++ b/Makefile @@ -1313,6 +1313,13 @@ build-ui-test-server: build-mock-backend react-ui protogen-go test-ui-e2e: build-ui-test-server cd core/http/react-ui && npm install && npx playwright install --with-deps chromium && npx playwright test +## Optional Playwright worker count for the UI e2e targets below. Pass +## UI_TEST_WORKERS=N (e.g. `make test-ui-coverage UI_TEST_WORKERS=20`) to +## override Playwright's default (cores/2). Empty by default so Playwright +## picks its own worker count. +UI_TEST_WORKERS ?= +PLAYWRIGHT_WORKERS_FLAG = $(if $(UI_TEST_WORKERS),--workers=$(UI_TEST_WORKERS),) + ## Fast Playwright e2e run used by the pre-commit hook on React UI changes. ## Force-rebuilds the (non-instrumented) dist so the suite tests the working ## 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 test-ui: build-mock-backend protogen-go cd core/http/react-ui && bun install && bun run build $(GOCMD) build -o tests/e2e-ui/ui-test-server ./tests/e2e-ui - cd core/http/react-ui && sh $(CURDIR)/scripts/ensure-playwright-browser.sh && bunx playwright test + cd core/http/react-ui && sh $(CURDIR)/scripts/ensure-playwright-browser.sh && bunx playwright test $(PLAYWRIGHT_WORKERS_FLAG) -## React UI code coverage from the Playwright e2e suite. Builds an -## istanbul-instrumented bundle (COVERAGE=true), re-embeds it into the -## ui-test-server (the dist is //go:embed'ed at compile time), runs the -## Playwright specs — which harvest window.__coverage__ via the coverage -## fixture — and writes an nyc report to core/http/react-ui/coverage/. -## Removes the instrumented dist afterwards so normal builds aren't served -## instrumented assets. +## React UI code coverage from the Playwright e2e suite. Builds a +## NON-instrumented bundle with source maps (COVERAGE_V8=true), re-embeds it +## into the ui-test-server (the dist is //go:embed'ed at compile time), runs the +## Playwright specs which collect native Chromium V8 coverage (PW_V8_COVERAGE=1) +## — far cheaper than istanbul's build-time counters (~40% faster end-to-end) — +## convert it to istanbul via v8-to-istanbul in the coverage fixture, and write +## an nyc report to core/http/react-ui/coverage/. Removes the dist afterwards so +## normal builds aren't served source-mapped assets. (The legacy istanbul path +## still exists: `bun run build:coverage` + unset PW_V8_COVERAGE.) test-ui-coverage: build-mock-backend protogen-go trap 'rm -rf "$(CURDIR)/core/http/react-ui/dist"' EXIT; \ - ( cd core/http/react-ui && bun install && bun run build:coverage ) && \ + ( cd core/http/react-ui && bun install && bun run build:coverage-v8 ) && \ $(GOCMD) build -o tests/e2e-ui/ui-test-server ./tests/e2e-ui && \ ( cd core/http/react-ui && rm -rf .nyc_output coverage && \ sh $(CURDIR)/scripts/ensure-playwright-browser.sh && \ - bunx playwright test && bun run coverage:report ) + PW_V8_COVERAGE=1 bunx playwright test $(PLAYWRIGHT_WORKERS_FLAG) && bun run coverage:report ) ## UI coverage baseline (committed) and the strict gate that compares against ## it — the React mirror of test-coverage-baseline / test-coverage-check. diff --git a/core/http/react-ui/bun.lock b/core/http/react-ui/bun.lock index d788ad4c5..dd2875fb8 100644 --- a/core/http/react-ui/bun.lock +++ b/core/http/react-ui/bun.lock @@ -32,6 +32,7 @@ "yaml": "^2.8.3", }, "devDependencies": { + "@bcoe/v8-coverage": "^1.0.2", "@eslint/js": "^9.27.0", "@playwright/test": "1.58.2", "@vitejs/plugin-react": "^6.0.2", @@ -41,6 +42,7 @@ "globals": "^16.1.0", "i18next-parser": "^9.4.0", "nyc": "^18.0.0", + "v8-to-istanbul": "^9.3.0", "vite": "^8.0.14", "vite-plugin-istanbul": "^9.0.0", }, @@ -81,6 +83,8 @@ "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + "@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="], + "@codemirror/autocomplete": ["@codemirror/autocomplete@6.20.1", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A=="], "@codemirror/commands": ["@codemirror/commands@6.10.3", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.6.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q=="], @@ -267,6 +271,8 @@ "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + "@types/istanbul-lib-coverage": ["@types/istanbul-lib-coverage@2.0.6", "", {}, "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w=="], + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], "@types/minimatch": ["@types/minimatch@3.0.5", "", {}, "sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ=="], @@ -983,6 +989,8 @@ "uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="], + "v8-to-istanbul": ["v8-to-istanbul@9.3.0", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.12", "@types/istanbul-lib-coverage": "^2.0.1", "convert-source-map": "^2.0.0" } }, "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA=="], + "value-or-function": ["value-or-function@4.0.0", "", {}, "sha512-aeVK81SIuT6aMJfNo9Vte8Dw0/FZINGBV8BfCraGtqVxIeLAEhJyoWs8SmvRVmXfGss2PmmOwZCuBPbZR+IYWg=="], "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], @@ -1121,6 +1129,8 @@ "test-exclude/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + "v8-to-istanbul/convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + "vinyl-sourcemap/convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], "vite-plugin-istanbul/espree": ["espree@11.2.0", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="], diff --git a/core/http/react-ui/coverage-baseline.txt b/core/http/react-ui/coverage-baseline.txt index 6cf2ff28c..4f8b89534 100644 --- a/core/http/react-ui/coverage-baseline.txt +++ b/core/http/react-ui/coverage-baseline.txt @@ -1 +1 @@ -30.66 \ No newline at end of file +38.29 \ No newline at end of file diff --git a/core/http/react-ui/e2e/coverage-fixtures.js b/core/http/react-ui/e2e/coverage-fixtures.js index b927a6f97..1f03cace9 100644 --- a/core/http/react-ui/e2e/coverage-fixtures.js +++ b/core/http/react-ui/e2e/coverage-fixtures.js @@ -15,9 +15,41 @@ import { randomUUID } from 'node:crypto' import path from 'node:path' const COVERAGE_DIR = path.resolve(process.cwd(), '.nyc_output') +const V8_COVERAGE = process.env.PW_V8_COVERAGE === '1' + +const withCoverage = base.extend({ + // Worker-scoped V8 coverage accumulator: collects every test's native + // Chromium coverage and converts it to istanbul ONCE at worker teardown + // (conversion is expensive; see e2e/v8-coverage.js). null when V8 mode is off. + _v8acc: [ + async ({}, use) => { + if (!V8_COVERAGE) { + await use(null) + return + } + const { createAccumulator } = await import('./v8-coverage.js') + const acc = createAccumulator() + await use(acc) + await acc.flush() + }, + { scope: 'worker' }, + ], + + page: async ({ page, _v8acc }, use) => { + // V8 coverage path: collect native Chromium coverage (cheap), hand it to the + // worker accumulator on teardown. Avoids running an instrumented bundle. + if (V8_COVERAGE) { + const { startV8 } = await import('./v8-coverage.js') + await startV8(page) + await use(page) + try { + _v8acc.add(await page.coverage.stopJSCoverage()) + } catch { + // page already closed — nothing to collect + } + return + } -export const test = base.extend({ - page: async ({ page }, use) => { await use(page) let coverage @@ -37,4 +69,5 @@ export const test = base.extend({ }, }) +export const test = withCoverage export { expect } diff --git a/core/http/react-ui/e2e/v8-coverage.js b/core/http/react-ui/e2e/v8-coverage.js new file mode 100644 index 000000000..ed14d481a --- /dev/null +++ b/core/http/react-ui/e2e/v8-coverage.js @@ -0,0 +1,88 @@ +// V8 -> istanbul coverage harvest for the Playwright suite. +// +// When PW_V8_COVERAGE=1 the suite runs against a NON-instrumented build (built +// with COVERAGE_V8=true, which only adds source maps). Chromium collects native +// V8 coverage with near-zero runtime overhead; we convert it back to per-source +// istanbul data via v8-to-istanbul (using the on-disk source maps), filter to +// src/**, and write the same .nyc_output/*.json the istanbul path produced — so +// `nyc report` and the strict baseline gate are unchanged. +// +// Conversion (v8-to-istanbul load() parses the large bundle source map) is the +// expensive part, so we do NOT convert per test. Instead each worker collects +// raw V8 coverage from every test, merges it with @bcoe/v8-coverage (which sums +// counts and reconciles overlapping ranges correctly — applyCoverage can't be +// called repeatedly, it pushes/overwrites), and converts ONCE at worker +// teardown. That cuts conversions from ~152 (per test) to ~1 per worker. +import v8toIstanbul from 'v8-to-istanbul' +import libCoverage from 'istanbul-lib-coverage' +import { mergeProcessCovs } from '@bcoe/v8-coverage' +import { mkdirSync, writeFileSync, existsSync } from 'node:fs' +import { randomUUID } from 'node:crypto' +import path from 'node:path' + +const COVERAGE_DIR = path.resolve(process.cwd(), '.nyc_output') +const DIST_ASSETS = path.resolve(process.cwd(), 'dist', 'assets') +// Absolute app source dir. Match on this (not a bare "/src/" substring) — the +// repo itself lives under .../go/src/..., so a substring check would collide. +const SRC_DIR = path.resolve(process.cwd(), 'src') + path.sep +// Only our own bundle chunks under /assets/*.js carry app source maps. +const APP_CHUNK = /\/assets\/([^/?]+\.js)(\?|$)/ + +export async function startV8(page) { + // resetOnNavigation:false so hard navigations (goto) within a test accumulate. + await page.coverage.startJSCoverage({ resetOnNavigation: false }) +} + +// One accumulator per worker (created by the worker-scoped fixture). +export function createAccumulator() { + const processCovs = [] + + return { + // Called on each test teardown with that test's V8 coverage entries. + add(entries) { + const result = entries + .filter((e) => APP_CHUNK.test(e.url)) + // Keep only structural fields (drop the ~1MB `source` per entry — it's + // re-read from disk at convert time — to bound per-worker memory). + .map((e) => ({ scriptId: e.scriptId || e.url, url: e.url, functions: e.functions })) + if (result.length) processCovs.push({ result }) + }, + + // Called once at worker teardown: merge all tests' coverage, convert, write. + async flush() { + if (processCovs.length === 0) return + const merged = mergeProcessCovs(processCovs) + const map = libCoverage.createCoverageMap({}) + + for (const script of merged.result) { + const m = APP_CHUNK.exec(script.url) + if (!m) continue + const diskPath = path.join(DIST_ASSETS, m[1]) + if (!existsSync(diskPath)) continue + + // v8-to-istanbul auto-loads source + sibling .map from disk; the served + // bytes match dist, so the V8 ranges line up. + const converter = v8toIstanbul(diskPath, 0) + try { + await converter.load() + converter.applyCoverage(script.functions) + const data = converter.toIstanbul() + for (const [key, fileCov] of Object.entries(data)) { + // v8-to-istanbul keys are already absolute; keep only app sources. + if (!key.startsWith(SRC_DIR) || key.includes(`${path.sep}node_modules${path.sep}`)) continue + map.merge({ [key]: fileCov }) + } + } catch { + // skip a chunk we couldn't convert + } finally { + converter.destroy() + } + } + + const json = map.toJSON() + if (Object.keys(json).length === 0) return + mkdirSync(COVERAGE_DIR, { recursive: true }) + writeFileSync(path.join(COVERAGE_DIR, `v8-${randomUUID()}.json`), JSON.stringify(json)) + }, + } +} diff --git a/core/http/react-ui/package.json b/core/http/react-ui/package.json index 6af515c4a..b0806e498 100644 --- a/core/http/react-ui/package.json +++ b/core/http/react-ui/package.json @@ -12,6 +12,7 @@ "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", "build:coverage": "COVERAGE=true vite build", + "build:coverage-v8": "COVERAGE_V8=true vite build", "coverage:report": "nyc report" }, "dependencies": { @@ -42,6 +43,7 @@ "yaml": "^2.8.3" }, "devDependencies": { + "@bcoe/v8-coverage": "^1.0.2", "@eslint/js": "^9.27.0", "@playwright/test": "1.58.2", "@vitejs/plugin-react": "^6.0.2", @@ -51,6 +53,7 @@ "globals": "^16.1.0", "i18next-parser": "^9.4.0", "nyc": "^18.0.0", + "v8-to-istanbul": "^9.3.0", "vite": "^8.0.14", "vite-plugin-istanbul": "^9.0.0" } diff --git a/core/http/react-ui/src/App.jsx b/core/http/react-ui/src/App.jsx index 73a59f81e..d9f170a39 100644 --- a/core/http/react-ui/src/App.jsx +++ b/core/http/react-ui/src/App.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef } from 'react' +import { useState, useEffect, useRef, Suspense } from 'react' import { Outlet, useLocation, useNavigate } from 'react-router-dom' import { useTranslation } from 'react-i18next' import Sidebar from './components/Sidebar' @@ -122,7 +122,14 @@ export default function App() {
- + {/* Per-route Suspense catches React.lazy chunk loads (router.jsx) + here, inside the App layout. Without it, suspension would bubble + up to main.jsx's outer boundary and unmount the sidebar/header + on every navigation. fallback={null} keeps the shell stable; the + page-content area briefly blanks while the chunk arrives. */} + + +
{!isChatRoute && ( diff --git a/core/http/react-ui/src/components/CanvasPanel.jsx b/core/http/react-ui/src/components/CanvasPanel.jsx index 9d57cff06..4ac4c3064 100644 --- a/core/http/react-ui/src/components/CanvasPanel.jsx +++ b/core/http/react-ui/src/components/CanvasPanel.jsx @@ -4,7 +4,7 @@ import { getArtifactIcon } from '../utils/artifacts' import { safeHref } from '../utils/url' import { copyToClipboard } from '../utils/clipboard' import DOMPurify from 'dompurify' -import hljs from 'highlight.js' +import hljs from '../utils/hljs' export default function CanvasPanel({ artifacts, selectedId, onSelect, onClose }) { const [showPreview, setShowPreview] = useState(true) diff --git a/core/http/react-ui/src/components/Sidebar.jsx b/core/http/react-ui/src/components/Sidebar.jsx index 148a33bfb..c75ed377d 100644 --- a/core/http/react-ui/src/components/Sidebar.jsx +++ b/core/http/react-ui/src/components/Sidebar.jsx @@ -6,6 +6,7 @@ import LanguageSwitcher from './LanguageSwitcher' import { useAuth } from '../context/AuthContext' import { useBranding } from '../contexts/BrandingContext' import { apiUrl } from '../utils/basePath' +import { preloadRoute } from '../router' const COLLAPSED_KEY = 'localai_sidebar_collapsed' const SECTIONS_KEY = 'localai_sidebar_sections' @@ -85,6 +86,10 @@ const sections = [ function NavItem({ item, onClose, collapsed }) { const { t } = useTranslation('nav') const label = t(item.labelKey) + // Warm the route's lazy chunk before the user clicks. Touch fires ~150ms + // before the synthetic click on mobile; mouseenter/focus cover desktop and + // keyboard. The underlying import() is memoised so multiple triggers are free. + const preload = () => preloadRoute(item.path) return ( @@ -296,6 +304,9 @@ export default function Sidebar({ isOpen, onClose }) {