diff --git a/apps/client/src/common/components/input/auto-textarea/AutoTextarea.tsx b/apps/client/src/common/components/input/auto-textarea/AutoTextarea.tsx index 4c403401e..e0f91039c 100644 --- a/apps/client/src/common/components/input/auto-textarea/AutoTextarea.tsx +++ b/apps/client/src/common/components/input/auto-textarea/AutoTextarea.tsx @@ -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); diff --git a/apps/client/src/common/components/input/text-input/useReactiveTextInput.tsx b/apps/client/src/common/components/input/text-input/useReactiveTextInput.tsx index 59f314ce7..eb32f67fe 100644 --- a/apps/client/src/common/components/input/text-input/useReactiveTextInput.tsx +++ b/apps/client/src/common/components/input/text-input/useReactiveTextInput.tsx @@ -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) => 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, diff --git a/apps/client/src/common/components/tooltip/Tooltip.tsx b/apps/client/src/common/components/tooltip/Tooltip.tsx index dbd00821d..e8ca553ff 100644 --- a/apps/client/src/common/components/tooltip/Tooltip.tsx +++ b/apps/client/src/common/components/tooltip/Tooltip.tsx @@ -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) { + useMountProbe('cell.tooltip'); // PERF-METRICS return ( {children} diff --git a/apps/client/src/common/devtools/cuesheet-metrics/README.md b/apps/client/src/common/devtools/cuesheet-metrics/README.md new file mode 100644 index 000000000..910787215 --- /dev/null +++ b/apps/client/src/common/devtools/cuesheet-metrics/README.md @@ -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`. diff --git a/apps/client/src/common/devtools/cuesheet-metrics/benchmark.ts b/apps/client/src/common/devtools/cuesheet-metrics/benchmark.ts new file mode 100644 index 000000000..0786ebc91 --- /dev/null +++ b/apps/client/src/common/devtools/cuesheet-metrics/benchmark.ts @@ -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, + options: BenchmarkOptions = {}, +): Promise { + 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 { + 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(); + }); +} diff --git a/apps/client/src/common/devtools/cuesheet-metrics/perfConfig.ts b/apps/client/src/common/devtools/cuesheet-metrics/perfConfig.ts new file mode 100644 index 000000000..e1249752e --- /dev/null +++ b/apps/client/src/common/devtools/cuesheet-metrics/perfConfig.ts @@ -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(); diff --git a/apps/client/src/common/devtools/cuesheet-metrics/perfStore.ts b/apps/client/src/common/devtools/cuesheet-metrics/perfStore.ts new file mode 100644 index 000000000..711d67209 --- /dev/null +++ b/apps/client/src/common/devtools/cuesheet-metrics/perfStore.ts @@ -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; + 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; +} diff --git a/apps/client/src/common/devtools/cuesheet-metrics/scrollFpsMonitor.ts b/apps/client/src/common/devtools/cuesheet-metrics/scrollFpsMonitor.ts new file mode 100644 index 000000000..654d93ca2 --- /dev/null +++ b/apps/client/src/common/devtools/cuesheet-metrics/scrollFpsMonitor.ts @@ -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; + } +} diff --git a/apps/client/src/common/devtools/cuesheet-metrics/useCuesheetPerf.ts b/apps/client/src/common/devtools/cuesheet-metrics/useCuesheetPerf.ts new file mode 100644 index 000000000..d64947f86 --- /dev/null +++ b/apps/client/src/common/devtools/cuesheet-metrics/useCuesheetPerf.ts @@ -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): 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]); +} diff --git a/apps/client/src/common/devtools/cuesheet-metrics/usePerfMark.ts b/apps/client/src/common/devtools/cuesheet-metrics/usePerfMark.ts new file mode 100644 index 000000000..31177cbb3 --- /dev/null +++ b/apps/client/src/common/devtools/cuesheet-metrics/usePerfMark.ts @@ -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(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 + }, []); +} diff --git a/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.tsx b/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.tsx index 89b155c68..9febd94b1 100644 --- a/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.tsx +++ b/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.tsx @@ -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(null); + useCuesheetPerf(virtuosoRef); // PERF-METRICS const { listeners } = useTableNav(); const meta = useMemo( diff --git a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/EventRow.tsx b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/EventRow.tsx index c142ba03a..f004cf721 100644 --- a/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/EventRow.tsx +++ b/apps/client/src/views/cuesheet/cuesheet-table/cuesheet-table-elements/EventRow.tsx @@ -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} )} - {table - .getRow(rowId) - .getVisibleCells() + {timeSync('row.getVisibleCells', () => table.getRow(rowId).getVisibleCells()) // PERF-METRICS .map((cell) => { return (