3

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?

mcclosa
  • 943
  • 7
  • 29
  • 59
  • The more you scroll down and back up again, the worse the jumping gets. – mcclosa May 23 '22 at 09:51
  • I suspect it might not be the reason but it could help a bit if you assign width and height to the image elements. – David May 27 '22 at 14:17
  • It looks like new content is also added if you scroll up, so the scroll event has to be filtered before adding new content – David May 28 '22 at 04:40
  • Furthermore there is something with childScroll, I think you never need it and should remove it perhaps – David May 28 '22 at 04:45
  • @David I think the adding of new content as you scroll up in intentional, and the reason that I used this library. If I load a 1000 items, I don't want a 1000 items in the dom, I only want what's in view to be rendered. It's the same way that the likes of Twitter works. Otherwise you'd find the site to be very slow having all the elements in the DOM. I will have a look at the childScroll though. – mcclosa May 30 '22 at 09:40
  • on twitter scrolling up with something like `InfiniteLoader` makes perfectly sense because you never start at the root usually. You in contrast start at the root and scrolling up after scrolling down never should change anything. Btw. on twitter nothing is changed too as long as you scroll within posts that have been shown in the current view already. So actually your argument is interesting but not applying to your case. – David May 30 '22 at 10:01

1 Answers1

1

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.

Ambroise Rabier
  • 3,636
  • 3
  • 27
  • 38
  • 1
    Thanks for this. In my actual example (not codesandbox) I do have a fixed height on the images. But even when I removed the images, like in my screenshot, it was still jumping. As for the CSS section, what would you recommend for creating the space between each item if not for the margin? I will maybe look at making another codesandbox with the exact issue, I think I just wanted to show the extremes of the issue. – mcclosa May 30 '22 at 09:36
  • @mcclosa Using margin and padding is ok. The issue is not realizing it may put off calculations. Usually the padding is contained inside the background (visually). And margin/padding of inside list element should not impact something outside that list element (see my screenshots). You can use margin between list element, put that margin should be on the top container of the list element (because you want to express space between list element). And make sure that if that top element has no margin, then the list element have no space between them. If they do, you have somewhat un-proper css. – Ambroise Rabier May 30 '22 at 15:34
  • @mcclosa I can have another look, if you make another codesandbox with constants list elements (ping me). Also, just to be clear, what I meant by image height, if that the height of the list element should stay the same with or without an image. Whenever you scroll down, previous html elements get unloaded it seems, the image get unloaded too, meaning when you start scrolling upward, the image get loaded back, the image being there or not change the list element height. (just try out by deleting the image). – Ambroise Rabier May 30 '22 at 16:26