Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Optimized in-memory datagrid mount performance #3628

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
);
};