perf(cuesheet): add result capture + candidate lazy-autosize (Exp B, under measurement)

- Scaffold: snapshot()/getResults() so the benchmark returns structured
  metrics for automated before/after capture (PERF-METRICS).
- Candidate Exp B: defer AutoTextarea autosize() off the mount path to
  focus-time, removing the per-row forced reflows during scroll. Under
  evaluation against the metrics.
This commit is contained in:
Claude
2026-06-15 20:46:54 +00:00
parent eab63e838d
commit a0d72d1776
4 changed files with 70 additions and 26 deletions
@@ -1,6 +1,6 @@
// @ts-expect-error no types from library // @ts-expect-error no types from library
import autosize from 'autosize/dist/autosize'; import autosize from 'autosize/dist/autosize';
import { RefObject, useEffect } from 'react'; import { FocusEvent, RefObject, useCallback, useEffect } from 'react';
import { timeSync } from '../../../devtools/cuesheet-metrics/usePerfMark'; // PERF-METRICS import { timeSync } from '../../../devtools/cuesheet-metrics/usePerfMark'; // PERF-METRICS
import Textarea, { type TextareaProps } from '../textarea/Textarea'; import Textarea, { type TextareaProps } from '../textarea/Textarea';
@@ -10,18 +10,41 @@ interface AutoTextAreaProps extends TextareaProps {
} }
/** /**
* A textarea that automatically resizes based on its content * A textarea that automatically resizes based on its content.
*
* `autosize()` forces a synchronous reflow (reads scrollHeight) and installs a MutationObserver.
* Doing that on mount makes it expensive when many of these are mounted at once (e.g. virtualised
* table rows during scroll), so we only attach autosize while the field is focused for editing and
* keep it in sync with the value during that time.
*/ */
export function AutoTextarea({ value, inputref, ...textAreaProps }: AutoTextAreaProps) { export function AutoTextarea({ value, inputref, onFocus, onBlur, ...textAreaProps }: AutoTextAreaProps) {
// when the value changes, we use the ref to reapply autosize const handleFocus = useCallback(
(event: FocusEvent<HTMLTextAreaElement>) => {
timeSync('cell.autosize', () => autosize(inputref.current)); // PERF-METRICS
onFocus?.(event);
},
[inputref, onFocus],
);
const handleBlur = useCallback(
(event: FocusEvent<HTMLTextAreaElement>) => {
if (inputref.current) {
autosize.destroy(inputref.current);
}
onBlur?.(event);
},
[inputref, onBlur],
);
// while focused, keep the height in sync as the value changes
useEffect(() => { useEffect(() => {
const node = inputref.current; const node = inputref.current;
timeSync('cell.autosize', () => autosize(inputref.current)); // PERF-METRICS if (node && document.activeElement === node) {
autosize(node);
return () => { }
autosize.destroy(node);
};
}, [inputref, value]); }, [inputref, value]);
return <Textarea ref={inputref} value={value} {...textAreaProps} />; return (
<Textarea ref={inputref} value={value} onFocus={handleFocus} onBlur={handleBlur} {...textAreaProps} />
);
} }
@@ -9,7 +9,7 @@ import type { RefObject } from 'react';
import type { TableVirtuosoHandle } from 'react-virtuoso'; import type { TableVirtuosoHandle } from 'react-virtuoso';
import { PERF_ENABLED } from './perfConfig'; import { PERF_ENABLED } from './perfConfig';
import { dump, endSession, startSession } from './perfStore'; import { dump, endSession, snapshot, startSession, type PerfSnapshot } from './perfStore';
import { startFpsMonitor, stopFpsMonitor } from './scrollFpsMonitor'; import { startFpsMonitor, stopFpsMonitor } from './scrollFpsMonitor';
export interface BenchmarkOptions { export interface BenchmarkOptions {
@@ -24,17 +24,17 @@ export interface BenchmarkOptions {
export async function runScrollBenchmark( export async function runScrollBenchmark(
virtuosoRef: RefObject<TableVirtuosoHandle | null>, virtuosoRef: RefObject<TableVirtuosoHandle | null>,
options: BenchmarkOptions = {}, options: BenchmarkOptions = {},
): Promise<void> { ): Promise<PerfSnapshot | undefined> {
if (!PERF_ENABLED) { if (!PERF_ENABLED) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.warn('[cuesheet-perf] disabled — open the cuesheet with ?perf=1 in a dev build.'); console.warn('[cuesheet-perf] disabled — open the cuesheet with ?perf=1 in a dev build.');
return; return undefined;
} }
const handle = virtuosoRef.current; const handle = virtuosoRef.current;
if (!handle) { if (!handle) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.warn('[cuesheet-perf] virtuoso handle not ready.'); console.warn('[cuesheet-perf] virtuoso handle not ready.');
return; return undefined;
} }
const { fromIndex = 0, toIndex = 200, stride = 8, stepMs = 16 } = options; const { fromIndex = 0, toIndex = 200, stride = 8, stepMs = 16 } = options;
@@ -48,6 +48,7 @@ export async function runScrollBenchmark(
stopFpsMonitor(); stopFpsMonitor();
endSession(); endSession();
dump(); dump();
return snapshot();
} }
function scrollThrough( function scrollThrough(
@@ -80,9 +80,21 @@ export function recordFrame(deltaMs: number): void {
} }
} }
/** Dumps the collected metrics to the console as tables. */ export interface PerfSnapshot {
export function dump(): void { summary: {
const markRows = Object.entries(state.marks) 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]) => ({ .map(([name, m]) => ({
mark: name, mark: name,
count: m.count, count: m.count,
@@ -95,19 +107,26 @@ export function dump(): void {
const avgFps = state.totalFrameMs > 0 ? round((state.frameCount * 1000) / state.totalFrameMs) : 0; const avgFps = state.totalFrameMs > 0 ? round((state.frameCount * 1000) / state.totalFrameMs) : 0;
const minFps = state.maxFrameMs > 0 ? round(1000 / state.maxFrameMs) : 0; const minFps = state.maxFrameMs > 0 ? round(1000 / state.maxFrameMs) : 0;
const summary = { return {
rowsMounted: state.rowsMounted, summary: {
frames: state.frameCount, rowsMounted: state.rowsMounted,
longFrames: state.longFrames, frames: state.frameCount,
avgFps, longFrames: state.longFrames,
minFps, avgFps,
maxFrameMs: round(state.maxFrameMs), 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 */ /* eslint-disable no-console */
console.group('[cuesheet-perf] benchmark result'); console.group('[cuesheet-perf] benchmark result');
console.table(summary); console.table(summary);
console.table(markRows); console.table(marks);
console.groupEnd(); console.groupEnd();
/* eslint-enable no-console */ /* eslint-enable no-console */
} }
@@ -13,7 +13,7 @@ import type { TableVirtuosoHandle } from 'react-virtuoso';
import { runScrollBenchmark, type BenchmarkOptions } from './benchmark'; import { runScrollBenchmark, type BenchmarkOptions } from './benchmark';
import { PERF_ENABLED } from './perfConfig'; import { PERF_ENABLED } from './perfConfig';
import { dump, endSession, reset, startSession } from './perfStore'; import { dump, endSession, reset, snapshot, startSession } from './perfStore';
import { startFpsMonitor, stopFpsMonitor } from './scrollFpsMonitor'; import { startFpsMonitor, stopFpsMonitor } from './scrollFpsMonitor';
export function useCuesheetPerf(virtuosoRef: RefObject<TableVirtuosoHandle | null>): void { export function useCuesheetPerf(virtuosoRef: RefObject<TableVirtuosoHandle | null>): void {
@@ -33,6 +33,7 @@ export function useCuesheetPerf(virtuosoRef: RefObject<TableVirtuosoHandle | nul
}, },
dump, dump,
reset, reset,
getResults: snapshot,
}; };
(window as unknown as { __cuesheetPerf?: typeof api }).__cuesheetPerf = api; (window as unknown as { __cuesheetPerf?: typeof api }).__cuesheetPerf = api;