Skip to content

Commit

Permalink
Optimized in-memory datagrid mount performance (#3628)
Browse files Browse the repository at this point in the history
* Optimized in-memory datagrid mount performance

* ie11 fix
  • Loading branch information
chandlerprall authored Jun 18, 2020
1 parent 67c747f commit 5318c2f
Show file tree
Hide file tree
Showing 2 changed files with 126 additions and 104 deletions.
52 changes: 35 additions & 17 deletions src/components/datagrid/data_grid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<EuiDataGridInMemoryValues>({});
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];
Expand Down
178 changes: 91 additions & 87 deletions src/components/datagrid/data_grid_inmemory_renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@
*/

import React, {
Fragment,
FunctionComponent,
JSXElementConstructor,
useEffect,
useCallback,
useMemo,
useState,
} from 'react';
Expand All @@ -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() {}
Expand All @@ -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<HTMLDivElement | null>();

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 (
<div ref={setRef}>
<CellElement
rowIndex={i}
columnId={column.id}
setCellProps={noop}
isExpandable={isExpandable}
isExpanded={false}
isDetails={false}
/>
</div>
);
};

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(
<Fragment key={i}>
{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 (
<ObservedCell
key={column.id}
i={i}
renderCellValue={renderCellValue}
column={column}
onCellRender={onCellRender}
cells.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 (
<div
key={`${i}-${column.id}`}
data-dg-row={i}
data-dg-column={column.id}>
<CellElement
rowIndex={i}
columnId={column.id}
setCellProps={noop}
isExpandable={isExpandable}
isExpanded={false}
isDetails={false}
/>
);
})
.filter(cell => cell != null)}
</Fragment>
</div>
);
})
.filter(cell => cell != null)
);
}

return rows;
}, [rowCount, columns, inMemory.skipColumns, renderCellValue, onCellRender]);
return cells;
}, [rowCount, columns, inMemory.skipColumns, renderCellValue]);

const onMutation = useCallback<MutationCallback>(
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(
<Fragment>{rows}</Fragment>,
<EuiMutationObserver
onMutation={onMutation}
observerOptions={{
characterData: true,
subtree: true,
attributes: true,
childList: true,
}}>
{ref => <div ref={ref}>{cells}</div>}
</EuiMutationObserver>,
(documentFragment as unknown) as Element
);
};

0 comments on commit 5318c2f

Please sign in to comment.