Replies: 10 comments 60 replies
-
The only thing that worked for us so far is a double As you've noticed both prepending or appending an item would change scroll height, but prepending pushes visible content while appending does not The
It's relatively easy to try it out, but it has some potential drawbacks
So here it is:
Pushing more items would add them to the top but would not cause scroll/content issues Since your case is a chat - I'm not sure whether there won't be the same problem when new messages arrive and they need to be added to the bottom. It might be possible to just scroll to index 0 (bottom) when new messages arrive (if you were already on the bottom) For our case the scaleY hack works BTW you also have to invert the mouse useEffect(() => {
const el = parentRef.current;
const invertedWheelScroll = (event) => {
el.scrollTop -= event.deltaY;
event.preventDefault();
};
el.addEventListener('wheel', invertedWheelScroll, false);
return () => el.removeEventListener('wheel', invertedWheelScroll);
}, []); |
Beta Was this translation helpful? Give feedback.
-
Man, this gets hairy- you gotta
EDIT/UPDATE I think you can actually do 4. with the I have a loosely passable POC but it's not in a good state to share at the moment. happy to discuss + send snippets if there is still interest. This blog post was helpful for me |
Beta Was this translation helpful? Give feedback.
-
I think we can go with this https://codesandbox.io/s/beautiful-meninsky-fr6csu?file=/pages/index.js The trick is to update scroll offset in same time when new items are prepend, and reverse indexes. |
Beta Was this translation helpful? Give feedback.
-
Hello, I ended up in this issue a couple of days ago searching for an efficient way to have two-direction infinite loader+scrolling with virtual and react-table. Your answers above helped a lot and I ended up with an approach that I believe works well enough. (Still wip - especially from the perf aspect) I work at netdata and you can check the end result (without signup or anything) here. 480p.movI used both react-table and react-virtual. Now for the problem at hand, bi-directional infinite scrolling, I used the usual implementation for infinite loading with the difference of adding a second The gist of what I used is this:
So in our use case:
The same happens when the user keeps scrolling up and fetches more and more data. All of the above are happening with just a small flickering the minute before Hope this helps anyone who wants to try something similar. |
Beta Was this translation helpful? Give feedback.
-
Did somebody have success reversing the arrow and |
Beta Was this translation helpful? Give feedback.
-
if anyone is interested, I have managed to create a reversed chat-like layout with https://stackblitz.com/edit/vitejs-vite-e28dau?file=src%2FVirtualList.tsx Component for referenceimport React, { CSSProperties, useCallback, useEffect, useRef } from 'react';
import { VirtualItem, useVirtualizer } from '@tanstack/react-virtual';
export type VirtualListProps<T> = {
className?: string;
style?: CSSProperties;
itemClassName?: string;
itemStyle?: CSSProperties;
items: T[];
getItemKey: (item: T, index: number) => string | number;
renderItem: (item: T, virtualItem: VirtualItem) => React.ReactNode;
estimateSize: (index: number) => number;
overscan?: number;
};
export function VirtualList<T>({
style,
itemStyle,
items,
getItemKey,
estimateSize,
renderItem,
overscan,
}: VirtualListProps<T>) {
const scrollableRef = useRef<HTMLDivElement>(null);
const getItemKeyCallback = useCallback(
(index: number) => getItemKey(items[index]!, index),
[getItemKey, items]
);
const virtualizer = useVirtualizer({
count: items.length,
getItemKey: getItemKeyCallback,
getScrollElement: () => scrollableRef.current,
estimateSize,
overscan,
debug: true,
});
useEffect(
function scrollToEnd() {
virtualizer.scrollToIndex(items.length - 1);
},
[items]
);
const virtualItems = virtualizer.getVirtualItems();
return (
<div
style={{
display: 'flex',
flexDirection: 'column-reverse',
...style,
}}
>
<div
ref={scrollableRef}
style={{
overflow: 'auto',
}}
>
<div
style={{
width: '100%',
position: 'relative',
height: virtualizer.getTotalSize(),
}}
>
<div
style={{
position: 'absolute',
top: '0',
left: '0',
width: '100%',
transform: `translateY(${virtualItems[0]?.start ?? 0}px)`,
}}
>
{virtualItems.map((virtualItem) => {
const item = items[virtualItem.index]!;
return (
<div
key={virtualItem.key}
ref={virtualizer.measureElement}
data-index={virtualItem.index}
style={itemStyle}
>
{renderItem(item, virtualItem)}
</div>
);
})}
</div>
</div>
</div>
</div>
);
} |
Beta Was this translation helpful? Give feedback.
-
I just solved it by making sure estimateSize is always larger than the list elements. const virtualizer = useVirtualizer({
estimateSize: () => 999,
// ...
});
// ...
useEffect(
() => {
virtualizer.scrollToIndex(items.length - 1);
},
[items]
); |
Beta Was this translation helpful? Give feedback.
-
Yeah, this is a tough problem. The engineering effort to implement a ChatRoom-like feature with TanStack virtual seems massive. A bidirectional, infinite loader just doesn't seem possible without side effects that create a pretty poor UX when it comes to loading in paginated messages in the scroll window. It would be amazing to work together to create something easy to use like message list provided by virtuoso, albeit not for free. |
Beta Was this translation helpful? Give feedback.
-
@piecyk I have extracted the relevant snippets, just to give you an idea. const Dynamic = () => {
const prevDataRef = useRef<typeof data>();
const scrollerRef = useRef<HTMLDivElement | null>(null);
const virtualizerRef = useRef<Virtualizer<
HTMLDivElement,
HTMLDivElement
> | null>(null);
const {data} = useInfiniteQuery({
queryKey: ['projects'],
queryFn: async ({pageParam}) => {
const res = await axios.get('/api/projects?cursor=' + pageParam);
return res.data;
},
initialPageParam: 0,
maxPages: 3, // Must be greater than 2
getPreviousPageParam: (firstPage) => {
return firstPage.previousId ?? undefined
},
getNextPageParam: (lastPage) => {
return lastPage.nextId ?? undefined
},
});
// ℹ️ Since this is a bidirectional infinite loader, when you scroll
// to the start of the list, items are added to the top. Also, as you
// scroll to the end of the list, older items at the top will be removed
// to maintain optimal performance. This will cause a layout shift, and
// we need to do scroll anchoring at this point. firstItemOffset is the
// total number of items that was added or removed from the top.
// PS: Negative value implies removal.
const firstItemOffset = calculateOffset(data, prevDataRef.current);
// ℹ️ This prevents multiple executions of the restoreScrollOffset function,
// which occurs in React StrictMode where useEffects will run twice
const restoredScrollOffsetRef = useRef<boolean>(false);
useLayoutEffect(() => {
// ℹ️ We can reset this flag at this point.
restoredScrollOffsetRef.current = false;
prevDataRef.current = data;
});
const restoreScrollOffset = useCallback(
(firstItemOffset: number) => {
if (firstItemOffset === 0) return;
if (restoredScrollOffsetRef.current) return;
if (!virtualizerRef.current) return;
const virtualizer = virtualizerRef.current;
if (firstItemOffset > 0) {
// ℹ️ Re-calculate the measurementsCache
// to get the size of newly added items.
virtualizer.calculateRange();
}
// ℹ️ When items are removed, this function should be called before the
// useVirtualizer hook so that we can get the size of the removed items.
// Conversely, when items are added, it should be called after the hook,
// so that we can get the size of the added items.
const offsetItemCache =
virtualizer.measurementsCache[Math.abs(firstItemOffset)];
const delta =
offsetItemCache.start -
virtualizer.options.paddingStart -
virtualizer.options.scrollMargin;
const scrollOffset = virtualizer.scrollOffset;
const adjustments = firstItemOffset < 0 ? -delta : delta;
scrollToOffset(virtualizer, scrollOffset, {
behavior: undefined,
adjustments: adjustments
});
// Set the scrollOffset within this render,
// to display the current range of items.
virtualizer.scrollOffset = scrollOffset + adjustments;
restoredScrollOffsetRef.current = true;
},
[]
);
// ⚠️ This is done before the useVirtualizer hook so that we can
// use the measurement cache of the removed items before the
// cache is reset in the virtualizer instance.
useMemo(() => {
if (firstItemOffset < 0) {
restoreScrollOffset(firstItemOffset);
}
}, [firstItemOffset, restoreScrollOffset]);
const listItems = useMemo(
() => data.pages.flatMap((page) => page.data) ?? [],
[data]
);
virtualizerRef.current = useVirtualizer({
count: listItems.length,
getScrollElement: () => scrollerRef.current,
getItemKey: useCallback((index) => listItems[index].id, [listItems]),
estimateSize: () => 150,
});
const virtualizer = virtualizerRef.current;
// ⚠️ This is done after the useVirtualizer hook so that we can
// use the measurementsCache of the added items.
useMemo(() => {
if (firstItemOffset > 0) {
restoreScrollOffset(firstItemOffset);
}
}, [firstItemOffset, restoreScrollOffset]);
if (firstItemOffset === 0) {
// ℹ️ When there is no change to the first item,
// we allow the scroll anchoring of the library.
virtualizer.shouldAdjustScrollPositionOnItemSizeChange = undefined;
} else {
// ℹ️ If we need to do manual scroll anchoring for changes to the first item,
// we will disable that of the library when items are removed, and we enable
// it for newly added items, so that the scroll can be adjusted for their sizes.
virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item) => {
if (firstItemOffset < 0) {
return false;
} else {
return item.index < Math.abs(firstItemOffset);
}
};
}
return (
<div
ref={scrollerRef}
style={{
height: "100%",
width: "100%",
// ℹ️ Disable browser scroll anchoring
// since it affects restoreScrollOffset
overflowAnchor: "none",
overflowY: "auto"
}}>
{/* Other stuffs.*/}
</div>
);
};
function calculateOffset<Data>(data: any, prevData: any) {
if (!data || !prevData) {
return 0;
}
if (prevData.pageParams[0] === data.pageParams[0]) {
return 0;
}
if (prevData.pageParams[0] === data.pageParams[1]) {
return data.pages[0].data.length;
} else if (prevData.pageParams[1] === data.pageParams[0]) {
return -prevData.pages[0].data.length;
} else {
return 0;
}
}
const scrollToOffset = (
virtualizer: Virtualizer<HTMLDivElement, HTMLDivElement>,
offset: number,
{
adjustments,
behavior
}: {
adjustments: number | undefined;
behavior: ScrollBehavior | undefined;
}
) => {
virtualizer.options.scrollToFn(offset, {behavior, adjustments}, virtualizer);
}; |
Beta Was this translation helpful? Give feedback.
-
For anyone having issues with this package, We have just migrated to Virtua, and it's much better. It has a simpler API and more features. I suggest giving it a try. |
Beta Was this translation helpful? Give feedback.
-
We have been using react-virtual for a few months, and love the fact that the library is headless and allow us to do things our way... However, we are trying to build a chat-like virtual list that starts at the bottom, we then prepend elements to the list when we reach the top. Nothing too fancy except that the messages have fairly variable sizes.
The problems started to show up when we started experimenting with bigger messages... It seems that if we used a list that would not start from the bottom we would not have nearly as many issues... Mostly because the height of the list gets calculated and as you usually scroll down, the list height expands but you are left at the same scroll position (so nothing more than a little flicker in the scrollbar). However when the list is inverted and the elements above your overscan are rendered, the height of the elements pushes the elements that are in view downward (happens only when the estimate size is not 100% correct).
Furthermore, we have a functionality to scroll to the bottom of the list which we achieve by scrolling to a big offset. The reason for that is due to the padding at the bottom, scrollToIndex would not work for us. However since our estimate function is not 100% accurate the scroll happens as follow:
It seems that this is something inevitable, and that is why we have a double scroll in the scrollToIndex.
So I was wondering if anyone has ever done a reversed list with dynamic elements and could give some guidance, or create an example.
Beta Was this translation helpful? Give feedback.
All reactions