mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-13 19:33:46 +00:00
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:
@@ -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), []);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user