mcclosa
mcclosa

Reputation: 1455

InfiniteLoader jumps when scrolling up after loadMoreRows completes

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;

And 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.

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?

Upvotes: 3

Views: 1487

Answers (1)

Ambroise Rabier
Ambroise Rabier

Reputation: 4082

1. Images

I get an improvement when I delete <img src={image} alt="test" />. I see that in the network tab, the images get reloaded when going up. If you look at infinite scrolls like twitter or reddit, the content above get partially unloaded, but the layout stays. So that it doesn't mess up document height.

That mean, once you loaded the image, you should set the image container size to the size of the image, so that when the image is unloaded, the layout stays the same above scroll position.

2. CSS

Careful with putting height: 500px; max-height: 500px, this won't be enough to fix the height, if you have padding or margin on your list elements, this will impact the list itself. Example: padding 1000px on image, will make your list element bigger, even if you put your list element height to 400px. Purely in CSS this can -somewhat- be fixed with overflow: hidden, but all this may mess up calculations.

It is kinda the same with margin, there is a place where you put margin: 50px auto, two div above, the height of the div is bigger than the colored rectangle you see on the view.

enter image description here

enter image description here

enter image description here

enter image description here

3. useEffect

Each time I have bump when scrolling down, I see "clap" being logged. Suspicious.

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

4. Note on reloading codesandbox

Also, for anyone use codesandbox, make sure to reload the page, no just the iframe, otherwise you get errors like : Children cannot be added or removed during a reorder operation..

5. Difficult scroll up

When I scroll up, I sometimes get slightly pushed back to the bottom. Maybe something is being loaded upside scroll bar and change document height ?

6. Unecessary re-render

Also you should avoid using this pattern:

const infiniteLoaderRender = () => (<span/>)

return (
  {infiniteLoaderRender()}
)

Simplify, and avoid unecesary re-render:

const infiniteLoaderRender = <span/>

return (
  {infiniteLoaderRender}
)

This does seem to improve the scrolling a lot. But not sure it fixe it.

7. Difficult to reproduce your issue

If you can, instead of populating the list with random elements, make a fixed list version, so that we can reproduce the bug easily.

Upvotes: 1

Related Questions