diff --git a/bench/README.md b/bench/README.md index 3f1c3d31..c8d93505 100644 --- a/bench/README.md +++ b/bench/README.md @@ -2,11 +2,15 @@ 1. **Library bundle size** — raw + gzipped bytes from `yarn build`'s `dist/` output. Catches accidental dependency bloat in shippable code. 2. **Lighthouse CI** — runs the demo (`yarn build:demo`, served by `vite preview`) against a fixed list of UniProt accessions. Captures LCP, TBT, CLS, Speed Index, and the overall Performance score. -3. **Custom milestones** — `bench/instrument.js` observes the host's DOM to mark `script-start`, `data-loaded` (loader removed), `first-render` (manager inserted), and `tracks-settled` (no subtree mutations for 250 ms — same quiescence pattern Playwright's `networkidle` uses). Lighthouse's user-timings audit captures these automatically, so they appear next to the headline metrics in `summary.md`. +3. **Custom milestones** — `` emits three `performance.mark()` calls at lifecycle transitions (`script-start` in `connectedCallback`, `data-loaded` after fetch resolves, `first-render` after Lit commits the manager to the DOM) plus three `performance.measure()` calls between them. Lighthouse's user-timings audit captures these automatically, so they appear next to the headline metrics in `summary.md`. -`fetch-and-parse` (script-start → data-loaded) and `render` (data-loaded → tracks-settled) are the per-stage breakdowns; `total` is the end-to-end. The `render` measure includes a constant ~250 ms quiescence gap, which cancels out in before/after comparisons. +`fetch-and-parse` (script-start → data-loaded), `render` (data-loaded → first-render), and `total` (script-start → first-render) are the durations surfaced in the report. -The custom layer is purely external: it only loads when the URL has `?bench=1`, observes the rendered DOM, and adds **zero changes to `src/`**. If you need finer-grained timings (e.g., per-track or per-adapter cost) later, add `performance.mark()` calls inside `src/` — but the milestones above usually suffice for spotting regressions in a refactor. +## Stability contract + +The four mark/measure names — `protvista:script-start`, `protvista:data-loaded`, `protvista:first-render`, plus the three measures derived from them — are part of the component's public observable surface. **Renaming them, moving them to a different lifecycle point, or removing them is a breaking change for performance comparison.** A refactor that changes the conceptual meaning of any mark must update the corresponding baseline. + +The marks fire unconditionally (every demo run, every consumer page) — they're cheap (~150 bytes shipped, no work when nobody is observing) and useful for any consumer that wants to profile. ## Run @@ -61,7 +65,7 @@ yarn bench ## Comparing -Eyeballing two `summary.md` tables is enough most of the time. For a stricter check, LHCI's own diff works against the raw reports — see `lhci compare` docs. +Eyeballing two `summary.md` tables — current run vs. a committed baseline under `bench/baselines/` — is enough most of the time. For raw numbers, `jq` over `bench/results/lighthouse/manifest.json` pulls per-run metrics out of the latest run; `lhci open` will pop the current run's HTML reports in a browser if you want to see Lighthouse's full breakdown for one scenario. Treat any single-metric delta under ~5% as noise unless it's consistent across all scenarios. @@ -75,8 +79,9 @@ Scenarios are defined in `bench/lighthouserc.cjs` under `ci.collect.url`. Each q | ------------------ | ----------------------------------------------------- | | `lighthouserc.cjs` | LHCI config: scenarios, run count, throttling preset | | `bundle-size.mjs` | Walks `dist/`, writes raw + gzip sizes per file | -| `instrument.js` | Browser-side marks; loaded only on `?bench=1` | | `summarize.mjs` | Reads results, writes `summary.md` | | `run.mjs` | One-shot driver (`yarn bench`) | | `baselines/` | Committed snapshots — reference points for comparison | | `results/` | Gitignored — output of the latest run | + +The custom marks themselves live in `src/protvista-uniprot.ts`, not in this directory. diff --git a/bench/baselines/bundle-size-14632a3.json b/bench/baselines/bundle-size-14632a3.json new file mode 100644 index 00000000..086e0d28 --- /dev/null +++ b/bench/baselines/bundle-size-14632a3.json @@ -0,0 +1,16 @@ +{ + "commit": "14632a30376b321fe98b2f33160a094e7dc55c08", + "shortSha": "14632a3", + "capturedAt": "2026-04-29T13:45:49.709Z", + "files": [ + { + "file": "protvista-uniprot.mjs", + "raw": 4655449, + "gzip": 1162894 + } + ], + "total": { + "raw": 4655449, + "gzip": 1162894 + } +} \ No newline at end of file diff --git a/bench/baselines/bundle-size-4c80b07.json b/bench/baselines/bundle-size-4c80b07.json deleted file mode 100644 index 44b76108..00000000 --- a/bench/baselines/bundle-size-4c80b07.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "commit": "4c80b07038e4bb04fabda8ae9ab8608d11c77414", - "shortSha": "4c80b07", - "capturedAt": "2026-04-28T18:11:29.285Z", - "files": [ - { - "file": "protvista-uniprot.mjs", - "raw": 4654875, - "gzip": 1162704 - } - ], - "total": { - "raw": 4654875, - "gzip": 1162704 - } -} \ No newline at end of file diff --git a/bench/baselines/summary-14632a3.md b/bench/baselines/summary-14632a3.md new file mode 100644 index 00000000..4cd6da9b --- /dev/null +++ b/bench/baselines/summary-14632a3.md @@ -0,0 +1,28 @@ +# Bench results + +Captured: 2026-04-29T13:33:27.855Z +Commit: `14632a3` + +Numeric cells show `median (min–max)`. + +## Lighthouse (5 runs) + +| Scenario | Perf | LCP | TBT | CLS | Speed Index | +|---|---|---|---|---|---| +| `accession=P05067` | 69 (38–70) | 5.5 s (5.5–5.8) | 22 ms (18–910) | 0.00 (0.00–0.00) | 2.1 s (2.0–2.9) | +| `accession=P38398` | 52 (45–53) | 46.4 s (46.2–46.7) | 266 ms (260–403) | 0.00 (0.00–0.00) | 4.2 s (3.7–4.6) | +| `accession=A0A2K5ULD0` | 80 (78–82) | 2.4 s (2.2–2.6) | 35 ms (16–42) | 0.00 (0.00–0.00) | 2.2 s (2.0–2.2) | + +### Custom milestones (5 runs) + +| Scenario | fetch-and-parse | render | total | +|---|---|---|---| +| `accession=P05067` | 1.7 s (1.6–2.3) | 7 ms (7–8) | 1.7 s (1.6–2.3) | +| `accession=P38398` | 5.2 s (4.3–5.8) | 7 ms (6–9) | 5.2 s (4.3–5.8) | +| `accession=A0A2K5ULD0` | 1.8 s (1.4–1.9) | 11 ms (9–12) | 1.8 s (1.5–1.9) | + +## Bundle size (library, `dist/`) + +| Total raw | Total gzip | Files | +|---|---|---| +| 4546.3 KB | 1135.6 KB | 1 | diff --git a/bench/baselines/summary-4c80b07.md b/bench/baselines/summary-4c80b07.md deleted file mode 100644 index ea03ffaa..00000000 --- a/bench/baselines/summary-4c80b07.md +++ /dev/null @@ -1,28 +0,0 @@ -# Bench results - -Captured: 2026-04-28T18:15:26.550Z -Commit: `4c80b07` - -Numeric cells show `median (min–max)`. - -## Lighthouse (5 runs) - -| Scenario | Perf | LCP | TBT | CLS | Speed Index | -| ------------------------------ | ---------- | ------------------ | ---------------- | ---------------- | --------------- | -| `accession=P05067&bench=1` | 69 (68–70) | 5.5 s (5.4–5.6) | 44 ms (35–53) | 0.00 (0.00–0.00) | 2.2 s (2.0–2.4) | -| `accession=P38398&bench=1` | 48 (46–52) | 46.7 s (46.1–47.0) | 341 ms (308–389) | 0.00 (0.00–0.00) | 4.0 s (2.5–4.5) | -| `accession=A0A2K5ULD0&bench=1` | 80 (76–82) | 2.3 s (2.2–2.7) | 33 ms (23–116) | 0.00 (0.00–0.00) | 2.1 s (2.0–2.4) | - -### Custom milestones (5 runs) - -| Scenario | fetch-and-parse | render | total | -| ------------------------------ | --------------- | ---------------- | --------------- | -| `accession=P05067&bench=1` | 2.1 s (1.5–2.4) | 326 ms (317–335) | 2.5 s (1.9–2.7) | -| `accession=P38398&bench=1` | 4.9 s (4.2–6.6) | 292 ms (291–305) | 5.2 s (4.4–6.9) | -| `accession=A0A2K5ULD0&bench=1` | 1.6 s (1.5–2.1) | 274 ms (273–278) | 1.9 s (1.8–2.4) | - -## Bundle size (library, `dist/`) - -| Total raw | Total gzip | Files | -| --------- | ---------- | ----- | -| 4545.8 KB | 1135.5 KB | 1 | diff --git a/bench/instrument.js b/bench/instrument.js deleted file mode 100644 index 7231db6e..00000000 --- a/bench/instrument.js +++ /dev/null @@ -1,140 +0,0 @@ -/** - * External instrumentation for protvista-uniprot. - * - * Always loaded by the demo build, but no-ops unless the URL has - * `?bench=1`. Emits `performance.mark` / `performance.measure` calls - * based on the component's existing public surface — no `src/` changes. - * - * Lighthouse's user-timings audit picks these up automatically, so they - * show up in `summary.md` next to LCP/TBT/etc. - * - * Marks: - * protvista:script-start when this script runs (≈ navigation start) - * protvista:data-loaded loader removed (data fetched + parsed) - * protvista:first-render nightingale-manager appears under the host - * protvista:tracks-settled no host-subtree mutations for QUIESCENCE_MS - * (proxy for "tracks finished painting") - * - * Measures: - * protvista:fetch-and-parse script-start → data-loaded - * protvista:render data-loaded → tracks-settled (incl. quiescence gap) - * protvista:total script-start → tracks-settled - * - * The render measure ends at tracks-settled rather than first-render - * because Lit batches the loader-removal and manager-insertion into the - * same render cycle — first-render fires before nightingale's track - * elements have actually painted. Quiescence detection is the same - * pattern Playwright's `networkidle` and web-vitals' "long task" probes - * use; the QUIESCENCE_MS threshold below is a constant offset that - * cancels out in before/after comparisons. - * - * If the public events change shape, marks may go missing — fail loudly - * by checking summary.md, not silently by adding fallback heuristics. - */ - -if (new URLSearchParams(location.search).has('bench')) { - run(); -} - -function run() { - const HOST_TAG = 'protvista-uniprot'; - const RENDERED_CHILD = 'nightingale-manager'; - const LOADER_CLASS = 'protvista-loader'; - const QUIESCENCE_MS = 250; - - performance.mark('protvista:script-start'); - - const hasMark = (name) => - performance.getEntriesByName(name, 'mark').length > 0; - - function whenHostExists() { - return new Promise((resolve) => { - const found = document.querySelector(HOST_TAG); - if (found) return resolve(found); - const obs = new MutationObserver(() => { - const el = document.querySelector(HOST_TAG); - if (el) { - obs.disconnect(); - resolve(el); - } - }); - obs.observe(document.documentElement, { - childList: true, - subtree: true, - }); - }); - } - - whenHostExists().then((host) => { - // We watch the light-DOM host for three transitions: - // 1. `.protvista-loader` was shown and is now gone → data has loaded - // 2. `` is present → component has first-rendered - // 3. no further subtree mutations for QUIESCENCE_MS → tracks have settled - // - // The component's own `protvista-event{hasData}` is unreliable - // (see "Note: this doesn't seem to work" in src/protvista-uniprot.ts); - // the loader div is the next-best public signal of "fetch resolved". - // - // Why the `loaderEverSeen` flag matters: the host can be observed - // *before* Lit's first render inserts the loader. A naive "loader not - // present" check then fires `data-loaded` immediately at framework - // startup time — wrong by orders of magnitude. - let loaderEverSeen = !!host.querySelector(`.${LOADER_CLASS}`); - let settleTimer = null; - - const obs = new MutationObserver(() => { - const loaderPresent = !!host.querySelector(`.${LOADER_CLASS}`); - const managerPresent = !!host.querySelector(RENDERED_CHILD); - if (loaderPresent) loaderEverSeen = true; - - // Data loaded: loader was shown and is now gone. Fallback: manager - // appeared without a loader ever showing (cached / instant render). - if ( - !hasMark('protvista:data-loaded') && - ((loaderEverSeen && !loaderPresent) || - (managerPresent && !loaderEverSeen)) - ) { - performance.mark('protvista:data-loaded'); - performance.measure( - 'protvista:fetch-and-parse', - 'protvista:script-start', - 'protvista:data-loaded' - ); - } - - if (!hasMark('protvista:first-render') && managerPresent) { - performance.mark('protvista:first-render'); - } - - // Once first-render has fired, debounce: every new mutation pushes - // the settle timer out by QUIESCENCE_MS. When the timer finally - // expires, the subtree has been mutation-free for that long and we - // treat painting as done. - if (hasMark('protvista:first-render')) { - clearTimeout(settleTimer); - settleTimer = setTimeout(settle, QUIESCENCE_MS); - } - }); - - function settle() { - if (hasMark('protvista:tracks-settled')) return; - if (!hasMark('protvista:first-render')) return; - performance.mark('protvista:tracks-settled'); - if (hasMark('protvista:data-loaded')) { - performance.measure( - 'protvista:render', - 'protvista:data-loaded', - 'protvista:tracks-settled' - ); - } - performance.measure( - 'protvista:total', - 'protvista:script-start', - 'protvista:tracks-settled' - ); - obs.disconnect(); - } - - obs.observe(host, { childList: true, subtree: true }); - }); -} diff --git a/bench/lighthouserc.cjs b/bench/lighthouserc.cjs index 1714068f..e18bb43a 100644 --- a/bench/lighthouserc.cjs +++ b/bench/lighthouserc.cjs @@ -17,15 +17,16 @@ module.exports = { startServerCommand: 'npx vite preview --config vite.demo.config.mjs --port 4173 --strictPort', startServerReadyPattern: 'Local:', - // `&bench=1` opts the page into bench/instrument.js, which emits - // `protvista:*` user timings that Lighthouse captures in its trace. + // The component emits `protvista:*` performance marks/measures + // unconditionally; Lighthouse captures them via its user-timings + // audit and `bench/summarize.mjs` surfaces them in summary.md. url: [ // Well-annotated default — features, variants, structure. - 'http://localhost:4173/?accession=P05067&bench=1', + 'http://localhost:4173/?accession=P05067', // Heavy entry — many variants, 3D Beacons. - 'http://localhost:4173/?accession=P38398&bench=1', + 'http://localhost:4173/?accession=P38398', // Sparse entry — minimal feature load. - 'http://localhost:4173/?accession=A0A2K5ULD0&bench=1', + 'http://localhost:4173/?accession=A0A2K5ULD0', ], // 5 runs per URL — LHCI takes the median, this smooths out the // noise floor more than the default 3 without doubling wall time. @@ -39,6 +40,11 @@ module.exports = { // Be explicit so two machines on different Chrome versions still // produce comparable numbers. chromeFlags: '--headless=new --no-sandbox', + // Default is 45000 ms; the heavy variation payload on P38398 + // sometimes runs right at that edge and Lighthouse marks the + // whole run as a page-load failure (Perf=0, all audits empty). + // 60 s gives those scenarios room to finish. + maxWaitForLoad: 60000, }, }, upload: { diff --git a/eslint.config.mjs b/eslint.config.mjs index 9c33df0f..ce18c8cf 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -46,18 +46,7 @@ export default [ }, }, - /* Bench scripts — plain JS, not part of the shipped library. Browser - globals for the instrumentation, Node globals for the runners. */ - { - files: ['bench/instrument.js'], - languageOptions: { - ecmaVersion: 2022, - sourceType: 'module', - globals: { - ...globals.browser, - }, - }, - }, + /* Bench runners — node scripts, not shipped. */ { files: ['bench/**/*.{mjs,cjs}'], languageOptions: { diff --git a/index.html b/index.html index b46f63bf..17f3e8ed 100644 --- a/index.html +++ b/index.html @@ -23,10 +23,6 @@ - -
diff --git a/package.json b/package.json index 7630af64..63294c2f 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "build": "vite build", "build:demo": "vite build --config vite.demo.config.mjs", "start": "vite", - "test:lint": "eslint 'src/**/*.ts' 'bench/**/*.{js,mjs,cjs}'", + "test:lint": "eslint 'src/**/*.ts' 'bench/**/*.{mjs,cjs}'", "test:types": "tsc", "test:unit": "vitest run", "test:watch": "vitest", diff --git a/src/filter-config.ts b/src/filter-config.ts index 77b9dae3..c25bbab0 100644 --- a/src/filter-config.ts +++ b/src/filter-config.ts @@ -1,4 +1,4 @@ -import { VariationDatum } from '@nightingale-elements/nightingale-variation'; +import { type VariationDatum } from '@nightingale-elements/nightingale-variation'; import { ClinicalSignificance } from '@nightingale-elements/nightingale-variation'; const scaleColors = { diff --git a/src/protvista-uniprot.ts b/src/protvista-uniprot.ts index b5067917..f5c01442 100644 --- a/src/protvista-uniprot.ts +++ b/src/protvista-uniprot.ts @@ -51,6 +51,34 @@ import loaderIcon from './icons/spinner.svg'; import protvistaStyles from './styles/protvista-styles'; import loaderStyles from './styles/loader-styles'; +// Performance marks emitted at three lifecycle transitions: +// protvista:script-start component connectedCallback runs +// protvista:data-loaded fetch + parse complete +// protvista:first-render nightingale-manager rendered with content +// These are part of the component's public observable surface — the +// `bench/` workflow relies on them to compare baselines across refactors. +// Renaming or moving them is a breaking change for perf measurement. +// +// Each mark fires at most once per page (subsequent component instances +// or re-loads no-op), and corresponding measures are emitted so they +// show up as named segments in Chrome DevTools and Lighthouse's +// user-timings audit. +const markOnce = (name: string) => { + if (performance.getEntriesByName(name, 'mark').length === 0) { + performance.mark(name); + } +}; +const measureOnce = (name: string, start: string, end: string) => { + if (performance.getEntriesByName(name, 'measure').length === 0) { + try { + performance.measure(name, start, end); + } catch { + // Either start/end mark missing — surface marks but skip the measure + // rather than throwing; comparing the marks directly still works. + } + } +}; + // Heterogeneous adapter map — each adapter has its own signature and return // shape. Typed loosely here so the .apply() dispatch below doesn't try to // reconcile the union of all signatures at the call site. @@ -158,10 +186,23 @@ class ProtvistaUniprot extends LitElement { ); // Some endpoints return empty arrays, while most fail 🙄 + const wasHasData = this.hasData; this.hasData = this.hasData || Object.values(this.rawData).some((d) => !!d?.features?.length); + // Fire the public protvista-event the moment data first becomes + // available. (Previously this was hung off a `'load'` listener that + // never fired — see `connectedCallback` history.) + if (this.hasData && !wasHasData) { + this.dispatchEvent( + new CustomEvent('protvista-event', { + detail: { hasData: true }, + bubbles: true, + }) + ); + } + // Now iterate over tracks and categories, transforming the data // and assigning it as adequate for (const { name: categoryName, tracks, trackType } of this.config @@ -235,6 +276,12 @@ class ProtvistaUniprot extends LitElement { } } this.loading = false; + markOnce('protvista:data-loaded'); + measureOnce( + 'protvista:fetch-and-parse', + 'protvista:script-start', + 'protvista:data-loaded' + ); this.requestUpdate(); // Why? } @@ -313,6 +360,21 @@ class ProtvistaUniprot extends LitElement { updated(changedProperties: Map) { super.updated(changedProperties); + // First render with content — manager is in the DOM, not the loader. + if (this.hasData && !this.loading) { + markOnce('protvista:first-render'); + measureOnce( + 'protvista:render', + 'protvista:data-loaded', + 'protvista:first-render' + ); + measureOnce( + 'protvista:total', + 'protvista:script-start', + 'protvista:first-render' + ); + } + const filterComponent = this.querySelector('nightingale-filter'); if (filterComponent && filterComponent.filters !== filterConfig) { @@ -351,6 +413,7 @@ class ProtvistaUniprot extends LitElement { connectedCallback() { super.connectedCallback(); + markOnce('protvista:script-start'); this.registerWebComponents(); if (!this.suspend) this._init(); @@ -363,21 +426,6 @@ class ProtvistaUniprot extends LitElement { this.displayCoordinates.end = e.detail.displayend; } }); - - // Note: this doesn't seem to work - this.addEventListener('load', () => { - if (!this.hasData) { - this.dispatchEvent( - new CustomEvent('protvista-event', { - detail: { - hasData: true, - }, - bubbles: true, - }) - ); - this.hasData = true; - } - }); } async loadEntry(accession: string) {