react-virtualized icon indicating copy to clipboard operation
react-virtualized copied to clipboard

InfiniteLoader jumps when scrolling up after loadMoreRows completes

Open AaronMcCloskey opened this issue 2 years ago • 2 comments

I have a react-virtualised InfiniteLoader consisting of single rows.

The main issue I believe, is that each cell can vary in height and have to load in different images for each so the height is not static and changes as the images load in. But I am still seeing the issue even when the all the cells are the exact same height.

This is my current component using react-virtualised InfiniteLoader with Grid

/* eslint-disable no-underscore-dangle */
import React, {
  FC,
  LegacyRef,
  useEffect,
  useLayoutEffect,
  useMemo,
  useRef
} from "react";
import {
  InfiniteLoader,
  Grid,
  SectionRenderedParams,
  AutoSizer,
  WindowScroller,
  GridCellProps,
  ColumnSizer,
  CellMeasurerCache,
  CellMeasurer,
  Index,
  InfiniteLoaderChildProps,
  WindowScrollerChildProps,
  Size,
  SizedColumnProps
} from "react-virtualized";
import { CellMeasurerChildProps } from "react-virtualized/dist/es/CellMeasurer";
import PuffLoader from "react-spinners/PuffLoader";
import useMeasure from "react-use-measure";
import { ResizeObserver } from "@juggle/resize-observer";
import styled from "styled-components";

const LOADER_SIZE = 100;

const LoaderWrapper = styled.div`
  width: calc(100% - ${LOADER_SIZE}px);
  text-align: center;
  height: ${LOADER_SIZE}px;
  margin: 15px 0px;
`;

interface InfiniteGridProps {
  items: any[] | undefined;
  defaultHeight?: number | undefined;
  loadMoreItems?: () => Promise<void>;
  totalResults?: number | undefined;
  overscanRowCount?: number;
  renderItem: (props: any, rowIndex: number) => React.ReactNode | undefined;
  preventScrollLoader?: boolean;
}

interface GridParent {
  _scrollingContainer?: any;
}

interface IGridCellProps extends GridCellProps {
  parent: GridCellProps["parent"] & GridParent;
}

interface InfiniteGridItemProps {
  renderItem: InfiniteGridProps["renderItem"];
  gridItem: any;
  reCalculateGrid: (
    rowIndex: IGridCellProps["rowIndex"],
    columnIndex: IGridCellProps["columnIndex"],
    measure: CellMeasurerChildProps["measure"]
  ) => void;
  rowIndex: IGridCellProps["rowIndex"];
  columnIndex: IGridCellProps["columnIndex"];
  parent: IGridCellProps["parent"];
  measure: CellMeasurerChildProps["measure"];
}

const InfiniteGridItem: React.FC<InfiniteGridItemProps> = ({
  renderItem,
  gridItem,
  reCalculateGrid,
  rowIndex,
  columnIndex,
  parent,
  measure
}) => {
  const [rowRef, { height }] = useMeasure({ polyfill: ResizeObserver });

  useLayoutEffect(() => {
    reCalculateGrid(
      rowIndex,
      columnIndex,
      parent._scrollingContainer ? measure : () => {}
    );
  }, [
    height,
    columnIndex,
    measure,
    parent._scrollingContainer,
    reCalculateGrid,
    rowIndex
  ]);

  return <div ref={rowRef}>{renderItem(gridItem, rowIndex)}</div>;
};

