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
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 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) {
// when the value changes, we use the ref to reapply autosize
export function AutoTextarea({ value, inputref, onFocus, onBlur, ...textAreaProps }: AutoTextAreaProps) {
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(() => {
const node = inputref.current;
timeSync('cell.autosize', () => autosize(inputref.current)); // PERF-METRICS
return () => {
autosize.destroy(node);
};
if (node && document.activeElement === node) {
autosize(node);
}
}, [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 { PERF_ENABLED } from './perfConfig';
import { dump, endSession, startSession } from './perfStore';
import { dump, endSession, snapshot, startSession, type PerfSnapshot } from './perfStore';
import { startFpsMonitor, stopFpsMonitor } from './scrollFpsMonitor';
export interface BenchmarkOptions {
@@ -24,17 +24,17 @@ export interface BenchmarkOptions {
export async function runScrollBenchmark(
virtuosoRef: RefObject<TableVirtuosoHandle | null>,
options: BenchmarkOptions = {},
): Promise<void> {
): Promise<PerfSnapshot | undefined> {
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;
return undefined;
}
const handle = virtuosoRef.current;
if (!handle) {
// eslint-disable-next-line no-console
console.warn('[cuesheet-perf] virtuoso handle not ready.');
return;
return undefined;
}
const { fromIndex = 0, toIndex = 200, stride = 8, stepMs = 16 } = options;
@@ -48,6 +48,7 @@ export async function runScrollBenchmark(
stopFpsMonitor();
endSession();
dump();
return snapshot();
}
function scrollThrough(
@@ -80,9 +80,21 @@ export function recordFrame(deltaMs: number): void {
}
}
/** Dumps the collected metrics to the console as tables. */
export function dump(): void {
const markRows = Object.entries(state.marks)
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,
@@ -95,19 +107,26 @@ export function dump(): void {
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),
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(markRows);
console.table(marks);
console.groupEnd();
/* eslint-enable no-console */
}
@@ -13,7 +13,7 @@ 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 { dump, endSession, reset, snapshot, startSession } from './perfStore';
import { startFpsMonitor, stopFpsMonitor } from './scrollFpsMonitor';
export function useCuesheetPerf(virtuosoRef: RefObject<TableVirtuosoHandle | null>): void {
@@ -33,6 +33,7 @@ export function useCuesheetPerf(virtuosoRef: RefObject<TableVirtuosoHandle | nul
},
dump,
reset,
getResults: snapshot,
};
(window as unknown as { __cuesheetPerf?: typeof api }).__cuesheetPerf = api;