perf(cuesheet): add temporary first-render metrics scaffold

Adds a dev-only, self-contained instrumentation module under
common/devtools/cuesheet-metrics to measure the first-render (mount) cost
of virtualised cuesheet rows, so scroll-performance changes can be backed
by before/after numbers.

- Console-only API on window.__cuesheetPerf (deterministic scroll benchmark
  + manual start/stop), gated by isDev && ?perf=1, runtime-inert in prod.
- Whole-row mount probe plus per-subsystem attribution: colour calc,
  getVisibleCells, AutoTextarea autosize, Tooltip portal mount,
  useReactiveTextInput hotkey-handler init; FPS/long-frame timing.
- All edits to existing files tagged // PERF-METRICS for a one-pass teardown.

Temporary: to be deleted once the optimizations are proven.
This commit is contained in:
Claude
2026-06-15 20:17:01 +00:00
parent c238147b75
commit eab63e838d
12 changed files with 437 additions and 21 deletions
@@ -2,6 +2,7 @@
import autosize from 'autosize/dist/autosize';
import { RefObject, useEffect } from 'react';
import { timeSync } from '../../../devtools/cuesheet-metrics/usePerfMark'; // PERF-METRICS
import Textarea, { type TextareaProps } from '../textarea/Textarea';
interface AutoTextAreaProps extends TextareaProps {
@@ -15,7 +16,7 @@ export function AutoTextarea({ value, inputref, ...textAreaProps }: AutoTextArea
// when the value changes, we use the ref to reapply autosize
useEffect(() => {
const node = inputref.current;
autosize(inputref.current);
timeSync('cell.autosize', () => autosize(inputref.current)); // PERF-METRICS
return () => {
autosize.destroy(node);
@@ -1,6 +1,8 @@
import { HotkeyItem, getHotkeyHandler } from '@mantine/hooks';
import { ChangeEvent, KeyboardEvent, RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { timeSync } from '../../../devtools/cuesheet-metrics/usePerfMark'; // PERF-METRICS
interface UseReactiveTextInputReturn {
value: string;
onChange: (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
@@ -95,7 +97,7 @@ export default function useReactiveTextInput(
setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before blurring
}, [initialText, options, ref]);
const keyHandler = useMemo(() => {
const keyHandler = useMemo(() => timeSync('cell.reactiveInputInit', () => { // PERF-METRICS
const hotKeys: HotkeyItem[] = [
[
'Escape',
@@ -155,7 +157,7 @@ export default function useReactiveTextInput(
hotKeyHandler(event);
};
}, [handleEscape, handleSubmit, options?.submitOnCtrlEnter, options?.submitOnEnter, options?.submitOnTab, text]);
}), [handleEscape, handleSubmit, options?.submitOnCtrlEnter, options?.submitOnEnter, options?.submitOnTab, text]); // PERF-METRICS
return {
value: text,
@@ -1,6 +1,8 @@
import { Tooltip as BaseTooltip } from '@base-ui/react/tooltip';
import { PropsWithChildren } from 'react';
import { useMountProbe } from '../../devtools/cuesheet-metrics/usePerfMark'; // PERF-METRICS
import style from './Tooltip.module.scss';
interface TooltipProps extends BaseTooltip.Trigger.Props {
@@ -8,6 +10,7 @@ interface TooltipProps extends BaseTooltip.Trigger.Props {
}
export default function Tooltip({ text, children, ...triggerProps }: PropsWithChildren<TooltipProps>) {
useMountProbe('cell.tooltip'); // PERF-METRICS
return (
<BaseTooltip.Root>
<BaseTooltip.Trigger {...triggerProps}>{children}</BaseTooltip.Trigger>
@@ -0,0 +1,52 @@
# cuesheet-metrics (TEMPORARY)
Dev-only instrumentation to measure the **first-render (mount) cost of virtualised cuesheet
rows**, so the scroll-performance optimizations can be supported by before/after numbers.
> This whole directory is temporary and must be deleted once the optimizations are proven.
> Every edit it makes to existing files is tagged `// PERF-METRICS`.
## Enable
Open the cuesheet in a **dev build** with the `?perf=1` query param:
```
http://localhost:3000/cuesheet?perf=1
```
Nothing is collected and nothing is exposed unless both conditions hold (`isDev && ?perf=1`).
In production builds `isDev` is `false`, so all recording paths are runtime-inert.
## Measure
A console API is exposed on `window.__cuesheetPerf`:
- `runScrollBenchmark(options?)` — deterministic, hands-off scroll from `fromIndex` to
`toIndex` and back, then prints the result. Use the same options before/after a change so
numbers are comparable. Options: `{ fromIndex=0, toIndex=200, stride=8, stepMs=16 }`.
- `start()` / `stop()` — begin a manual session, scroll by hand, then stop to print.
- `dump()` / `reset()`.
To emulate a low-end device, set Chrome DevTools → Performance → CPU 4×–6× slowdown, then run
the benchmark 3× and compare medians.
## What is reported
- **Summary:** rows mounted, frames, long frames (>~25ms), avg/min FPS, worst frame ms.
- **Marks** (count / avg ms / max ms / total ms):
- `EventRow.mount` — whole-row mount time (render-start → post-commit layout)
- `cell.tooltip.mount` — mount of a delay-indicator tooltip subtree (portal/positioner)
- `row.colourCalc` — per-row colour-accessibility computation
- `row.getVisibleCells` — react-table visible-cell expansion
- `cell.autosize``AutoTextarea` autosize + MutationObserver setup
- `cell.reactiveInputInit``useReactiveTextInput` hotkey-handler construction
- `longtask` — main-thread long tasks captured during the session (where supported)
## Teardown
1. `rg "PERF-METRICS" apps/client/src` → revert each tagged line in the existing files
(`CuesheetTable.tsx`, `EventRow.tsx`, `AutoTextarea.tsx`, `Tooltip.tsx`,
`useReactiveTextInput.tsx`) back to its original body.
2. Delete this directory.
3. `rg "cuesheet-metrics|__cuesheetPerf" apps/client/src` → must be empty.
4. `pnpm typecheck && pnpm test:pipeline && pnpm build`.
@@ -0,0 +1,81 @@
/**
* PERF-METRICS — temporary cuesheet scroll-performance scaffold (delete with this directory).
*
* A deterministic, hands-off scroll runner so before/after numbers are comparable. Scrolls the
* virtualised table from `fromIndex` to `toIndex` and back in fixed index strides, under an
* active frame-timing session, then dumps the result.
*/
import type { RefObject } from 'react';
import type { TableVirtuosoHandle } from 'react-virtuoso';
import { PERF_ENABLED } from './perfConfig';
import { dump, endSession, startSession } from './perfStore';
import { startFpsMonitor, stopFpsMonitor } from './scrollFpsMonitor';
export interface BenchmarkOptions {
fromIndex?: number;
toIndex?: number;
/** Indices advanced per step. */
stride?: number;
/** Delay between steps, ms. */
stepMs?: number;
}
export async function runScrollBenchmark(
virtuosoRef: RefObject<TableVirtuosoHandle | null>,
options: BenchmarkOptions = {},
): Promise<void> {
if (!PERF_ENABLED) {
// eslint-disable-next-line no-console
console.warn('[cuesheet-perf] disabled — open the cuesheet with ?perf=1 in a dev build.');
return;
}
const handle = virtuosoRef.current;
if (!handle) {
// eslint-disable-next-line no-console
console.warn('[cuesheet-perf] virtuoso handle not ready.');
return;
}
const { fromIndex = 0, toIndex = 200, stride = 8, stepMs = 16 } = options;
startSession();
startFpsMonitor();
await scrollThrough(handle, fromIndex, toIndex, stride, stepMs);
await scrollThrough(handle, toIndex, fromIndex, stride, stepMs);
stopFpsMonitor();
endSession();
dump();
}
function scrollThrough(
handle: TableVirtuosoHandle,
from: number,
to: number,
stride: number,
stepMs: number,
): Promise<void> {
return new Promise((resolve) => {
const dir = to >= from ? 1 : -1;
let current = from;
const step = () => {
handle.scrollToIndex({ index: current, behavior: 'auto', align: 'start' });
if (current === to) {
resolve();
return;
}
current += stride * dir;
if ((dir > 0 && current > to) || (dir < 0 && current < to)) {
current = to;
}
setTimeout(step, stepMs);
};
step();
});
}
@@ -0,0 +1,22 @@
/**
* PERF-METRICS — temporary cuesheet scroll-performance scaffold.
*
* This whole directory (common/devtools/cuesheet-metrics) is temporary and is meant to be
* deleted once the first-render optimizations are proven. See the perf investigation plan.
*
* Gates all instrumentation: enabled only in a dev build AND when the page is opened with
* `?perf=1`. Because `isDev` resolves to `import.meta.env.DEV`, this is `false` in production,
* so every recording path stays runtime-inert there (the guarded code may still be present in
* the bundle — it is never executed, and the whole scaffold is removed at teardown).
*/
import { isDev } from '../../../externals';
function readPerfParam(): boolean {
try {
return new URLSearchParams(window.location.search).get('perf') === '1';
} catch {
return false;
}
}
export const PERF_ENABLED = isDev && readPerfParam();
@@ -0,0 +1,117 @@
/**
* PERF-METRICS — temporary cuesheet scroll-performance scaffold (delete with this directory).
*
* Plain module singleton (no zustand): the metrics are surfaced via the console only, so there
* is no React subscriber that would justify a reactive store. Everything here is a no-op unless
* a session is running, and call sites are additionally gated by PERF_ENABLED.
*/
/** A frame slower than ~1.5 frames at 60fps is counted as a "long frame". */
const LONG_FRAME_MS = (1000 / 60) * 1.5;
interface MarkStat {
count: number;
totalMs: number;
maxMs: number;
}
interface PerfState {
running: boolean;
marks: Record<string, MarkStat>;
rowsMounted: number;
frameCount: number;
totalFrameMs: number;
maxFrameMs: number;
longFrames: number;
}
function createState(): PerfState {
return {
running: false,
marks: {},
rowsMounted: 0,
frameCount: 0,
totalFrameMs: 0,
maxFrameMs: 0,
longFrames: 0,
};
}
let state = createState();
export function isRunning(): boolean {
return state.running;
}
export function startSession(): void {
state = createState();
state.running = true;
}
export function endSession(): void {
state.running = false;
}
export function reset(): void {
state = createState();
}
export function recordMark(name: string, ms: number): void {
if (!state.running) return;
const mark = state.marks[name] ?? { count: 0, totalMs: 0, maxMs: 0 };
mark.count += 1;
mark.totalMs += ms;
mark.maxMs = Math.max(mark.maxMs, ms);
state.marks[name] = mark;
}
export function recordRowMount(): void {
if (!state.running) return;
state.rowsMounted += 1;
}
export function recordFrame(deltaMs: number): void {
if (!state.running) return;
state.frameCount += 1;
state.totalFrameMs += deltaMs;
state.maxFrameMs = Math.max(state.maxFrameMs, deltaMs);
if (deltaMs > LONG_FRAME_MS) {
state.longFrames += 1;
}
}
/** Dumps the collected metrics to the console as tables. */
export function dump(): void {
const markRows = Object.entries(state.marks)
.map(([name, m]) => ({
mark: name,
count: m.count,
avgMs: round(m.totalMs / m.count),
maxMs: round(m.maxMs),
totalMs: round(m.totalMs),
}))
.sort((a, b) => b.totalMs - a.totalMs);
const avgFps = state.totalFrameMs > 0 ? round((state.frameCount * 1000) / state.totalFrameMs) : 0;
const minFps = state.maxFrameMs > 0 ? round(1000 / state.maxFrameMs) : 0;
const summary = {
rowsMounted: state.rowsMounted,
frames: state.frameCount,
longFrames: state.longFrames,
avgFps,
minFps,
maxFrameMs: round(state.maxFrameMs),
};
/* eslint-disable no-console */
console.group('[cuesheet-perf] benchmark result');
console.table(summary);
console.table(markRows);
console.groupEnd();
/* eslint-enable no-console */
}
function round(value: number): number {
return Math.round(value * 100) / 100;
}
@@ -0,0 +1,48 @@
/**
* PERF-METRICS — temporary cuesheet scroll-performance scaffold (delete with this directory).
*
* Drives a requestAnimationFrame loop while a session is running to capture frame timing
* (FPS / long-frame count) and, where supported, records main-thread long tasks.
*/
import { PERF_ENABLED } from './perfConfig';
import { recordFrame, recordMark } from './perfStore';
let rafId: number | null = null;
let lastFrame = 0;
let observer: PerformanceObserver | null = null;
export function startFpsMonitor(): void {
if (!PERF_ENABLED || rafId !== null) return;
lastFrame = performance.now();
const tick = (now: number) => {
recordFrame(now - lastFrame);
lastFrame = now;
rafId = requestAnimationFrame(tick);
};
rafId = requestAnimationFrame(tick);
if (typeof PerformanceObserver !== 'undefined') {
try {
observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
recordMark('longtask', entry.duration);
}
});
observer.observe({ entryTypes: ['longtask'] });
} catch {
observer = null;
}
}
}
export function stopFpsMonitor(): void {
if (rafId !== null) {
cancelAnimationFrame(rafId);
rafId = null;
}
if (observer) {
observer.disconnect();
observer = null;
}
}
@@ -0,0 +1,47 @@
/**
* PERF-METRICS — temporary cuesheet scroll-performance scaffold (delete with this directory).
*
* Wires the metrics scaffold into the cuesheet table. When enabled (`?perf=1` in a dev build) it
* exposes a console API on `window.__cuesheetPerf` and is otherwise an inert no-op.
*
* __cuesheetPerf.runScrollBenchmark() // deterministic scroll, dumps a comparable result
* __cuesheetPerf.start() // begin a manual session (then scroll by hand)
* __cuesheetPerf.stop() // end the manual session and dump
*/
import { useEffect, type RefObject } from 'react';
import type { TableVirtuosoHandle } from 'react-virtuoso';
import { runScrollBenchmark, type BenchmarkOptions } from './benchmark';
import { PERF_ENABLED } from './perfConfig';
import { dump, endSession, reset, startSession } from './perfStore';
import { startFpsMonitor, stopFpsMonitor } from './scrollFpsMonitor';
export function useCuesheetPerf(virtuosoRef: RefObject<TableVirtuosoHandle | null>): void {
useEffect(() => {
if (!PERF_ENABLED) return;
const api = {
runScrollBenchmark: (options?: BenchmarkOptions) => runScrollBenchmark(virtuosoRef, options),
start: () => {
startSession();
startFpsMonitor();
},
stop: () => {
stopFpsMonitor();
endSession();
dump();
},
dump,
reset,
};
(window as unknown as { __cuesheetPerf?: typeof api }).__cuesheetPerf = api;
// eslint-disable-next-line no-console
console.info('[cuesheet-perf] enabled. window.__cuesheetPerf = { runScrollBenchmark, start, stop, dump, reset }');
return () => {
stopFpsMonitor();
delete (window as unknown as { __cuesheetPerf?: typeof api }).__cuesheetPerf;
};
}, [virtuosoRef]);
}
@@ -0,0 +1,38 @@
/**
* PERF-METRICS — temporary cuesheet scroll-performance scaffold (delete with this directory).
*
* Lightweight timing helpers. All no-op when instrumentation is disabled so the call sites in
* shared components stay cheap (and fold away in production builds).
*/
import { useLayoutEffect, useRef } from 'react';
import { PERF_ENABLED } from './perfConfig';
import { recordMark, recordRowMount } from './perfStore';
/** Times the synchronous `fn`, records the duration under `name`, and returns its result. */
export function timeSync<T>(name: string, fn: () => T): T {
if (!PERF_ENABLED) return fn();
const start = performance.now();
try {
return fn();
} finally {
recordMark(name, performance.now() - start);
}
}
/**
* Measures the mount cost of a component (render-start → post-commit layout) and records it
* under `${name}.mount`. Used on EventRow and on the Tooltip subtree to attribute per-row
* first-render cost. Pass `countAsRow` on the row probe so only rows feed the rows-mounted tally.
*/
export function useMountProbe(name: string, countAsRow = false): void {
const renderStart = useRef(PERF_ENABLED ? performance.now() : 0);
useLayoutEffect(() => {
if (!PERF_ENABLED) return;
recordMark(`${name}.mount`, performance.now() - renderStart.current);
if (countAsRow) {
recordRowMount();
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only probe
}, []);
}
@@ -14,6 +14,7 @@ import {
import EmptyPage from '../../../common/components/state/EmptyPage';
import EmptyTableBody from '../../../common/components/state/EmptyTableBody';
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import { useCuesheetPerf } from '../../../common/devtools/cuesheet-metrics/useCuesheetPerf'; // PERF-METRICS
import type { RundownSource } from '../../../common/hooks-query/useScopedRundown';
import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
import { usePersistedRundownOptions } from '../../../features/rundown/rundown.options';
@@ -72,6 +73,7 @@ export default function CuesheetTable({
const setScrollHandler = useEventSelection((state) => state.setScrollHandler);
const virtuosoRef = useRef<TableVirtuosoHandle | null>(null);
useCuesheetPerf(virtuosoRef); // PERF-METRICS
const { listeners } = useTableNav();
const meta = useMemo(
@@ -5,6 +5,7 @@ import { CSSProperties, memo, useMemo } from 'react';
import { IoEllipsisHorizontal } from 'react-icons/io5';
import IconButton from '../../../../common/components/buttons/IconButton';
import { timeSync, useMountProbe } from '../../../../common/devtools/cuesheet-metrics/usePerfMark'; // PERF-METRICS
import type { ExtendedEntry } from '../../../../common/utils/rundownMetadata';
import { cx, getAccessibleColour } from '../../../../common/utils/styleUtils';
import { AppMode } from '../../../../ontimeConfig';
@@ -56,25 +57,29 @@ function EventRow({
const openMenu = useCuesheetTableMenu((store) => store.openMenu);
useMountProbe('EventRow', true); // PERF-METRICS
const { rowBgColour, backgroundColor, mutedText } = useMemo(() => {
const accessible = getAccessibleColour(colour);
const tmpColour = cssOrHexToColour(accessible.color) as RGBColour;
return timeSync('row.colourCalc', () => { // PERF-METRICS
const accessible = getAccessibleColour(colour);
const tmpColour = cssOrHexToColour(accessible.color) as RGBColour;
let rowBgColour: string | undefined;
if (isLoaded) {
rowBgColour = '#087A27'; // $active-green
} else if (colour) {
const accessibleBg = cssOrHexToColour(accessible.backgroundColor);
if (accessibleBg !== null) {
rowBgColour = colourToHex({ ...accessibleBg, alpha: accessibleBg.alpha * 0.25 });
let rowBgColour: string | undefined;
if (isLoaded) {
rowBgColour = '#087A27'; // $active-green
} else if (colour) {
const accessibleBg = cssOrHexToColour(accessible.backgroundColor);
if (accessibleBg !== null) {
rowBgColour = colourToHex({ ...accessibleBg, alpha: accessibleBg.alpha * 0.25 });
}
}
}
return {
rowBgColour,
backgroundColor: accessible.backgroundColor,
mutedText: colourToHex({ ...tmpColour, alpha: tmpColour.alpha * 0.8 }),
};
return {
rowBgColour,
backgroundColor: accessible.backgroundColor,
mutedText: colourToHex({ ...tmpColour, alpha: tmpColour.alpha * 0.8 }),
};
}); // PERF-METRICS
}, [colour, isLoaded]);
return (
@@ -117,9 +122,7 @@ function EventRow({
{eventIndex}
</td>
)}
{table
.getRow(rowId)
.getVisibleCells()
{timeSync('row.getVisibleCells', () => table.getRow(rowId).getVisibleCells()) // PERF-METRICS
.map((cell) => {
return (
<td