const InfiniteGrid: FC<InfiniteGridProps> = ({
  items,
  defaultHeight = 300,
  loadMoreItems,
  totalResults,
  overscanRowCount = 10,
  renderItem
}) => {
  const loaderRef = useRef<InfiniteLoader | undefined>();

  const cache = useMemo(
    () =>
      new CellMeasurerCache({
        fixedWidth: true,
        defaultHeight
      }),
    [defaultHeight]
  );

  const onResize = () => {
    cache.clearAll();
    if (loaderRef && loaderRef.current) {
      loaderRef.current.resetLoadMoreRowsCache(true);
    }
  };

  const reCalculateGrid = (
    rowIndex: IGridCellProps["rowIndex"],
    columnIndex: IGridCellProps["columnIndex"],
    measure: CellMeasurerChildProps["measure"]
  ) => {
    cache.clear(rowIndex, columnIndex);
    measure();
  };

  const isRowLoaded = ({ index }: Index) => {
    if (items && totalResults !== undefined) {
      const isLoaded = !!items[index] || totalResults <= items.length;
      return isLoaded;
    }
    return false;
  };

  const loadMoreRows = async () => {
    if (loadMoreItems) await loadMoreItems();
  };

  const cellRenderer = (
    { rowIndex, columnIndex, style, key, parent }: IGridCellProps,
    columnCount: number
  ) => {
    const index = rowIndex * columnCount + columnIndex;
    const gridItem = items?.[index];

    if (!gridItem || !renderItem) return null;

    return (
      <CellMeasurer
        key={key}
        cache={cache}
        parent={parent}
        columnIndex={columnIndex}
        rowIndex={rowIndex}
      >
        {({ registerChild, measure }: any) => (
          <div
            ref={registerChild}
            style={{
              ...style,
              overflow: "visible"
            }}
            key={key}
          >
            <InfiniteGridItem
              renderItem={renderItem}
              gridItem={gridItem}
              reCalculateGrid={reCalculateGrid}
              rowIndex={rowIndex}
              columnIndex={columnIndex}
              parent={parent}
              measure={measure}
            />
          </div>
        )}
      </CellMeasurer>
    );
  };

  useEffect(() => {
    cache.clearAll();
    if (loaderRef && loaderRef.current) {
      loaderRef.current.resetLoadMoreRowsCache(true);
    }
  }, [loaderRef, cache, items]);

  const infiniteLoaderRender = () => (
    <WindowScroller>
      {({
        height,
        onChildScroll,
        scrollTop,
        registerChild
      }: WindowScrollerChildProps) => (
        <div ref={registerChild}>
          <InfiniteLoader
            isRowLoaded={isRowLoaded}
            loadMoreRows={loadMoreRows}
            rowCount={totalResults}
            threshold={1}
            ref={loaderRef as LegacyRef<InfiniteLoader> | undefined}
          >
            {({ onRowsRendered }: InfiniteLoaderChildProps) => (
              <AutoSizer disableHeight onResize={onResize}>
                {({ width }: Size) => {
                  const columnCount = Math.max(Math.floor(width / width), 1);
                  return (
                    <ColumnSizer width={width} columnCount={columnCount}>
                      {({ registerChild: rg }: SizedColumnProps) =>
                        loaderRef && loaderRef.current ? (
                          <Grid
                            autoHeight
                            width={width}
                            height={height}
                            scrollTop={scrollTop}
                            ref={rg}
                            overscanRowCount={overscanRowCount}
                            scrollingResetTimeInterval={0}
                            onScroll={onChildScroll}
                            columnWidth={Math.floor(width / columnCount)}
                            columnCount={columnCount}
                            rowCount={Math.ceil(
                              (!items ? overscanRowCount : items?.length) /
                                columnCount
                            )}
                            rowHeight={cache.rowHeight}
                            cellRenderer={(gridCellProps: GridCellProps) =>
                              cellRenderer(gridCellProps, columnCount)
                            }
                            onSectionRendered={({
                              rowStartIndex,
                              rowStopIndex,
                              columnStartIndex,
                              columnStopIndex
                            }: SectionRenderedParams) => {
                              const startIndex =
                                rowStartIndex * columnCount + columnStartIndex;
                              const stopIndex =
                                rowStopIndex * columnCount + columnStopIndex;
                              return onRowsRendered({ startIndex, stopIndex });
                            }}
                          />
                        ) : null
                      }
                    </ColumnSizer>
                  );
                }}
              </AutoSizer>
            )}
          </InfiniteLoader>
        </div>
      )}
    </WindowScroller>
  );

  const shouldRenderLoader =
    !(items && items.length === totalResults) &&
    loadMoreItems &&
    items &&
    items.length > 0;

  const renderBottom = () => {
    if (shouldRenderLoader)
      return (
        <LoaderWrapper>
          <PuffLoader color={"#000"} size={LOADER_SIZE} />
        </LoaderWrapper>
      );
    return null;
  };

  return (
    <>
      {infiniteLoaderRender()}
      {renderBottom()}
    </>
  );
};

export default InfiniteGrid;

As you can see from this video, when you scroll to the bottom, then attempt to scroll up, it shifts wildly. It should only move up a few pixels but jumps a few more pixels than I'd expect.

https://user-images.githubusercontent.com/20436343/169559360-71b21a58-eb49-43c4-ba35-189ecea6e305.mov

This is just before I scroll Just before scrolling

And this is immediately after scrolling up just a few pixels on my mouse wheel enter image description here

Notice how Test 752596 is close to the bottom and with the scroll, I'd expect it just be a little higher on the screen but a whole other item seems to appear when I would not expect it to. It's around the 8 second mark in the video and seems a lot more obvious there.

Here's a CodeSandbox that replicates the issue

Is there something I can do to make this smoother?

AaronMcCloskey avatar May 20 '22 15:05 AaronMcCloskey

The more you scroll down and back up again, the worse the jumping gets.

AaronMcCloskey avatar May 23 '22 09:05 AaronMcCloskey

any solution?

mugavri avatar Aug 20 '23 21:08 mugavri