Table optimizations
Summary: Performance fine tuning. Did some performance fine-tuning primarily by creating a production build, and verifying the responsiveness of searching, tailing etc in the logs plugin while generating a lot of load, and finetuned based on that. For example stopped using requestAnimationFrame which is too sensitive of starving Flipper under high load, as it doesn't leave room for other events to be processed. Also made scrolling smoother by making an append 'high prio' update while taililng. Also debounced changing the (search) filters, as that is an expensive operation we don't want to trigger on every key press Reviewed By: passy Differential Revision: D27046726 fbshipit-source-id: c3efe59eb26e2d9e518325d85531a0e4a6b245ca
This commit is contained in:
committed by
Facebook GitHub Bot
parent
6a30899803
commit
de92495f04
@@ -21,7 +21,9 @@ import {useVirtual} from 'react-virtual';
|
|||||||
import styled from '@emotion/styled';
|
import styled from '@emotion/styled';
|
||||||
|
|
||||||
// how fast we update if updates are low-prio (e.g. out of window and not super significant)
|
// how fast we update if updates are low-prio (e.g. out of window and not super significant)
|
||||||
const DEBOUNCE = 500; //ms
|
const LOW_PRIO_UPDATE = 1000; //ms
|
||||||
|
const HIGH_PRIO_UPDATE = 40; // 25fps
|
||||||
|
const SMALL_DATASET = 1000; // what we consider a small dataset, for which we keep all updates snappy
|
||||||
|
|
||||||
enum UpdatePrio {
|
enum UpdatePrio {
|
||||||
NONE,
|
NONE,
|
||||||
@@ -115,6 +117,7 @@ export const DataSourceRenderer: <T extends object, C>(
|
|||||||
if (unmounted) {
|
if (unmounted) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
timeoutHandle = undefined;
|
||||||
setForceUpdate((x) => x + 1);
|
setForceUpdate((x) => x + 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -135,16 +138,24 @@ export const DataSourceRenderer: <T extends object, C>(
|
|||||||
// already scheduled an update with equal or higher prio
|
// already scheduled an update with equal or higher prio
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
renderPending.current = prio;
|
renderPending.current = Math.max(renderPending.current, prio);
|
||||||
if (prio === UpdatePrio.LOW) {
|
if (prio === UpdatePrio.LOW) {
|
||||||
// TODO: make DEBOUNCE depend on how big the relative change is
|
// Possible optimization: make DEBOUNCE depend on how big the relative change is, and how far from the current window
|
||||||
timeoutHandle = setTimeout(forceUpdate, DEBOUNCE);
|
if (!timeoutHandle) {
|
||||||
|
timeoutHandle = setTimeout(forceUpdate, LOW_PRIO_UPDATE);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// High
|
// High, drop low prio timeout
|
||||||
if (timeoutHandle) {
|
if (timeoutHandle) {
|
||||||
clearTimeout(timeoutHandle);
|
clearTimeout(timeoutHandle);
|
||||||
|
timeoutHandle = undefined;
|
||||||
|
}
|
||||||
|
if (lastRender.current < Date.now() - HIGH_PRIO_UPDATE) {
|
||||||
|
forceUpdate(); // trigger render now
|
||||||
|
} else {
|
||||||
|
// debounced
|
||||||
|
timeoutHandle = setTimeout(forceUpdate, HIGH_PRIO_UPDATE);
|
||||||
}
|
}
|
||||||
requestAnimationFrame(forceUpdate);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,7 +165,15 @@ export const DataSourceRenderer: <T extends object, C>(
|
|||||||
rerender(UpdatePrio.HIGH, true);
|
rerender(UpdatePrio.HIGH, true);
|
||||||
break;
|
break;
|
||||||
case 'shift':
|
case 'shift':
|
||||||
if (event.location === 'in') {
|
if (dataSource.view.size < SMALL_DATASET) {
|
||||||
|
rerender(UpdatePrio.HIGH, false);
|
||||||
|
} else if (
|
||||||
|
event.location === 'in' ||
|
||||||
|
// to support smooth tailing we want to render on records directly at the end of the window immediately as well
|
||||||
|
(event.location === 'after' &&
|
||||||
|
event.delta > 0 &&
|
||||||
|
event.index === dataSource.view.windowEnd)
|
||||||
|
) {
|
||||||
rerender(UpdatePrio.HIGH, false);
|
rerender(UpdatePrio.HIGH, false);
|
||||||
} else {
|
} else {
|
||||||
// optimization: we don't want to listen to every count change, especially after window
|
// optimization: we don't want to listen to every count change, especially after window
|
||||||
@@ -221,7 +240,7 @@ export const DataSourceRenderer: <T extends object, C>(
|
|||||||
}, [autoScroll, parentRef]);
|
}, [autoScroll, parentRef]);
|
||||||
|
|
||||||
useLayoutEffect(function scrollToEnd() {
|
useLayoutEffect(function scrollToEnd() {
|
||||||
if (followOutput.current) {
|
if (followOutput.current && autoScroll) {
|
||||||
virtualizer.scrollToIndex(
|
virtualizer.scrollToIndex(
|
||||||
dataSource.view.size - 1,
|
dataSource.view.size - 1,
|
||||||
/* smooth is not typed by react-virtual, but passed on to the DOM as it should*/
|
/* smooth is not typed by react-virtual, but passed on to the DOM as it should*/
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import React, {
|
|||||||
MutableRefObject,
|
MutableRefObject,
|
||||||
CSSProperties,
|
CSSProperties,
|
||||||
useEffect,
|
useEffect,
|
||||||
useContext,
|
|
||||||
useReducer,
|
useReducer,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import {TableRow, DEFAULT_ROW_HEIGHT} from './TableRow';
|
import {TableRow, DEFAULT_ROW_HEIGHT} from './TableRow';
|
||||||
@@ -46,6 +45,7 @@ import {CoffeeOutlined, SearchOutlined} from '@ant-design/icons';
|
|||||||
import {useAssertStableRef} from '../../utils/useAssertStableRef';
|
import {useAssertStableRef} from '../../utils/useAssertStableRef';
|
||||||
import {Formatter} from '../DataFormatter';
|
import {Formatter} from '../DataFormatter';
|
||||||
import {usePluginInstance} from '../../plugin/PluginContext';
|
import {usePluginInstance} from '../../plugin/PluginContext';
|
||||||
|
import {debounce} from 'lodash';
|
||||||
|
|
||||||
interface DataTableProps<T = any> {
|
interface DataTableProps<T = any> {
|
||||||
columns: DataTableColumn<T>[];
|
columns: DataTableColumn<T>[];
|
||||||
@@ -105,7 +105,7 @@ export function DataTable<T extends object>(
|
|||||||
// eslint-disable-next-line
|
// eslint-disable-next-line
|
||||||
const scope = props._testHeight ? "" : usePluginInstance().pluginKey;
|
const scope = props._testHeight ? "" : usePluginInstance().pluginKey;
|
||||||
const virtualizerRef = useRef<DataSourceVirtualizer | undefined>();
|
const virtualizerRef = useRef<DataSourceVirtualizer | undefined>();
|
||||||
const [state, dispatch] = useReducer(
|
const [tableState, dispatch] = useReducer(
|
||||||
dataTableManagerReducer as DataTableReducer<T>,
|
dataTableManagerReducer as DataTableReducer<T>,
|
||||||
undefined,
|
undefined,
|
||||||
() =>
|
() =>
|
||||||
@@ -118,15 +118,16 @@ export function DataTable<T extends object>(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const stateRef = useRef(state);
|
const stateRef = useRef(tableState);
|
||||||
stateRef.current = state;
|
stateRef.current = tableState;
|
||||||
const lastOffset = useRef(0);
|
const lastOffset = useRef(0);
|
||||||
|
const dragging = useRef(false);
|
||||||
|
|
||||||
const [tableManager] = useState(() =>
|
const [tableManager] = useState(() =>
|
||||||
createDataTableManager(dataSource, dispatch, stateRef),
|
createDataTableManager(dataSource, dispatch, stateRef),
|
||||||
);
|
);
|
||||||
|
|
||||||
const {columns, selection, searchValue, sorting} = state;
|
const {columns, selection, searchValue, sorting} = tableState;
|
||||||
|
|
||||||
const visibleColumns = useMemo(
|
const visibleColumns = useMemo(
|
||||||
() => columns.filter((column) => column.visible),
|
() => columns.filter((column) => column.visible),
|
||||||
@@ -134,18 +135,17 @@ export function DataTable<T extends object>(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const renderingConfig = useMemo<RenderContext<T>>(() => {
|
const renderingConfig = useMemo<RenderContext<T>>(() => {
|
||||||
let dragging = false;
|
|
||||||
let startIndex = 0;
|
let startIndex = 0;
|
||||||
return {
|
return {
|
||||||
columns: visibleColumns,
|
columns: visibleColumns,
|
||||||
onMouseEnter(_e, _item, index) {
|
onMouseEnter(e, _item, index) {
|
||||||
if (dragging) {
|
if (dragging.current && e.buttons === 1) {
|
||||||
// by computing range we make sure no intermediate items are missed when scrolling fast
|
// by computing range we make sure no intermediate items are missed when scrolling fast
|
||||||
tableManager.addRangeToSelection(startIndex, index);
|
tableManager.addRangeToSelection(startIndex, index);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onMouseDown(e, _item, index) {
|
onMouseDown(e, _item, index) {
|
||||||
if (!dragging) {
|
if (!dragging.current) {
|
||||||
if (e.ctrlKey || e.metaKey) {
|
if (e.ctrlKey || e.metaKey) {
|
||||||
tableManager.addRangeToSelection(index, index, true);
|
tableManager.addRangeToSelection(index, index, true);
|
||||||
} else if (e.shiftKey) {
|
} else if (e.shiftKey) {
|
||||||
@@ -154,11 +154,11 @@ export function DataTable<T extends object>(
|
|||||||
tableManager.selectItem(index);
|
tableManager.selectItem(index);
|
||||||
}
|
}
|
||||||
|
|
||||||
dragging = true;
|
dragging.current = true;
|
||||||
startIndex = index;
|
startIndex = index;
|
||||||
|
|
||||||
function onStopDragSelecting() {
|
function onStopDragSelecting() {
|
||||||
dragging = false;
|
dragging.current = false;
|
||||||
document.removeEventListener('mouseup', onStopDragSelecting);
|
document.removeEventListener('mouseup', onStopDragSelecting);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,6 +231,9 @@ export function DataTable<T extends object>(
|
|||||||
shiftPressed,
|
shiftPressed,
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
case 'Escape':
|
||||||
|
tableManager.clearSelection();
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
handled = false;
|
handled = false;
|
||||||
}
|
}
|
||||||
@@ -242,48 +245,54 @@ export function DataTable<T extends object>(
|
|||||||
[dataSource, tableManager],
|
[dataSource, tableManager],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const [debouncedSetFilter] = useState(() => {
|
||||||
|
// we don't want to trigger filter changes too quickly, as they can be pretty expensive
|
||||||
|
// and would block the user from entering text in the search bar for example
|
||||||
|
// (and in the future would really benefit from concurrent mode here :))
|
||||||
|
const setFilter = (search: string, columns: DataTableColumn<T>[]) => {
|
||||||
|
dataSource.view.setFilter(computeDataTableFilter(search, columns));
|
||||||
|
};
|
||||||
|
return props._testHeight ? setFilter : debounce(setFilter, 250);
|
||||||
|
});
|
||||||
useEffect(
|
useEffect(
|
||||||
function updateFilter() {
|
function updateFilter() {
|
||||||
dataSource.view.setFilter(
|
debouncedSetFilter(tableState.searchValue, tableState.columns);
|
||||||
computeDataTableFilter(state.searchValue, state.columns),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
// Important dep optimization: we don't want to recalc filters if just the width or visibility changes!
|
// Important dep optimization: we don't want to recalc filters if just the width or visibility changes!
|
||||||
// We pass entire state.columns to computeDataTableFilter, but only changes in the filter are a valid cause to compute a new filter function
|
// We pass entire state.columns to computeDataTableFilter, but only changes in the filter are a valid cause to compute a new filter function
|
||||||
// eslint-disable-next-line
|
// eslint-disable-next-line
|
||||||
[state.searchValue, ...state.columns.map((c) => c.filters)],
|
[tableState.searchValue, ...tableState.columns.map((c) => c.filters)],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
function updateSorting() {
|
function updateSorting() {
|
||||||
if (state.sorting === undefined) {
|
if (tableState.sorting === undefined) {
|
||||||
dataSource.view.setSortBy(undefined);
|
dataSource.view.setSortBy(undefined);
|
||||||
dataSource.view.setReversed(false);
|
dataSource.view.setReversed(false);
|
||||||
} else {
|
} else {
|
||||||
dataSource.view.setSortBy(state.sorting.key);
|
dataSource.view.setSortBy(tableState.sorting.key);
|
||||||
dataSource.view.setReversed(state.sorting.direction === 'desc');
|
dataSource.view.setReversed(tableState.sorting.direction === 'desc');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[dataSource, state.sorting],
|
[dataSource, tableState.sorting],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
function triggerSelection() {
|
function triggerSelection() {
|
||||||
onSelect?.(
|
onSelect?.(
|
||||||
getSelectedItem(dataSource, state.selection),
|
getSelectedItem(dataSource, tableState.selection),
|
||||||
getSelectedItems(dataSource, state.selection),
|
getSelectedItems(dataSource, tableState.selection),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[onSelect, dataSource, state.selection],
|
[onSelect, dataSource, tableState.selection],
|
||||||
);
|
);
|
||||||
|
|
||||||
// The initialScrollPosition is used to both capture the initial px we want to scroll to,
|
// The initialScrollPosition is used to both capture the initial px we want to scroll to,
|
||||||
// and whether we performed that scrolling already (if so, it will be 0)
|
// and whether we performed that scrolling already (if so, it will be 0)
|
||||||
// const initialScrollPosition = useRef(scrollOffset.current);
|
|
||||||
useLayoutEffect(
|
useLayoutEffect(
|
||||||
function scrollSelectionIntoView() {
|
function scrollSelectionIntoView() {
|
||||||
if (state.initialOffset) {
|
if (tableState.initialOffset) {
|
||||||
virtualizerRef.current?.scrollToOffset(state.initialOffset);
|
virtualizerRef.current?.scrollToOffset(tableState.initialOffset);
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'appliedInitialScroll',
|
type: 'appliedInitialScroll',
|
||||||
});
|
});
|
||||||
@@ -305,7 +314,6 @@ export function DataTable<T extends object>(
|
|||||||
|
|
||||||
const onRangeChange = useCallback(
|
const onRangeChange = useCallback(
|
||||||
(start: number, end: number, total: number, offset) => {
|
(start: number, end: number, total: number, offset) => {
|
||||||
// TODO: figure out if we don't trigger this callback to often hurting perf
|
|
||||||
setRange(`${start} - ${end} / ${total}`);
|
setRange(`${start} - ${end} / ${total}`);
|
||||||
lastOffset.current = offset;
|
lastOffset.current = offset;
|
||||||
clearTimeout(hideRange.current!);
|
clearTimeout(hideRange.current!);
|
||||||
@@ -326,10 +334,10 @@ export function DataTable<T extends object>(
|
|||||||
dataSource,
|
dataSource,
|
||||||
dispatch,
|
dispatch,
|
||||||
selection,
|
selection,
|
||||||
state.columns,
|
tableState.columns,
|
||||||
visibleColumns,
|
visibleColumns,
|
||||||
),
|
),
|
||||||
[dataSource, dispatch, selection, state.columns, visibleColumns],
|
[dataSource, dispatch, selection, tableState.columns, visibleColumns],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(function initialSetup() {
|
useEffect(function initialSetup() {
|
||||||
@@ -371,8 +379,8 @@ export function DataTable<T extends object>(
|
|||||||
</Layout.Container>
|
</Layout.Container>
|
||||||
<DataSourceRenderer<T, RenderContext<T>>
|
<DataSourceRenderer<T, RenderContext<T>>
|
||||||
dataSource={dataSource}
|
dataSource={dataSource}
|
||||||
autoScroll={props.autoScroll}
|
autoScroll={props.autoScroll && !dragging.current}
|
||||||
useFixedRowHeight={!state.usesWrapping}
|
useFixedRowHeight={!tableState.usesWrapping}
|
||||||
defaultRowHeight={DEFAULT_ROW_HEIGHT}
|
defaultRowHeight={DEFAULT_ROW_HEIGHT}
|
||||||
context={renderingConfig}
|
context={renderingConfig}
|
||||||
itemRenderer={itemRenderer}
|
itemRenderer={itemRenderer}
|
||||||
|
|||||||
Reference in New Issue
Block a user