TanStack / virtual

🤖 Headless UI for Virtualizing Large Element Lists in JS/TS, React, Solid, Vue and Svelte
https://tanstack.com/virtual
MIT License
5.52k stars 302 forks source link

Scrolling up with dynamic heights stutters and jumps #659

Closed nconfrey closed 6 months ago

nconfrey commented 9 months ago

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:

https://github.com/TanStack/virtual/assets/7350670/655b0a0b-4562-47e2-aa96-cb72daf8ad37

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

piecyk commented 9 months ago

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.

titodarmawan commented 9 months ago

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

https://github.com/TanStack/virtual/assets/136794762/2d06b05d-d347-464f-ba89-a1656cc53b22

nconfrey commented 9 months ago

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?

piecyk commented 9 months ago

@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

ReidCampbell commented 8 months ago

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

ISilviu commented 8 months ago

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

piecyk commented 8 months ago

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

ISilviu commented 8 months ago

Can you create an minimal reproducible example?

Ok, working on it.

ISilviu commented 8 months ago

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.

OrmEmbaar commented 7 months ago

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>
  );
}
nuanyang233 commented 6 months ago

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

piecyk commented 6 months ago

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

piecyk commented 6 months ago

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

bukke101 commented 6 months ago

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.

DmitryAvanesov commented 5 months ago

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

piecyk commented 5 months ago

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.

piecyk commented 5 months ago

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.

ypresto commented 5 months ago

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?

piecyk commented 5 months ago

Then I found ResizeObserverEntry comes even without any actual resize.

yep as the ResizeObserverEntry will also call for initial value

msmoter commented 5 months ago

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

kamil-homernik commented 3 months ago

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

piecyk commented 3 months ago

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

kamil-homernik commented 3 months ago

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.

andrconstruction commented 3 months ago

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

msmoter commented 3 months ago

@andrconstruction in our case there were two problems:

  1. Virtualizer was wrapped in memo (don't do that :smile: )
  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.

thekyle5000 commented 3 months ago

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. This css hack is not perfect, though, and has some style limitations (especially when images have varying aspect ratios)

ashishsurya commented 4 weeks ago

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(props: VirtualInfiniteScrollerProps) { 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 (

{virtualItems.map((virtualItem) => { return (
virtualizer.measureElement(el)} > {virtualItem.index > rowData.length - 1 ? ( ) : ( renderRow(virtualItem.index) )}
        );
      })}
    </div>
  </div>
</div>

); }

If i am doing this in production I got an error that says unable to read index from undefined.

for virtualRow.index, and also dynamic height is not working

piecyk commented 3 weeks ago

If i am doing this in production I got an error that says unable to read index from undefined. for virtualRow.index, and also dynamic height is not working

@ashishsurya can't really say anything without an example.

ashishsurya commented 3 weeks ago

If i am doing this in production I got an error that says unable to read index from undefined. for virtualRow.index, and also dynamic height is not working

@ashishsurya can't really say anything without an example.

What I meant is that if clear the measurementCache, the table is unable to render rows with dynamic height.

And also memorization solved my issues, no worries.

Thanks for the reply @piecyk

wwzzyying commented 3 weeks ago

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:我有动态项目的提要,例如 iframe、照片和文本。当我向下滚动时,一切都很好,滚动也很流畅,因为高度增加的测量项目会将其他项目向下推到视线之外。然而,当我向上滚动时,性能非常卡顿,项目跳得到处都是:

Untitled.mov 无题.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 macOS、Chrome

tanstack-virtual version tanstack-虚拟版本

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

TypeScript version TypeScript 版本

No response 没有回应

Additional context 额外的背景信息

The following code inside of the useVirtualizer fixes the issue:useVirtualizer中的以下代码修复了该问题:

    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

条款和行为准则

  • [x] I agree to follow this project's Code of Conduct我同意遵守该项目的行为准则[x] 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.我知道,如果我的错误无法在可调试环境中可靠地重现,它可能不会被修复,这个问题甚至可能会被关闭。

Using scrollDirection to determine the itemSizeCache can solve most scenarios, but testing shows that some Android devices may experience a sudden forward movement during backward scrolling.