Reorganise for easier extraction
Summary: To make the DataSource abstraction reusable for other teams and an upcoming talk, this diff moves all DataSource storage & virtualization logic in one folder. Will set up a build process and demo project in later diffs. Reviewed By: nikoant Differential Revision: D28056700 fbshipit-source-id: 7cfe5b40bbbe387da711f765a604a45029d451c7
This commit is contained in:
committed by
Facebook GitHub Bot
parent
5a7d4e2f17
commit
84e2646909
@@ -0,0 +1,344 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import React, {
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useLayoutEffect,
|
||||
MutableRefObject,
|
||||
useContext,
|
||||
createContext,
|
||||
} from 'react';
|
||||
import {DataSource} from './DataSource';
|
||||
import {useVirtual} from 'react-virtual';
|
||||
import observeRect from '@reach/observe-rect';
|
||||
|
||||
// how fast we update if updates are low-prio (e.g. out of window and not super significant)
|
||||
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 {
|
||||
NONE,
|
||||
LOW,
|
||||
HIGH,
|
||||
}
|
||||
|
||||
export type DataSourceVirtualizer = ReturnType<typeof useVirtual>;
|
||||
|
||||
type DataSourceProps<T extends object, C> = {
|
||||
/**
|
||||
* The data source to render
|
||||
*/
|
||||
dataSource: DataSource<T, any, any>;
|
||||
/**
|
||||
* Automatically scroll if the user is near the end?
|
||||
*/
|
||||
autoScroll?: boolean;
|
||||
/**
|
||||
* additional context that will be passed verbatim to the itemRenderer, so that it can be easily memoized
|
||||
*/
|
||||
context?: C;
|
||||
/**
|
||||
* Takes care of rendering an item
|
||||
* @param item The item as stored in the dataSource
|
||||
* @param index The index of the item being rendered. The index represents the offset in the _visible_ items of the dataSource
|
||||
* @param context The optional context passed into this DataSourceRenderer
|
||||
*/
|
||||
itemRenderer(item: T, index: number, context: C): React.ReactElement;
|
||||
useFixedRowHeight: boolean;
|
||||
defaultRowHeight: number;
|
||||
onKeyDown?: React.KeyboardEventHandler<HTMLDivElement>;
|
||||
virtualizerRef?: MutableRefObject<DataSourceVirtualizer | undefined>;
|
||||
onRangeChange?(
|
||||
start: number,
|
||||
end: number,
|
||||
total: number,
|
||||
offset: number,
|
||||
): void;
|
||||
onUpdateAutoScroll?(autoScroll: boolean): void;
|
||||
emptyRenderer?: null | ((dataSource: DataSource<T>) => React.ReactElement);
|
||||
};
|
||||
|
||||
/**
|
||||
* This component is UI agnostic, and just takes care of virtualizing the provided dataSource, and render it as efficiently a possibible,
|
||||
* de priorizing off screen updates etc.
|
||||
*/
|
||||
export const DataSourceRendererVirtual: <T extends object, C>(
|
||||
props: DataSourceProps<T, C>,
|
||||
) => React.ReactElement = memo(function DataSourceRendererVirtual({
|
||||
dataSource,
|
||||
defaultRowHeight,
|
||||
useFixedRowHeight,
|
||||
context,
|
||||
itemRenderer,
|
||||
autoScroll,
|
||||
onKeyDown,
|
||||
virtualizerRef,
|
||||
onRangeChange,
|
||||
onUpdateAutoScroll,
|
||||
emptyRenderer,
|
||||
}: DataSourceProps<any, any>) {
|
||||
/**
|
||||
* Virtualization
|
||||
*/
|
||||
// render scheduling
|
||||
const renderPending = useRef(UpdatePrio.NONE);
|
||||
const lastRender = useRef(Date.now());
|
||||
const [, setForceUpdate] = useState(0);
|
||||
const forceHeightRecalculation = useRef(0);
|
||||
const parentRef = React.useRef<null | HTMLDivElement>(null);
|
||||
const isUnitTest = useInUnitTest();
|
||||
|
||||
const virtualizer = useVirtual({
|
||||
size: dataSource.view.size,
|
||||
parentRef,
|
||||
useObserver: isUnitTest ? () => ({height: 500, width: 1000}) : undefined,
|
||||
// eslint-disable-next-line
|
||||
estimateSize: useCallback(() => defaultRowHeight, [forceHeightRecalculation.current, defaultRowHeight]),
|
||||
overscan: 0,
|
||||
});
|
||||
if (virtualizerRef) {
|
||||
virtualizerRef.current = virtualizer;
|
||||
}
|
||||
|
||||
const redraw = useCallback(() => {
|
||||
forceHeightRecalculation.current++;
|
||||
setForceUpdate((x) => x + 1);
|
||||
}, []);
|
||||
|
||||
useEffect(
|
||||
function subscribeToDataSource() {
|
||||
const forceUpdate = () => {
|
||||
if (unmounted) {
|
||||
return;
|
||||
}
|
||||
timeoutHandle = undefined;
|
||||
setForceUpdate((x) => x + 1);
|
||||
};
|
||||
|
||||
let unmounted = false;
|
||||
let timeoutHandle: NodeJS.Timeout | undefined = undefined;
|
||||
|
||||
function rerender(prio: 1 | 2, invalidateHeights = false) {
|
||||
if (invalidateHeights && !useFixedRowHeight) {
|
||||
// the height of some existing rows might have changed
|
||||
forceHeightRecalculation.current++;
|
||||
}
|
||||
if (isUnitTest) {
|
||||
// test environment, update immediately
|
||||
forceUpdate();
|
||||
return;
|
||||
}
|
||||
if (renderPending.current >= prio) {
|
||||
// already scheduled an update with equal or higher prio
|
||||
return;
|
||||
}
|
||||
renderPending.current = Math.max(renderPending.current, prio);
|
||||
if (prio === UpdatePrio.LOW) {
|
||||
// Possible optimization: make DEBOUNCE depend on how big the relative change is, and how far from the current window
|
||||
if (!timeoutHandle) {
|
||||
timeoutHandle = setTimeout(forceUpdate, LOW_PRIO_UPDATE);
|
||||
}
|
||||
} else {
|
||||
// High, drop low prio timeout
|
||||
if (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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dataSource.view.setListener((event) => {
|
||||
switch (event.type) {
|
||||
case 'reset':
|
||||
rerender(UpdatePrio.HIGH, true);
|
||||
break;
|
||||
case 'shift':
|
||||
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);
|
||||
} else {
|
||||
// optimization: we don't want to listen to every count change, especially after window
|
||||
// and in some cases before window
|
||||
rerender(UpdatePrio.LOW, false);
|
||||
}
|
||||
break;
|
||||
case 'update':
|
||||
// in visible range, so let's force update
|
||||
rerender(UpdatePrio.HIGH, true);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unmounted = true;
|
||||
dataSource.view.setListener(undefined);
|
||||
};
|
||||
},
|
||||
[dataSource, setForceUpdate, useFixedRowHeight, isUnitTest],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// initial virtualization is incorrect because the parent ref is not yet set, so trigger render after mount
|
||||
setForceUpdate((x) => x + 1);
|
||||
}, [setForceUpdate]);
|
||||
|
||||
useLayoutEffect(function updateWindow() {
|
||||
const start = virtualizer.virtualItems[0]?.index ?? 0;
|
||||
const end = start + virtualizer.virtualItems.length;
|
||||
if (start !== dataSource.view.windowStart && !autoScroll) {
|
||||
onRangeChange?.(
|
||||
start,
|
||||
end,
|
||||
dataSource.view.size,
|
||||
parentRef.current?.scrollTop ?? 0,
|
||||
);
|
||||
}
|
||||
dataSource.view.setWindow(start, end);
|
||||
});
|
||||
|
||||
/**
|
||||
* Scrolling
|
||||
*/
|
||||
const onScroll = useCallback(() => {
|
||||
const elem = parentRef.current;
|
||||
if (!elem) {
|
||||
return;
|
||||
}
|
||||
const fromEnd = elem.scrollHeight - elem.scrollTop - elem.clientHeight;
|
||||
if (autoScroll && fromEnd > 1) {
|
||||
onUpdateAutoScroll?.(false);
|
||||
} else if (!autoScroll && fromEnd < 1) {
|
||||
onUpdateAutoScroll?.(true);
|
||||
}
|
||||
}, [onUpdateAutoScroll, autoScroll]);
|
||||
|
||||
useLayoutEffect(function scrollToEnd() {
|
||||
if (autoScroll) {
|
||||
virtualizer.scrollToIndex(
|
||||
dataSource.view.size - 1,
|
||||
/* smooth is not typed by react-virtual, but passed on to the DOM as it should*/
|
||||
{
|
||||
align: 'end',
|
||||
behavior: 'smooth',
|
||||
} as any,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Render finalization
|
||||
*/
|
||||
useEffect(function renderCompleted() {
|
||||
renderPending.current = UpdatePrio.NONE;
|
||||
lastRender.current = Date.now();
|
||||
});
|
||||
|
||||
/**
|
||||
* Observer parent height
|
||||
*/
|
||||
useEffect(
|
||||
function redrawOnResize() {
|
||||
if (!parentRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
let lastWidth = 0;
|
||||
const observer = observeRect(parentRef.current, (rect) => {
|
||||
if (lastWidth !== rect.width) {
|
||||
lastWidth = rect.width;
|
||||
redraw();
|
||||
}
|
||||
});
|
||||
observer.observe();
|
||||
return () => observer.unobserve();
|
||||
},
|
||||
[redraw],
|
||||
);
|
||||
|
||||
/**
|
||||
* Rendering
|
||||
*/
|
||||
return (
|
||||
<RedrawContext.Provider value={redraw}>
|
||||
<div ref={parentRef} onScroll={onScroll} style={tableContainerStyle}>
|
||||
{virtualizer.virtualItems.length === 0
|
||||
? emptyRenderer?.(dataSource)
|
||||
: null}
|
||||
<div
|
||||
style={{
|
||||
...tableWindowStyle,
|
||||
height: virtualizer.totalSize,
|
||||
}}
|
||||
onKeyDown={onKeyDown}
|
||||
tabIndex={0}>
|
||||
{virtualizer.virtualItems.map((virtualRow) => {
|
||||
const value = dataSource.view.get(virtualRow.index);
|
||||
// the position properties always change, so they are not part of the TableRow to avoid invalidating the memoized render always.
|
||||
// Also all row containers are renderd as part of same component to have 'less react' framework code in between*/}
|
||||
return (
|
||||
<div
|
||||
key={virtualRow.index}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: useFixedRowHeight ? virtualRow.size : undefined,
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
ref={useFixedRowHeight ? undefined : virtualRow.measureRef}>
|
||||
{itemRenderer(value, virtualRow.index, context)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</RedrawContext.Provider>
|
||||
);
|
||||
}) as any;
|
||||
|
||||
const tableContainerStyle = {
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
} as const;
|
||||
|
||||
const tableWindowStyle = {
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
} as const;
|
||||
|
||||
export const RedrawContext = createContext<undefined | (() => void)>(undefined);
|
||||
|
||||
export function useTableRedraw() {
|
||||
return useContext(RedrawContext);
|
||||
}
|
||||
|
||||
function useInUnitTest(): boolean {
|
||||
return process.env.NODE_ENV === 'test';
|
||||
}
|
||||
Reference in New Issue
Block a user