TanStack / virtual

🤖 Headless UI for Virtualizing Large Element Lists in JS/TS, React, Solid, Vue and Svelte

Home Page:https://tanstack.com/virtual

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Scrolling up with dynamic heights stutters and jumps

nconfrey opened this issue · comments

Describe the bug

I have a feed of dynamic items, like iframes, photos, and text. When I scroll downward, everything works great and the scrolling is smooth, as measured items that increase in height push the other items down out of sight. However, when I scroll upwards, the performance is super stuttery and the items jump all over the place:

Untitled.mov

Your minimal, reproducible example

Code below:

Steps to reproduce

Create the virtualizer as normal:

const rowVirtualizer = useVirtualizer({
    count: itemCount,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 100,
    overscan: 2
  })

Then the feed:

const mainFeed = () => {
    return (
      <div
        style={{
          height: `${rowVirtualizer.getTotalSize()}px`,
          width: '100%',
          position: 'relative',
        }}
      >
        {rowVirtualizer.getVirtualItems().map((virtualRow) => {
          const post = loadedPosts[virtualRow.index]
          return (
            <div
              key={virtualRow.key}
              data-index={virtualRow.index}
              ref={rowVirtualizer.measureElement}
              style={{
                position: 'absolute',
                top: 0,
                left: 0,
                width: '100%',
                transform: `translateY(${virtualRow.start - rowVirtualizer.options.scrollMargin}px)`,
                display: 'flex',
              }}
            >
              <div style={{ width: '100%' }}>
                <FeedItem post={post} />
              </div>
            </div>
          )
        })}
      </div>
    )
  }

Expected behavior

Scrolling upwards should be as smooth as scrolling downwards.

How often does this bug happen?

Every time

Screenshots or Videos

No response

Platform

macOS, Chrome

tanstack-virtual version

"@tanstack/react-virtual": "^3.0.1"

TypeScript version

No response

Additional context

The following code inside of the useVirtualizer fixes the issue:

    measureElement: (element, entry, instance) => {
      const direction = instance.scrollDirection
      if (direction === "forward" || direction === null) {
        return element.scrollHeight
      } else {
        // don't remeasure if we are scrolling up
        const indexKey = Number(element.getAttribute("data-index"))
        let cacheMeasurement = instance.itemSizeCache.get(indexKey)
        return cacheMeasurement
      }
    }

I propose that the above behavior is default, that items are not remeasured when scrolling upward.

Terms & Code of Conduct

  • I agree to follow this project's Code of Conduct
  • I understand that if my bug cannot be reliable reproduced in a debuggable environment, it will probably not be fixed and this issue may even be closed.

hmm that is interesting, the scroll backward is bit tricky, we are measuring and adjusting scroll position to remove this jumping, and it was working great for us.

Also keep in mind that when jumping to place in the list where we didn't scroll, when scrolling backward we only have estimated size that will break if we don't measure.

this happened to me too in Vue, my list only have 7 items and I already put my exact items height to estimateSize (776px). But in my case, not only the scroll up is stuttery, it loop back to previous item when I try to scroll up and create infinite loop of scrolling. It will look like the video below, in this video I try to scroll up but it loop me back to previous item

Screen.Recording.2024-02-02.at.22.05.53.mov

hmm that is interesting, the scroll backward is bit tricky, we are measuring and adjusting scroll position to remove this jumping, and it was working great for us.

Also keep in mind that when jumping to place in the list where we didn't scroll, when scrolling backward we only have estimated size that will break if we don't measure.

@piecyk what does the code look like to adjust the scroll position?

@nconfrey it's pretty simple, for elements above scroll offset we adjust scroll position with the difference

https://github.com/TanStack/virtual/blob/main/packages/virtual-core/src/index.ts#L662-L673

Experiencing this too but only in iOS Safari.react-virtuoso suffers from this issue too.

Is there any solution to this?
For example, would it be an option to skip the corrections?

Is there any solution to this?

Can you create an minimal reproducible example? It should work out of box, just testes dynamic example on safari and looks fine.

For example, would it be an option to skip the corrections?

Yes, for example passing your own elementScroll https://github.com/TanStack/virtual/blob/main/packages/virtual-core/src/index.ts#L207-L221 that will not add adjustments

Can you create an minimal reproducible example?

Ok, working on it.

Yes, for example passing your own elementScroll https://github.com/TanStack/virtual/blob/main/packages/virtual-core/src/index.ts#L207-L221 that will not add adjustments

Unfortunately, it still doesn't solve the problem. Will try to reproduce it in a sandbox.

commented

