mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-21 23:19:09 +00:00
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:
committed by
Carlos Valente
parent
2e9ee698e9
commit
cd8e014f08
+10
-22
@@ -1,4 +1,4 @@
|
|||||||
import { RefObject, useMemo } from 'react';
|
import { RefObject, useEffect } from 'react';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { RowModel, Table } from '@tanstack/react-table';
|
import { RowModel, Table } from '@tanstack/react-table';
|
||||||
import { isOntimeBlock, isOntimeDelay, isOntimeEvent, OntimeBlock, OntimeEntry, Rundown } from 'ontime-types';
|
import { isOntimeBlock, isOntimeDelay, isOntimeEvent, OntimeBlock, OntimeEntry, Rundown } from 'ontime-types';
|
||||||
@@ -13,7 +13,7 @@ import { usePersistedCuesheetOptions } from '../../cuesheet.options';
|
|||||||
import BlockRow from './BlockRow';
|
import BlockRow from './BlockRow';
|
||||||
import DelayRow from './DelayRow';
|
import DelayRow from './DelayRow';
|
||||||
import EventRow from './EventRow';
|
import EventRow from './EventRow';
|
||||||
import { useVisibleRowsStore } from './visibleRowsStore';
|
import { cleanup } from './rowObserver';
|
||||||
|
|
||||||
interface CuesheetBodyProps {
|
interface CuesheetBodyProps {
|
||||||
rowModel: RowModel<OntimeEntry>;
|
rowModel: RowModel<OntimeEntry>;
|
||||||
@@ -27,25 +27,6 @@ export default function CuesheetBody({ rowModel, selectedRef, table }: CuesheetB
|
|||||||
const hidePast = usePersistedCuesheetOptions((state) => state.hidePast);
|
const hidePast = usePersistedCuesheetOptions((state) => state.hidePast);
|
||||||
const hideDelays = usePersistedCuesheetOptions((state) => state.hideDelays);
|
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 getVisibleColumns = lazyEvaluate(() => table.getVisibleFlatColumns());
|
||||||
const getColumnHash = lazyEvaluate(() => {
|
const getColumnHash = lazyEvaluate(() => {
|
||||||
let columnHash = '';
|
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
|
// for the first event, it will be past if there is something selected
|
||||||
let isPast = Boolean(selectedEventId);
|
let isPast = Boolean(selectedEventId);
|
||||||
let hadBlock = false;
|
let hadBlock = false;
|
||||||
|
|
||||||
|
// remove the observer when the table unmounts
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
cleanup();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tbody>
|
<tbody>
|
||||||
{rowModel.rows.map((row, index) => {
|
{rowModel.rows.map((row, index) => {
|
||||||
@@ -150,7 +139,6 @@ export default function CuesheetBody({ rowModel, selectedRef, table }: CuesheetB
|
|||||||
table={table}
|
table={table}
|
||||||
firstAfterBlock={firstAfterBlock}
|
firstAfterBlock={firstAfterBlock}
|
||||||
columnHash={columnHash}
|
columnHash={columnHash}
|
||||||
observer={observer}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-11
@@ -1,4 +1,4 @@
|
|||||||
import { MutableRefObject, useLayoutEffect, useRef } from 'react';
|
import { MutableRefObject, useEffect, useRef } from 'react';
|
||||||
import { IoEllipsisHorizontal } from 'react-icons/io5';
|
import { IoEllipsisHorizontal } from 'react-icons/io5';
|
||||||
import { flexRender, Table } from '@tanstack/react-table';
|
import { flexRender, Table } from '@tanstack/react-table';
|
||||||
import { OntimeEntry, OntimeEvent, RGBColour } from 'ontime-types';
|
import { OntimeEntry, OntimeEvent, RGBColour } from 'ontime-types';
|
||||||
@@ -9,6 +9,7 @@ import { cx, getAccessibleColour } from '../../../../common/utils/styleUtils';
|
|||||||
import { usePersistedCuesheetOptions } from '../../cuesheet.options';
|
import { usePersistedCuesheetOptions } from '../../cuesheet.options';
|
||||||
import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu';
|
import { useCuesheetTableMenu } from '../cuesheet-table-menu/useCuesheetTableMenu';
|
||||||
|
|
||||||
|
import { observeRow, unobserveRow } from './rowObserver';
|
||||||
import { useVisibleRowsStore } from './visibleRowsStore';
|
import { useVisibleRowsStore } from './visibleRowsStore';
|
||||||
|
|
||||||
import style from './EventRow.module.scss';
|
import style from './EventRow.module.scss';
|
||||||
@@ -27,7 +28,6 @@ interface EventRowProps {
|
|||||||
table: Table<OntimeEntry>;
|
table: Table<OntimeEntry>;
|
||||||
/** hack to force re-rendering of the row when the column sizes change */
|
/** hack to force re-rendering of the row when the column sizes change */
|
||||||
columnHash: string;
|
columnHash: string;
|
||||||
observer: IntersectionObserver;
|
|
||||||
firstAfterBlock: boolean;
|
firstAfterBlock: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,7 +41,6 @@ export default function EventRow({
|
|||||||
rowBgColour,
|
rowBgColour,
|
||||||
parentBgColour,
|
parentBgColour,
|
||||||
table,
|
table,
|
||||||
observer,
|
|
||||||
firstAfterBlock,
|
firstAfterBlock,
|
||||||
}: EventRowProps) {
|
}: EventRowProps) {
|
||||||
const hideIndexColumn = usePersistedCuesheetOptions((state) => state.hideIndexColumn);
|
const hideIndexColumn = usePersistedCuesheetOptions((state) => state.hideIndexColumn);
|
||||||
@@ -52,19 +51,20 @@ export default function EventRow({
|
|||||||
|
|
||||||
const openMenu = useCuesheetTableMenu((store) => store.openMenu);
|
const openMenu = useCuesheetTableMenu((store) => store.openMenu);
|
||||||
|
|
||||||
// store a reference of the row in the observer
|
// register this row with the intersection observer
|
||||||
useLayoutEffect(() => {
|
useEffect(() => {
|
||||||
const handleRefCurrent = ownRef.current;
|
const element = ownRef.current;
|
||||||
if (handleRefCurrent) {
|
if (element) {
|
||||||
observer.observe(handleRefCurrent);
|
element.id = rowId;
|
||||||
|
observeRow(element);
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (handleRefCurrent) {
|
if (element) {
|
||||||
observer.unobserve(handleRefCurrent);
|
unobserveRow(element);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [observer]);
|
}, [rowId]);
|
||||||
|
|
||||||
const { color, backgroundColor } = getAccessibleColour(event.colour);
|
const { color, backgroundColor } = getAccessibleColour(event.colour);
|
||||||
const tmpColour = cssOrHexToColour(color) as RGBColour; // we know this to be a correct 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user