diff --git a/src/components/datagrid/data_grid.tsx b/src/components/datagrid/data_grid.tsx index cab0d8cfdc1..ce5d1c6be35 100644 --- a/src/components/datagrid/data_grid.tsx +++ b/src/components/datagrid/data_grid.tsx @@ -379,28 +379,46 @@ function useInMemoryValues( rowCount: number ): [ EuiDataGridInMemoryValues, - (rowIndex: number, column: EuiDataGridColumn, value: string) => void + (rowIndex: number, columnId: string, value: string) => void ] { - const [inMemoryValues, setInMemoryValues] = useState< - EuiDataGridInMemoryValues - >({}); - - const onCellRender = useCallback( - (rowIndex, column, value) => { - setInMemoryValues(inMemoryValues => { - const nextInMemoryValues = { ...inMemoryValues }; - nextInMemoryValues[rowIndex] = nextInMemoryValues[rowIndex] || {}; - nextInMemoryValues[rowIndex][column.id] = value; - return nextInMemoryValues; - }); - }, - [setInMemoryValues] - ); + /** + * For performance, `onCellRender` below mutates the inMemoryValues object + * instead of cloning. If this operation were done in a setState call + * React would ignore the update as the object itself has not changed. + * So, we keep a dual record: the in-memory values themselves and a "version" counter. + * When the object is mutated, the version is incremented triggering a re-render, and + * the returned `inMemoryValues` object is re-created (cloned) from the mutated version. + * The version updates are batched, so only one clone happens per batch. + **/ + const _inMemoryValues = useRef({}); + const [inMemoryValuesVersion, setInMemoryValuesVersion] = useState(0); + + // eslint-disable-next-line react-hooks/exhaustive-deps + const inMemoryValues = useMemo(() => ({ ..._inMemoryValues.current }), [ + inMemoryValuesVersion, + ]); + + const onCellRender = useCallback((rowIndex, columnId, value) => { + const nextInMemoryValues = _inMemoryValues.current; + nextInMemoryValues[rowIndex] = nextInMemoryValues[rowIndex] || {}; + nextInMemoryValues[rowIndex][columnId] = value; + setInMemoryValuesVersion(version => version + 1); + }, []); // if `inMemory.level` or `rowCount` changes reset the values const inMemoryLevel = inMemory && inMemory.level; + const resetRunCount = useRef(0); useEffect(() => { - setInMemoryValues({}); + if (resetRunCount.current++ > 0) { + // this has to delete "overflow" keys from the object instead of resetting to an empty one, + // as the internal inmemoryrenderer component's useEffect which sets the values + // exectues before this outer, wrapping useEffect + const existingRowKeyCount = Object.keys(_inMemoryValues.current).length; + for (let i = rowCount; i < existingRowKeyCount; i++) { + delete _inMemoryValues.current[i]; + } + setInMemoryValuesVersion(version => version + 1); + } }, [inMemoryLevel, rowCount]); return [inMemoryValues, onCellRender]; diff --git a/src/components/datagrid/data_grid_inmemory_renderer.tsx b/src/components/datagrid/data_grid_inmemory_renderer.tsx index 01d0d0c25c4..a1b54ca059b 100644 --- a/src/components/datagrid/data_grid_inmemory_renderer.tsx +++ b/src/components/datagrid/data_grid_inmemory_renderer.tsx @@ -18,10 +18,10 @@ */ import React, { - Fragment, FunctionComponent, JSXElementConstructor, useEffect, + useCallback, useMemo, useState, } from 'react'; @@ -32,17 +32,14 @@ import { } from './data_grid_cell'; import { EuiDataGridColumn, EuiDataGridInMemory } from './data_grid_types'; import { enqueueStateChange } from '../../services/react'; +import { EuiMutationObserver } from '../observer/mutation_observer'; export interface EuiDataGridInMemoryRendererProps { inMemory: EuiDataGridInMemory; columns: EuiDataGridColumn[]; rowCount: number; renderCellValue: EuiDataGridCellProps['renderCellValue']; - onCellRender: ( - rowIndex: number, - column: EuiDataGridColumn, - value: string - ) => void; + onCellRender: (rowIndex: number, columnId: string, value: string) => void; } function noop() {} @@ -55,101 +52,108 @@ function getElementText(element: HTMLElement) { element.textContent || undefined; } -const ObservedCell: FunctionComponent<{ - renderCellValue: EuiDataGridInMemoryRendererProps['renderCellValue']; - onCellRender: EuiDataGridInMemoryRendererProps['onCellRender']; - i: number; - column: EuiDataGridColumn; - isExpandable: boolean; -}> = ({ renderCellValue, i, column, onCellRender, isExpandable }) => { - const [ref, setRef] = useState(); - - useEffect(() => { - if (ref) { - // this is part of React's component lifecycle, onCellRender->setState are automatically batched - onCellRender(i, column, getElementText(ref)); - const observer = new MutationObserver(() => { - // onMutation callbacks aren't in the component lifecycle, intentionally batch any effects - enqueueStateChange( - onCellRender.bind(null, i, column, getElementText(ref)) - ); - }); - observer.observe(ref, { - characterData: true, - subtree: true, - attributes: true, - childList: true, - }); - - return () => { - observer.disconnect(); - }; - } - }, [column, i, onCellRender, ref]); - - const CellElement = renderCellValue as JSXElementConstructor< - EuiDataGridCellValueElementProps - >; - - return ( -
- -
- ); -}; - export const EuiDataGridInMemoryRenderer: FunctionComponent< EuiDataGridInMemoryRendererProps > = ({ inMemory, columns, rowCount, renderCellValue, onCellRender }) => { const [documentFragment] = useState(() => document.createDocumentFragment()); - const rows = useMemo(() => { - const rows = []; + const cells = useMemo(() => { + const CellElement = renderCellValue as JSXElementConstructor< + EuiDataGridCellValueElementProps + >; + + const cells = []; for (let i = 0; i < rowCount; i++) { - rows.push( - - {columns - .map(column => { - const skipThisColumn = - inMemory.skipColumns && - inMemory.skipColumns.indexOf(column.id) !== -1; - - if (skipThisColumn) { - return null; - } - - const isExpandable = - column.isExpandable !== undefined ? column.isExpandable : true; - - return ( - { + const skipThisColumn = + inMemory.skipColumns && + inMemory.skipColumns.indexOf(column.id) !== -1; + + if (skipThisColumn) { + return null; + } + + const isExpandable = + column.isExpandable !== undefined ? column.isExpandable : true; + + return ( +
+ - ); - }) - .filter(cell => cell != null)} - +
+ ); + }) + .filter(cell => cell != null) ); } - return rows; - }, [rowCount, columns, inMemory.skipColumns, renderCellValue, onCellRender]); + return cells; + }, [rowCount, columns, inMemory.skipColumns, renderCellValue]); + + const onMutation = useCallback( + records => { + recordLoop: for (let i = 0; i < records.length; i++) { + const record = records[i]; + let target: Node | null = record.target; + + while (true) { + if (target == null) continue recordLoop; // somehow hit the document fragment + if ( + target.nodeType === Node.ELEMENT_NODE && + (target as Element).hasAttribute('data-dg-row') + ) { + // target is the cell wrapping div + break; + } + target = target.parentElement; + } + + const cellDiv = target as HTMLDivElement; + const rowIndex = parseInt(cellDiv.getAttribute('data-dg-row')!, 10); + const column = cellDiv.getAttribute('data-dg-column')!; + enqueueStateChange(() => + onCellRender(rowIndex, column, getElementText(cellDiv)) + ); + } + }, + [onCellRender] + ); + + useEffect(() => { + const cellDivs = documentFragment.childNodes[0].childNodes; + for (let i = 0; i < cellDivs.length; i++) { + const cellDiv = cellDivs[i] as HTMLDivElement; + const rowIndex = parseInt(cellDiv.getAttribute('data-dg-row')!, 10); + const column = cellDiv.getAttribute('data-dg-column')!; + onCellRender(rowIndex, column, getElementText(cellDiv)); + } + // changes to documentFragment.children is reflected by `cells` + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [onCellRender, cells]); return createPortal( - {rows}, + + {ref =>
{cells}
} +
, (documentFragment as unknown) as Element ); };