refactor(teleprompter): let the compiler memoise the view

The client runs babel-plugin-react-compiler in annotation mode, so 'use memo'
is the opt in and the codebase already uses it in eighteen places. The
teleprompter was the odd one out, hand rolling what the compiler does better.

Teleprompter and ScriptBlock now carry the directive, which replaces two
useMemo calls, four useCallback calls and a memo wrapper. Verified against the
compiler rather than assumed: the two files emit 46 and 15 memo cache slots
respectively, finer grained than the six the hand written version had.

The scroll engine and the key handler deliberately keep no directive. They
derive nothing and render nothing, they drive an animation frame loop through
refs, so there is nothing to memoise and no reason to put them through the
compiler.

Also fixes a hazard the conversion surfaced. The follow effect listed the
blocks array in its dependencies, but memoisation is a performance hint React
is free to discard, under the compiler as much as by hand. A rebuilt but
otherwise identical list would re-fire the jump to the loaded event and yank
the script back mid read. It now watches whether the loaded event has a block
at all, which is the condition it actually cared about and does not depend on
identity.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cb8RVPNQ2ETPJxdy4b8CHf
This commit is contained in:
Claude
2026-08-13 19:19:20 +00:00
parent e9289685fd
commit 23a184244c
3 changed files with 38 additions and 42 deletions
@@ -1,5 +1,5 @@
import { OntimeView } from 'ontime-types';
import { type CSSProperties, useCallback, useMemo, useState } from 'react';
import { type CSSProperties, useState } from 'react';
import EmptyPage from '../../common/components/state/EmptyPage';
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
@@ -38,6 +38,8 @@ export default function TeleprompterLoader() {
}
function Teleprompter({ rundown, rundownMetadata, customFields }: TeleprompterData) {
'use memo';
const options = useTeleprompterOptions();
const selectedEventId = useSelectedEventId();
// the shared "Flip Screen" toggle from the navigation menu
@@ -48,28 +50,15 @@ function Teleprompter({ rundown, rundownMetadata, customFields }: TeleprompterDa
const [flipV, setFlipV] = useState(options.flipV);
const [showHelp, setShowHelp] = useState(false);
const viewOptions = useMemo(() => getTeleprompterOptions(customFields), [customFields]);
const viewOptions = getTeleprompterOptions(customFields);
const blocks = useMemo(
() =>
buildScript(rundown, rundownMetadata, customFields, {
scriptSource: options.scriptSource,
heading: options.heading,
hideEmpty: options.hideEmpty,
hidePast: options.hidePast,
showGroups: options.showGroups,
}),
[
rundown,
rundownMetadata,
customFields,
options.scriptSource,
options.heading,
options.hideEmpty,
options.hidePast,
options.showGroups,
],
);
const blocks = buildScript(rundown, rundownMetadata, customFields, {
scriptSource: options.scriptSource,
heading: options.heading,
hideEmpty: options.hideEmpty,
hidePast: options.hidePast,
showGroups: options.showGroups,
});
const {
scrollerRef,
@@ -90,20 +79,17 @@ function Teleprompter({ rundown, rundownMetadata, customFields }: TeleprompterDa
blocks,
});
const handleFlip = useCallback((axis: 'h' | 'v') => {
const handleFlip = (axis: 'h' | 'v') => {
if (axis === 'h') {
setFlipH((current) => !current);
} else {
setFlipV((current) => !current);
}
}, []);
};
const handleFontSize = useCallback((delta: number) => {
setFontScale((current) => clampFontScale(current + delta));
}, []);
const handleResetFontSize = useCallback(() => setFontScale(1), []);
const handleToggleHelp = useCallback(() => setShowHelp((current) => !current), []);
const handleFontSize = (delta: number) => setFontScale((current) => clampFontScale(current + delta));
const handleResetFontSize = () => setFontScale(1);
const handleToggleHelp = () => setShowHelp((current) => !current);
useTeleprompterControls({
controller,
@@ -1,5 +1,3 @@
import { memo, useCallback } from 'react';
import type { ScriptBlock } from '../teleprompter.types';
interface ScriptBlockProps {
@@ -7,16 +5,18 @@ interface ScriptBlockProps {
registerRef: (id: string, element: HTMLElement | null) => void;
}
export default memo(ScriptBlockView);
function ScriptBlockView({ block, registerRef }: ScriptBlockProps) {
// the id is bound here rather than by the parent so that the callback stays
// stable: React re-runs a ref callback whenever its identity changes, which
// would unregister and re-register every block on every parent render
const setRef = useCallback((element: HTMLElement | null) => registerRef(block.id, element), [block.id, registerRef]);
export default function ScriptBlockView({ block, registerRef }: ScriptBlockProps) {
'use memo';
return (
<section className='teleprompter__block' ref={setRef} data-loaded={block.isLoaded || undefined}>
// the id is bound here rather than by the parent so the compiler can keep
// the callback stable: React re-runs a ref callback whenever its identity
// changes, which would unregister every block on every parent render
<section
className='teleprompter__block'
ref={(element) => registerRef(block.id, element)}
data-loaded={block.isLoaded || undefined}
>
{block.groupTitle && <div className='teleprompter__group'>{block.groupTitle}</div>}
{block.heading && <h2 className='teleprompter__heading'>{block.heading}</h2>}
{/* the script is user data, it is rendered as text and never as markup */}
@@ -25,7 +25,7 @@ interface UseTeleprompterScrollArgs {
selectedEventId: string | null;
/** percentage from the top of the screen */
readingLinePos: number;
/** the rendered document, watched so a follow can retarget once blocks remount */
/** the rendered document, watched so a follow can retarget once its block exists */
blocks: ScriptBlock[];
}
@@ -234,6 +234,16 @@ export function useTeleprompterScroll({
// eslint-disable-next-line react-hooks/exhaustive-deps -- autoplay is a starting condition, not a live one
}, []);
/**
* Whether the loaded event currently has a block to scroll to.
*
* The follow watches this rather than the blocks array, because memoisation is
* a performance hint that React is free to discard: keying off array identity
* would let a rebuilt but otherwise identical list re-fire the jump and yank
* the script back while it is being read.
*/
const hasSelectedBlock = selectedEventId !== null && blocks.some((block) => block.id === selectedEventId);
// follow the loaded event
useEffect(() => {
if (!followLoaded || followLocked || !selectedEventId) return;
@@ -250,7 +260,7 @@ export function useTeleprompterScroll({
catchUpTargetRef.current = clamp(top, 0, Math.max(maxScrollRef.current, 0));
setAtEnd(false);
ensureLoop();
}, [selectedEventId, followLoaded, followLocked, readingLinePos, blocks, ensureLoop]);
}, [selectedEventId, followLoaded, followLocked, readingLinePos, hasSelectedBlock, ensureLoop]);
const lockFollow = useMemo(() => throttle(() => setFollowLocked(true), FOLLOW_LOCK_THROTTLE), []);