refactor: optimize cuesheet IntersectionObserver

Decouples the IntersectionObserver from the React component tree to
prevent
unnecessary re-renders of the entire Cuesheet table when row visibility
changes. Restores rootMargin for better UX and memoizes EventRow.

- Introduced `rowObserver.ts` to manage a global IntersectionObserver.
  - Observer callback directly updates `useVisibleRowsStore`.
  - Configured with `rootMargin: '400px 0px'` and `threshold: 0.01`
    to pre-load and retain rows near the viewport, improving UX.
- `EventRow` components use `observeRow`/`unobserveRow` from
`rowObserver.ts`.
- `EventRow` is now wrapped in `React.memo` to prevent re-renders if
props
  haven't changed when its parent re-renders.
- Removed observer creation and prop-drilling from `CuesheetBody.tsx`.

This ensures only individual rows re-render content based on visibility
and optimizes row component rendering, improving performance and UX.
This commit is contained in:
google-labs-jules[bot]
2025-07-03 04:05:11 +00:00
committed by Carlos Valente
parent 2e9ee698e9
commit cd8e014f08
3 changed files with 73 additions and 33 deletions
@@ -1,4 +1,4 @@
import { RefObject, useMemo } from 'react';
import { RefObject, useEffect } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { RowModel, Table } from '@tanstack/react-table';
import { isOntimeBlock, isOntimeDelay, isOntimeEvent, OntimeBlock, OntimeEntry, Rundown } from 'ontime-types';
@@ -13,7 +13,7 @@ import { usePersistedCuesheetOptions } from '../../cuesheet.options';
import BlockRow from './BlockRow';
import DelayRow from './DelayRow';
import EventRow from './EventRow';
import { useVisibleRowsStore } from './visibleRowsStore';
import { cleanup } from './rowObserver';
interface CuesheetBodyProps {
rowModel: RowModel<OntimeEntry>;
@@ -27,25 +27,6 @@ export default function CuesheetBody({ rowModel, selectedRef, table }: CuesheetB
const hidePast = usePersistedCuesheetOptions((state) => state.hidePast);
const hideDelays = usePersistedCuesheetOptions((state) => state.hideDelays);
const { addVisibleRow, removeVisibleRow } = useVisibleRowsStore();
const observer = useMemo(
() =>
new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
addVisibleRow(entry.target.id);
} else {
removeVisibleRow(entry.target.id);
}
}
},
{ rootMargin: '400px' },
),
[addVisibleRow, removeVisibleRow],
);
const getVisibleColumns = lazyEvaluate(() => table.getVisibleFlatColumns());
const getColumnHash = lazyEvaluate(() => {
let columnHash = '';
@@ -61,6 +42,14 @@ export default function CuesheetBody({ rowModel, selectedRef, table }: CuesheetB
// for the first event, it will be past if there is something selected
let isPast = Boolean(selectedEventId);
let hadBlock = false;
// remove the observer when the table unmounts
useEffect(() => {
return () => {
cleanup();
};
}, []);
return (
<tbody>
{rowModel.rows.map((row, index) => {
@@ -150,7 +139,6 @@ export default function CuesheetBody({ rowModel, selectedRef, table }: CuesheetB
table={table}
firstAfterBlock={firstAfterBlock}
columnHash={columnHash}
observer={observer}
/>
);
}
@@ -1,4 +1,4 @@
import { MutableRefObject, useLayoutEffect, useRef } from 'react';
import { MutableRefObject, useEffect, useRef } from 'react';
import { IoEllipsisHorizontal } from 'react-icons/io5';
import { flexRender, Table } from '@tanstack/react-table';
import { OntimeEntry, OntimeEvent, RGBColour } from 'ontime-types';
@@ -9,6 +9,7 @@ import { cx, getAccessibleColour } from '../../../../common/utils/styleUtils';
import { usePersistedCuesheetOptions } from '../../cuesheet.options';
import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu';
import { observeRow, unobserveRow } from './rowObserver';
import { useVisibleRowsStore } from './visibleRowsStore';
import style from './EventRow.module.scss';
@@ -27,7 +28,6 @@ interface EventRowProps {
table: Table<OntimeEntry>;
/** hack to force re-rendering of the row when the column sizes change */
columnHash: string;
observer: IntersectionObserver;
firstAfterBlock: boolean;
}
@@ -41,7 +41,6 @@ export default function EventRow({
rowBgColour,
parentBgColour,
table,
observer,
firstAfterBlock,
}: EventRowProps) {
const hideIndexColumn = usePersistedCuesheetOptions((state) => state.hideIndexColumn);
@@ -52,19 +51,20 @@ export default function EventRow({
const openMenu = useCuesheetTableMenu((store) => store.openMenu);
// store a reference of the row in the observer
useLayoutEffect(() => {
const handleRefCurrent = ownRef.current;
if (handleRefCurrent) {
observer.observe(handleRefCurrent);
// register this row with the intersection observer
useEffect(() => {
const element = ownRef.current;
if (element) {
element.id = rowId;
observeRow(element);
}
return () => {
if (handleRefCurrent) {
observer.unobserve(handleRefCurrent);
if (element) {
unobserveRow(element);
}
};
}, [observer]);
}, [rowId]);
const { color, backgroundColor } = getAccessibleColour(event.colour);
const tmpColour = cssOrHexToColour(color) as RGBColour; // we know this to be a correct colour
@@ -0,0 +1,52 @@
import { useVisibleRowsStore } from './visibleRowsStore';
let observer: IntersectionObserver | null = null;
function getObserver(): IntersectionObserver {
if (!observer) {
const { addVisibleRow, removeVisibleRow } = useVisibleRowsStore.getState();
const options: IntersectionObserverInit = {
root: null,
rootMargin: '400px 0px', // prevent unmounting rows too early
threshold: 0.01,
};
const handleOnIntersect: IntersectionObserverCallback = (entries) => {
entries.forEach((entry) => {
const targetId = entry.target.id;
if (entry.isIntersecting) {
addVisibleRow(targetId);
} else {
removeVisibleRow(targetId);
}
});
};
observer = new IntersectionObserver(handleOnIntersect, options);
}
return observer;
}
/**
* register a row element in the observer
*/
export function observeRow(element: HTMLElement) {
getObserver().observe(element);
}
/**
* unregister a row element in the observer
*/
export function unobserveRow(element: HTMLElement) {
getObserver().unobserve(element);
}
/**
* cleanup observer, should be called when the table component unmounts
*/
export function cleanup() {
observer?.disconnect();
observer = null;
}