Replies: 11 comments 67 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.
-
I have a proof of concept that leverages https://stackblitz.com/edit/tanstack-virtual-ek9n4kke?file=src%2Fmain.tsx A personal benefit of leveraging One issue I've experienced with this implementation is that it seems to have issues handling data that resizes after a delay, e.g. images. The padding bottom and scroll position seem to get muddled when that happens... Here is the DOM structure. <!-- Establishes viewing window. -->
<!-- Flex column reverse leverages browser overflow-anchor to stick to the bottom of the content. -->
<div className="flex flex-col-reverse size-100" ref={scrollElement}>
<!-- Wrapper around the list that also sticks to the content at the bottom. ->
<!-- Does not shrink in flexbox and occupies the designated height. -->
<div className="flex flex-col-reverse shrink-0" height={virtualizer.getTotalSize()}>
<!-- The reversed list. -->
<!-- Only shows the visible items, and uses padding-bottom to push them into view. -->
<ul className="flex flex-col-reverse" style={{ paddingBottom: virtualizer.items[0].start }}>
{items.map((item) => {
return (
<li ref={virtualizer.measureElement} data-index={item.index}>
</li>
)
}}
</ul>
</div>
</div> This example applies two small patches to the existing virtualizer.
Here is the provided function RowVirtualizerDynamic() {
const parentRef = React.useRef<HTMLDivElement>(null)
const [enabled, setEnabled] = React.useState(true)
const count = sentences.length
const virtualizer = useVirtualizer({
count,
getScrollElement: () => parentRef.current,
estimateSize: () => 45,
enabled,
// Override!
scrollToFn(offset, options, instance) {
return elementScroll(offset * -1, options, instance)
},
})
// Override!
React.useEffect(() => {
// @ts-expect-error Overriding private method.
const getScrollOffset = virtualizer.getScrollOffset
// @ts-expect-error Overriding private method.
virtualizer.getScrollOffset = () => Math.abs(getScrollOffset())
}, [virtualizer])
// Can work the same without virtualization.
const items = enabled
? virtualizer.getVirtualItems()
: sentences.map((_sentence, index) => {
return { index }
})
return (
<div>
<button
onClick={() => {
virtualizer.scrollToIndex(0)
}}
>
scroll to the top
</button>
<span style={{ padding: '0 4px' }} />
<button
onClick={() => {
virtualizer.scrollToIndex(count / 2)
}}
>
scroll to the middle
</button>
<span style={{ padding: '0 4px' }} />
<button
onClick={() => {
virtualizer.scrollToIndex(count - 1)
}}
>
scroll to the end
</button>
<span style={{ padding: '0 4px' }} />
<button
onClick={() => {
setEnabled((prev) => !prev)
}}
>
turn {enabled ? 'off' : 'on'} virtualizer
</button>
<hr />
<div
ref={parentRef}
className="List"
style={{
height: 400,
width: 400,
overflowY: 'auto',
contain: 'strict',
display: 'flex',
flexDirection: 'column-reverse',
}}
>
<div
style={{
height: virtualizer.getTotalSize(),
display: 'flex',
flexDirection: 'column-reverse',
flexShrink: 0,
}}
>
<div
style={{
display: 'flex',
flexDirection: 'column-reverse',
paddingBottom: items[0]?.start ?? 0,
}}
>
{items.map((virtualRow) => (
<div
key={virtualRow.key}
data-index={virtualRow.index}
ref={virtualizer.measureElement}
className={
virtualRow.index % 2 ? 'ListItemOdd' : 'ListItemEven'
}
>
<div style={{ padding: '10px 0' }}>
<div>Row {virtualRow.index}</div>
<div>{sentences[virtualRow.index]}</div>
</div>
</div>
))}
</div>
</div>
</div>
</div>
)
} Bi-directional, infinite reverse scrollIn addition, Bi-directional infinite reverse scrolling can be implemented within this revision. Here is a proof of concept. Please note that scrolling UP and loading more at the TOP should work perfectly fine because padding-bottom should remain the same. However, scrolling DOWN and loading more at the BOTTOM will require a proper repositioning. A proof of concept repositioner is provided, but it seems very janky... function InfiniteRowVirtualizerDynamic() {
const parentRef = React.useRef<HTMLDivElement>(null)
const topRef = React.useRef<HTMLParagraphElement>(null)
const bottomRef = React.useRef<HTMLParagraphElement>(null)
const [mounted, setMounted] = React.useState(false)
const query = useInfiniteQuery({
queryKey: [],
initialPageParam: length / 2,
queryFn: async (context) => {
await new Promise((resolve) => setTimeout(resolve, 1_000))
const data = sentences.slice(context.pageParam, context.pageParam + size)
return data
},
getNextPageParam: (_lastPage, _allPages, lastPageParam, _allPageParams) => {
return lastPageParam < length ? lastPageParam + size : null
},
getPreviousPageParam: (
_lastPage,
_allPages,
lastPageParam,
_allPageParams,
) => {
return lastPageParam > 0 ? lastPageParam - size : null
},
})
const [enabled, setEnabled] = React.useState(true)
const count = sentences.length
const data = React.useMemo(() => {
return query.data?.pages.flat() ?? []
}, [query.data])
const virtualizer = useVirtualizer({
count: data.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 45,
enabled,
scrollToFn(offset, options, instance) {
return elementScroll(offset * -1, options, instance)
},
})
React.useEffect(() => {
// @ts-expect-error Overriding private method.
const getScrollOffset = virtualizer.getScrollOffset
// @ts-expect-error Overriding private method.
virtualizer.getScrollOffset = () => Math.abs(getScrollOffset())
}, [virtualizer])
// Scroll to the middle of the list once the page loads.
React.useEffect(() => {
if (!mounted && virtualizer.elementsCache.size) {
virtualizer.scrollToIndex(Math.floor(data.length / 2))
setMounted(true)
}
}, [virtualizer.elementsCache.size])
React.useEffect(() => {
if (!topRef.current || !mounted) return
const observer = new IntersectionObserver(async (entries) => {
if (!entries[0].isIntersecting || query.isFetchingNextPage) return
await query.fetchNextPage()
})
observer.observe(topRef.current)
}, [mounted, topRef])
React.useEffect(() => {
if (!bottomRef.current || !mounted) return
const observer = new IntersectionObserver(async (entries) => {
if (!entries[0].isIntersecting || query.isFetchingPreviousPage) return
let previousOffset = virtualizer.scrollOffset || 0
const result = await query.fetchPreviousPage()
const index = result.data?.pages[0]?.length || 0
if (!index) return
virtualizer.scrollToIndex(index)
setTimeout(async () => {
const measurement = virtualizer.measurementsCache[index]
const start = measurement?.start || 0
const delta =
start -
virtualizer.options.paddingStart -
virtualizer.options.scrollMargin
const offset = previousOffset - delta
virtualizer.scrollToOffset(Math.abs(offset))
virtualizer.scrollOffset = offset
}, 100)
})
observer.observe(bottomRef.current)
}, [mounted, bottomRef])
const items = virtualizer.getVirtualItems()
const start = (query.data?.pageParams[0] as number) || 0
return (
<div>
<button
onClick={() => {
virtualizer.scrollToIndex(0)
}}
>
scroll to the top
</button>
<span style={{ padding: '0 4px' }} />
<button
onClick={() => {
virtualizer.scrollToIndex(count / 2)
}}
>
scroll to the middle
</button>
<span style={{ padding: '0 4px' }} />
<button
onClick={() => {
virtualizer.scrollToIndex(count - 1)
}}
>
scroll to the end
</button>
<span style={{ padding: '0 4px' }} />
<button
onClick={() => {
setEnabled((prev) => !prev)
}}
>
turn {enabled ? 'off' : 'on'} virtualizer
</button>
<hr />
<div
ref={parentRef}
className="List"
style={{
height: 400,
width: 400,
overflowY: 'auto',
contain: 'strict',
display: 'flex',
flexDirection: 'column-reverse',
}}
>
{query.hasPreviousPage && (
<p ref={bottomRef}>
<span>Bottom</span>
{query.isFetchingPreviousPage && (
<span>Loading previous page...</span>
)}
</p>
)}
<div
style={{
height: virtualizer.getTotalSize(),
display: 'flex',
flexDirection: 'column-reverse',
flexShrink: 0,
}}
>
<div
style={{
display: 'flex',
flexDirection: 'column-reverse',
paddingBottom: items[0]?.start ?? 0,
}}
>
{items.map((virtualRow) => (
<div
key={virtualRow.key}
data-index={virtualRow.index}
ref={virtualizer.measureElement}
className={
virtualRow.index % 2 ? 'ListItemOdd' : 'ListItemEven'
}
>
<div style={{ padding: '10px 0' }}>
<div>Row {start + virtualRow.index}</div>
<div>{data[virtualRow.index]}</div>
</div>
</div>
))}
</div>
{query.hasNextPage && (
<p ref={topRef}>
<span>Top</span>
{query.isFetchingNextPage && <span>Loading next page...</span>}
</p>
)}
</div>
</div>
</div>
)
} |
Beta Was this translation helpful? Give feedback.
Uh oh!
There was an error while loading. Please reload this page.
-
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