perf(cuesheet): lazily mount text-cell editors (Exp A) — validated win

Render a lightweight, focusable TextLikeInput for cue/title/note/custom cells
and only mount the real input/textarea editor (with reactive-input hooks and
autosize) on click/focus, mirroring the existing time/duration cells.

Measured baseline vs Exp A at CPU 4x (median of 3, ~1140 row mounts):
- longFrames 201 -> 127 (-37%), maxFrameMs 517 -> 417 (-19%)
- longtask total 58.9s -> 30.7s (-48%), EventRow.mount avg 179 -> 146ms (-18%)
- cell.autosize and cell.reactiveInputInit eliminated from the scroll path
- per-row re-renders 4648 -> 3048 (-34%): stable display height, no virtuoso churn
  (the opposite of the reverted lazy-autosize Exp B, which doubled re-renders)

Editing verified end-to-end: click mounts+focuses the editor, edits persist,
editor unmounts back to the display on commit. TextLikeInput now fills its cell
so the whole cell is clickable to edit.
This commit is contained in:
Claude
2026-06-16 05:32:11 +00:00
parent 7498abb326
commit c960a983a0
5 changed files with 132 additions and 12 deletions
@@ -0,0 +1,86 @@
import { memo, useCallback, useEffect, useRef, useState } from 'react';
import MultiLineCell from './MultiLineCell';
import SingleLineCell from './SingleLineCell';
import TextLikeInput from './TextLikeInput';
interface EditableCellProps {
initialValue: string;
multiline?: boolean;
fieldId?: string;
fieldLabel?: string;
handleUpdate: (newValue: string) => void;
}
interface FocusableEditor {
focus: () => void;
select?: () => void;
}
/**
* Lazily mounts the text editor for a cell.
*
* Mounting an `<input>`/`<textarea>` editor (with its reactive-input hooks and autosize) for every
* cell is expensive when many rows mount at once during virtualised scroll. While the cell is not
* being edited we render a lightweight, focusable display element and only mount the real editor
* when the user clicks/focuses the cell — mirroring how the time/duration cells already behave.
*/
function EditableCell({ initialValue, multiline, fieldId, fieldLabel, handleUpdate }: EditableCellProps) {
const [isEditing, setIsEditing] = useState(false);
const editorRef = useRef<FocusableEditor | null>(null);
// focus the editor once it mounts on entering edit mode
useEffect(() => {
if (isEditing) {
editorRef.current?.focus();
editorRef.current?.select?.();
}
}, [isEditing]);
const enterEdit = useCallback(() => setIsEditing(true), []);
const onSubmit = useCallback(
(newValue: string) => {
setIsEditing(false);
handleUpdate(newValue);
},
[handleUpdate],
);
const onCancel = useCallback(() => setIsEditing(false), []);
if (!isEditing) {
return (
<TextLikeInput
onClick={enterEdit}
onFocus={enterEdit}
multiline={multiline}
aria-label={fieldLabel ? `${fieldLabel} cell` : undefined}
>
{initialValue}
</TextLikeInput>
);
}
return multiline ? (
<MultiLineCell
ref={editorRef}
initialValue={initialValue}
fieldId={fieldId}
fieldLabel={fieldLabel}
handleUpdate={onSubmit}
handleCancelUpdate={onCancel}
/>
) : (
<SingleLineCell
ref={editorRef}
initialValue={initialValue}
fieldId={fieldId}
fieldLabel={fieldLabel}
handleUpdate={onSubmit}
handleCancelUpdate={onCancel}
/>
);
}
export default memo(EditableCell);
@@ -1,4 +1,4 @@
import { memo, useCallback, useRef } from 'react';
import { forwardRef, memo, useCallback, useImperativeHandle, useRef } from 'react';
import { AutoTextarea } from '../../../../common/components/input/auto-textarea/AutoTextarea';
import useReactiveTextInput from '../../../../common/components/input/text-input/useReactiveTextInput';
@@ -8,19 +8,36 @@ interface MultiLineCellProps {
fieldId?: string;
fieldLabel?: string;
handleUpdate: (newValue: string) => void;
handleCancelUpdate?: () => void;
}
export default memo(MultiLineCell);
function MultiLineCell({ initialValue, fieldId, fieldLabel, handleUpdate }: MultiLineCellProps) {
const MultiLineCell = forwardRef(function MultiLineCell(
{ initialValue, fieldId, fieldLabel, handleUpdate, handleCancelUpdate }: MultiLineCellProps,
inputRef,
) {
const ref = useRef<HTMLTextAreaElement | null>(null);
const submitCallback = useCallback((newValue: string) => handleUpdate(newValue), [handleUpdate]);
const { value, onChange, onBlur, onKeyDown } = useReactiveTextInput(initialValue, submitCallback, ref, {
submitOnCtrlEnter: true,
allowKeyboardNavigation: true,
onCancelUpdate: handleCancelUpdate,
});
// expose focus to the parent so the editor can be focused when mounted on demand
useImperativeHandle(
inputRef,
() => ({
focus() {
ref.current?.focus();
},
select() {
ref.current?.select();
},
}),
[ref],
);
return (
<AutoTextarea
inputref={ref}
@@ -36,4 +53,6 @@ function MultiLineCell({ initialValue, fieldId, fieldLabel, handleUpdate }: Mult
aria-label={fieldLabel ? `${fieldLabel} editor` : undefined}
/>
);
}
});
export default memo(MultiLineCell);
@@ -1,6 +1,8 @@
/* element matching input styles */
.textInput {
height: 2rem;
width: 100%;
height: 100%;
min-height: 2rem;
background-color: transparent;
border-radius: $component-border-radius-md;
text-wrap: nowrap;
@@ -32,4 +34,14 @@
background-color: $gray-1100;
cursor: text;
}
&.multiline {
height: auto;
min-height: 2rem;
text-wrap: wrap;
white-space: break-spaces;
overflow: hidden;
align-items: flex-start;
padding-top: 0.25em;
}
}
@@ -8,11 +8,12 @@ interface TextLikeInputProps extends HTMLAttributes<HTMLSpanElement> {
offset?: 'over' | 'under' | 'muted' | null;
muted?: boolean;
disabled?: boolean;
multiline?: boolean;
}
const TextLikeInput = forwardRef(
(
{ offset, muted, disabled, children, className, ...elementProps }: PropsWithChildren<TextLikeInputProps>,
{ offset, muted, disabled, multiline, children, className, ...elementProps }: PropsWithChildren<TextLikeInputProps>,
textRef,
) => {
const ref = useRef<HTMLDivElement | null>(null);
@@ -21,6 +22,7 @@ const TextLikeInput = forwardRef(
offset && style[offset],
muted && style.muted,
disabled && style.disabled,
multiline && style.multiline,
className,
]);
@@ -9,12 +9,11 @@ import { formatDuration, formatTime } from '../../../../common/utils/time';
import { AppMode } from '../../../../ontimeConfig';
import { getCuesheetColumnAccessPolicy } from '../../cuesheet.policies';
import DurationInput from './DurationInput';
import EditableCell from './EditableCell';
import EditableImage from './EditableImage';
import FlagCell from './FlagCell';
import GhostedText from './GhostedText';
import MultiLineCell from './MultiLineCell';
import MutedText from './MutedText';
import SingleLineCell from './SingleLineCell';
import TimeInput from './TimeInput';
function getColumnLabel(column: CellContext<ExtendedEntry, unknown>['column']): string {
@@ -151,7 +150,8 @@ function MakeMultiLineField({ row, column, table }: CellContext<ExtendedEntry, u
}
return (
<MultiLineCell
<EditableCell
multiline
initialValue={initialValue as string}
fieldId={column.id}
fieldLabel={getColumnLabel(column)}
@@ -198,7 +198,7 @@ function MakeSingleLineField({ row, column, table }: CellContext<ExtendedEntry,
}
return (
<SingleLineCell
<EditableCell
initialValue={initialValue as string}
fieldId={column.id}
fieldLabel={getColumnLabel(column)}
@@ -238,7 +238,8 @@ function MakeCustomField({ row, column, table }: CellContext<ExtendedEntry, unkn
}
return (
<MultiLineCell
<EditableCell
multiline
initialValue={initialValue}
fieldId={column.id}
fieldLabel={getColumnLabel(column)}