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 e0f91039c..4c403401e 100644 --- a/apps/client/src/common/components/input/auto-textarea/AutoTextarea.tsx +++ b/apps/client/src/common/components/input/auto-textarea/AutoTextarea.tsx @@ -2,7 +2,6 @@ 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 { @@ -16,7 +15,7 @@ export function AutoTextarea({ value, inputref, ...textAreaProps }: AutoTextArea // when the value changes, we use the ref to reapply autosize useEffect(() => { const node = inputref.current; - timeSync('cell.autosize', () => autosize(inputref.current)); // PERF-METRICS + autosize(inputref.current); 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 eb32f67fe..59f314ce7 100644 --- a/apps/client/src/common/components/input/text-input/useReactiveTextInput.tsx +++ b/apps/client/src/common/components/input/text-input/useReactiveTextInput.tsx @@ -1,8 +1,6 @@ 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; @@ -97,7 +95,7 @@ export default function useReactiveTextInput( setTimeout(() => ref.current?.blur()); // Immediate timeout to ensure text is set before blurring }, [initialText, options, ref]); - const keyHandler = useMemo(() => timeSync('cell.reactiveInputInit', () => { // PERF-METRICS + const keyHandler = useMemo(() => { const hotKeys: HotkeyItem[] = [ [ 'Escape', @@ -157,7 +155,7 @@ export default function useReactiveTextInput( hotKeyHandler(event); }; - }), [handleEscape, handleSubmit, options?.submitOnCtrlEnter, options?.submitOnEnter, options?.submitOnTab, text]); // PERF-METRICS + }, [handleEscape, handleSubmit, options?.submitOnCtrlEnter, options?.submitOnEnter, options?.submitOnTab, text]); return { value: text, diff --git a/apps/client/src/common/components/tooltip/Tooltip.tsx b/apps/client/src/common/components/tooltip/Tooltip.tsx index e8ca553ff..dbd00821d 100644 --- a/apps/client/src/common/components/tooltip/Tooltip.tsx +++ b/apps/client/src/common/components/tooltip/Tooltip.tsx @@ -1,8 +1,6 @@ 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 { @@ -10,7 +8,6 @@ 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 deleted file mode 100644 index 910787215..000000000 --- a/apps/client/src/common/devtools/cuesheet-metrics/README.md +++ /dev/null @@ -1,52 +0,0 @@ -# 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 deleted file mode 100644 index b837d4434..000000000 --- a/apps/client/src/common/devtools/cuesheet-metrics/benchmark.ts +++ /dev/null @@ -1,82 +0,0 @@ -/** - * 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, snapshot, startSession, type PerfSnapshot } 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 undefined; - } - const handle = virtuosoRef.current; - if (!handle) { - // eslint-disable-next-line no-console - console.warn('[cuesheet-perf] virtuoso handle not ready.'); - return undefined; - } - - 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(); - return snapshot(); -} - -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 deleted file mode 100644 index e1249752e..000000000 --- a/apps/client/src/common/devtools/cuesheet-metrics/perfConfig.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * 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 deleted file mode 100644 index a4c27c47e..000000000 --- a/apps/client/src/common/devtools/cuesheet-metrics/perfStore.ts +++ /dev/null @@ -1,136 +0,0 @@ -/** - * 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; - } -} - -export interface PerfSnapshot { - summary: { - rowsMounted: number; - frames: number; - longFrames: number; - avgFps: number; - minFps: number; - maxFrameMs: number; - }; - marks: Array<{ mark: string; count: number; avgMs: number; maxMs: number; totalMs: number }>; -} - -/** Returns the collected metrics as a structured object (for console or automated capture). */ -export function snapshot(): PerfSnapshot { - const marks = 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; - - return { - summary: { - rowsMounted: state.rowsMounted, - frames: state.frameCount, - longFrames: state.longFrames, - avgFps, - minFps, - maxFrameMs: round(state.maxFrameMs), - }, - marks, - }; -} - -/** Dumps the collected metrics to the console as tables. */ -export function dump(): void { - const { summary, marks } = snapshot(); - /* eslint-disable no-console */ - console.group('[cuesheet-perf] benchmark result'); - console.table(summary); - console.table(marks); - 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 deleted file mode 100644 index 654d93ca2..000000000 --- a/apps/client/src/common/devtools/cuesheet-metrics/scrollFpsMonitor.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * 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 deleted file mode 100644 index 4da5b7a3a..000000000 --- a/apps/client/src/common/devtools/cuesheet-metrics/useCuesheetPerf.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * 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, snapshot, 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, - getResults: snapshot, - }; - - (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 deleted file mode 100644 index 31177cbb3..000000000 --- a/apps/client/src/common/devtools/cuesheet-metrics/usePerfMark.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * 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 9febd94b1..89b155c68 100644 --- a/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.tsx +++ b/apps/client/src/views/cuesheet/cuesheet-table/CuesheetTable.tsx @@ -14,7 +14,6 @@ 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'; @@ -73,7 +72,6 @@ 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 f004cf721..c142ba03a 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,7 +5,6 @@ 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'; @@ -57,29 +56,25 @@ function EventRow({ const openMenu = useCuesheetTableMenu((store) => store.openMenu); - useMountProbe('EventRow', true); // PERF-METRICS - const { rowBgColour, backgroundColor, mutedText } = useMemo(() => { - return timeSync('row.colourCalc', () => { // PERF-METRICS - const accessible = getAccessibleColour(colour); - const tmpColour = cssOrHexToColour(accessible.color) as RGBColour; + 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 }), - }; - }); // PERF-METRICS + return { + rowBgColour, + backgroundColor: accessible.backgroundColor, + mutedText: colourToHex({ ...tmpColour, alpha: tmpColour.alpha * 0.8 }), + }; }, [colour, isLoaded]); return ( @@ -122,7 +117,9 @@ function EventRow({ {eventIndex} )} - {timeSync('row.getVisibleCells', () => table.getRow(rowId).getVisibleCells()) // PERF-METRICS + {table + .getRow(rowId) + .getVisibleCells() .map((cell) => { return (