I've got it kind of working with the following code. There are still rendering artefacts on mobile as elements are resized, but for our use case it's better than having no virtualisation. It needs a reasonably large overscan (i've got 20) to prevent empty space appearing at the top of the list on certain viewport widths. It's also necessary to kill the cache entirely. The better you can make your estimateSize method, the fewer rendering artefacts you'll get.

export function VirtualInfiniteScroller<T>(props: VirtualInfiniteScrollerProps<T>) {
  const {
    rowData,
    renderRow,
    hasNextPage,
    fetchNextPage,
    isFetchingNextPage,
    estimateRowHeight,
    overscan,
  } = props;

  const listRef = useRef<HTMLDivElement | null>(null);

  const estimateHeightWithLoading = (index: number) => {
    if (index > rowData.length - 1) {
      return LOADING_ROW_HEIGHT;
    }
    return estimateRowHeight(index);
  };

  const virtualizer = useWindowVirtualizer({
    count: hasNextPage ? rowData.length + 1 : rowData.length,
    estimateSize: estimateHeightWithLoading,
    overscan: overscan ?? 20,
    scrollMargin: listRef.current?.offsetTop ?? 0,
  });

  const virtualItems = virtualizer.getVirtualItems();

  // Kill the cache entirely to prevent weird scrolling issues. This is a hack
  virtualizer.measurementsCache = [];

  useEffect(() => {
    const [lastItem] = [...virtualItems].reverse();

    if (!lastItem) {
      return;
    }

    if (
      hasNextPage &&
      !isFetchingNextPage &&
      lastItem.index >= rowData.length - 1
    ) {
      fetchNextPage();
    }
  }, [
    hasNextPage,
    fetchNextPage,
    rowData.length,
    isFetchingNextPage,
    virtualItems,
  ]);

  return (
    <div ref={listRef} className="List">
      <div
        style={{
          height: virtualizer.getTotalSize(),
          width: '100%',
          position: 'relative',
        }}
      >
        <div
          style={{
            position: 'absolute',
            top: 0,
            left: 0,
            width: '100%',
            transform: `translateY(${
              (virtualItems[0]?.start || 0) - virtualizer.options.scrollMargin
            }px)`,
          }}
        >
          {virtualItems.map((virtualItem) => {
            return (
              <div
                key={virtualItem.key}
                data-index={virtualItem.index}
                ref={(el) => virtualizer.measureElement(el)}
              >
                {virtualItem.index > rowData.length - 1 ? (
                  <FlexSpinner />
                ) : (
                  renderRow(virtualItem.index)
                )}
              </div>
            );
          })}
        </div>
      </div>
    </div>
  );
}

I am encountering the same issue with react-virtuoso as you are. Is there any update on this problem?

I am encountering the same issue with react-virtuoso as you are. Is there any update on this problem?

hmm as mention above the size changes when scrolling up that causes the jumping because of scrolling element size change. We can't really fix this on library size as it's tightly coupled with specific implementation

Please use suggested workaround with custom measureElement

Maybe caching total size when scrolling backward also should limit the jumping, overall please share examples then i can have a look.

Has anyone managed to solve the issue of flickering when scrolling in reverse with react-virtuoso? I've tried using increaseViewportBy, overscan, and either itemSize or measureElement, but I'm still experiencing flickering/jumping while scrolling up.

commented

Just ran into this issue with react-virtuoso. Are there any tips on how to fix that?

Just ran into this issue with react-virtuoso. Are there any tips on how to fix that?

It boils down to implementation of the item, why the sizes are changing are you loading some dynamic content. If you an share some stackblitz example maybe we can find a solution.

Ok, got the reproducible example https://stackblitz.com/edit/tanstack-query-dxabsn?file=src%2Fpages%2Findex.js The issue here is that the height of Item is async, in the example above simulating it via setTimeout.

NOTE: we are using vue version of this library.

Adding setTimeout() to :ref="measureElement" function solved the issue.
Note that virtualizer measureElement hack (described in The following code inside of the useVirtualizer fixes the issue:) is NOT necessary.

const measureElement: VNodeRef = el => {
  if (!(el && el instanceof Element)) return

  setTimeout(() => { // this!
    rowVirtualizer.value.measureElement(el)
  })
}

The issue here is that the height of Item is async, in the example above simulating it via setTimeout.

I saw this and tried the workaround.

I added below option to useVirtualizer() for debug. Then I found ResizeObserverEntry comes even without any actual resize.

measureElement: (element, entry, instance) => {
  const result = me(element, entry, instance)
  console.log(entry ? 'has-entry' : 'not-has-entry', element.getAttribute('data-index'), result)
  return result
},
image

Above log still reproduces even when we removed all of content so its height is zero. (Our row has dynamic content: filled combo box, automatic height textarea and some of filled input)

This does not change with setTimeout() hack, but maybe related?

Then I found ResizeObserverEntry comes even without any actual resize.

yep as the ResizeObserverEntry will also call for initial value

Has anyone solve this problem having dynamic item content? We fetch images from api and when user scrolls really fast down and up - list just jumps. I tried to add some image placeholder and keep the same height for image, but it doesn't help

@piecyk, might the big difference in the elements' size cause scrolling issues?
I have 2 lists. On the first one elements' height varies from 250-290px and the scrolling is very smooth, without any issues. On the second list, the first element is over 1000px and the rest is around 250-350px. I set the estimate to 1100px and I can observe the scrolling issues, especially when scrolling through the first big element.

@kamil-homernik could be, if you can create something on stackblitz will have a look.

It was solved by adjusting the JSX structure and elements' sizes. In the end, we have 4 useWindowVirtualizer instances with dynamic elements' height and it works perfectly. Thanks for this library.

faced with same problem, first time scroll perfectly, when back, scroll flickering ( in debug thousand messages about recalculate ranges getindexes etc and if no try to scroll , messages increase )
image

It was solved by adjusting the JSX structure and elements' sizes. In the end, we have 4 useWindowVirtualizer instances with dynamic elements' height and it works perfectly. Thanks for this library.

can you share an example

@andrconstruction in our case there were two problems:

  1. Virtualizer was wrapped in memo (don't do that 😄 )
  2. As we were loading images from API, we have placeholders added, but there was bug in implementation: there were delay between placeholder unmount and image mount, so there were miliseconds where neither placeholder and image were mounted. It causes recalculation of height of whole item and then problem with the whole list

Try to optimise your JSX elements - in our case that was the problem, not virtualizer at all.

hope this helps someone in the future. Used object-fit: contain; height: [some value here] as CSS for img element, and this seems to have solved the issue for me. The suggested solution was giving me issues with scroll restoration.