/** * 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; type DataSourceProps = { /** * The data source to render */ dataSource: DataSource; /** * 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; virtualizerRef?: MutableRefObject; onRangeChange?( start: number, end: number, total: number, offset: number, ): void; onUpdateAutoScroll?(autoScroll: boolean): void; emptyRenderer?: | null | ((dataSource: DataSource) => 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: ( props: DataSourceProps, ) => React.ReactElement = memo(function DataSourceRendererVirtual({ dataSource, defaultRowHeight, useFixedRowHeight, context, itemRenderer, autoScroll, onKeyDown, virtualizerRef, onRangeChange, onUpdateAutoScroll, emptyRenderer, }: DataSourceProps) { /** * 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); 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], ), // TODO: optimise by using setting a keyExtractor if DataSource is keyed 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: any = 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 (
{virtualizer.virtualItems.length === 0 ? emptyRenderer?.(dataSource) : null}
{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 (
{itemRenderer(value, virtualRow.index, context)}
); })}
); }) as any; const tableContainerStyle = { overflowY: 'auto', overflowX: 'hidden', display: 'flex', // because: https://stackoverflow.com/questions/37386244/what-does-flex-1-mean flex: `1 1 0`, } as const; const tableWindowStyle = { position: 'relative', width: '100%', } as const; export const RedrawContext = createContext void)>(undefined); export function useTableRedraw() { return useContext(RedrawContext); } declare const process: any; function useInUnitTest(): boolean { // N.B. Not reusing flipper-common here, since data-source is published as separate package return typeof process !== 'undefined' && process?.env?.NODE_ENV === 'test'; }