inokawa / virtua

A zero-config, fast and small (~3kB) virtual list (and grid) component for React, Vue, Solid and Svelte.
https://inokawa.github.io/virtua/
MIT License
1.35k stars 49 forks source link

Unexpected jumpy behavior occurs when scrolling, suddenly jumping up to page 1. #447

Closed humanscape-mars closed 1 month ago

humanscape-mars commented 6 months ago

Describe the bug When using the WindowVirtualizer component for infinite scrolling of paginated pages, there is unexpected behavior where the scroll position suddenly jumps to page 1. This issue occurs particularly when the list pages (e.g., pages 1, 2, 3...) are not cached and need to be fetched from the server.

This is my implementation.


import { CacheSnapshot, WindowVirtualizer, WindowVirtualizerHandle } from 'virtua';

import type { LazyQueryExecFunction } from '@apollo/client';

interface RestorableListProps {
  mergedNodes: Data[];
  currPage: number;
  setCurrPage: React.Dispatch<React.SetStateAction<number>>;

  fetchList: LazyQueryExecFunction<
    ListQuery,
    Exact<{
      listOptions: ListOptions;
    }>
  >;
}

const RestorableList = ({
  mergedNodes,
  currPage,
  setCurrPage,
  fetchList,
}: RestorableListProps) => {
  const cacheKey = 'window-list-cache';

  const ref = useRef<WindowVirtualizerHandle>(null);

  const [_, offset, cache] = useMemo(() => {
    const serialized = sessionStorage.getItem(cacheKey);
    if (!serialized) return [];
    try {
      return JSON.parse(serialized) as [number, number, CacheSnapshot];
    } catch (e) {
      return [];
    }
  }, []);

  useLayoutEffect(() => {
    if (!ref.current) return;
    const handle = ref.current;

    window.scrollTo(0, offset ?? 0);

    let scrollY = 0;
    const onScroll = () => {
      scrollY = window.scrollY;
    };
    window.addEventListener('scroll', onScroll);
    onScroll();

    return () => {
      window.removeEventListener('scroll', onScroll);
      // Use stored window.scrollY because it may return 0 in useEffect cleanup

      sessionStorage.setItem(cacheKey, JSON.stringify([currPage, scrollY, handle.cache]));
    };
  }, [currPage]);

  const onRangeChange = async (start: number, end: number) => {
    const pagePosition = Math.floor((end + 2) / 20) + 1;

    if (pagePosition > currPage) {
      setCurrPage(pagePosition);
      fetchList({
        variables: {
          listOptions: {
            page: pagePosition,
          },
        },
      });
    }
  };

  return (
    <WindowVirtualizer ref={ref} cache={cache} onRangeChange={onRangeChange}>
      {mergedNodes?.map((node, index) => (
        <EventCard
          key={index}
          eventData={node}
        />
      ))}
    </WindowVirtualizer>
  );
};

export default RestorableList;

To Reproduce Steps to reproduce the behavior:

Scroll down through the list to trigger data fetching from the server. Observe that as new data loads and is not present in the cache, the scroll position unexpectedly jumps back to page 1.

Expected behavior The expected behavior is that as users scroll down, pages should load sequentially (pages 1, 2, 3, etc.) without the scroll position abruptly jumping back to the start or any previously viewed page.

Platform:

Additional context Add any other context about the problem here.

If there are any workarounds to avoid this problem, please let me know. By the way, awesome library! I think it's the best in terms of virtual window functionality.

inokawa commented 6 months ago

As far as I granced the code, the useLayoutEffect seems to be called when currPage is changed. It should be called only on mount like:

+ const initialized = useRef(false);
  useLayoutEffect(() => {
    if (!ref.current) return;
    const handle = ref.current;

+   if (!initialized.current) {
+   initialized.current = false;
    window.scrollTo(0, offset ?? 0);
+   }

    let scrollY = 0;
    const onScroll = () => {
      scrollY = window.scrollY;
    };
    window.addEventListener('scroll', onScroll);
    onScroll();

    return () => {
      window.removeEventListener('scroll', onScroll);
      // Use stored window.scrollY because it may return 0 in useEffect cleanup

      sessionStorage.setItem(cacheKey, JSON.stringify([currPage, scrollY, handle.cache]));
    };
  }, [currPage]);
humanscape-mars commented 6 months ago

Thank you, that's a reasonable solution, but the problem still persists. hmm...