Virtual doesn't load items when displayed in a dialog
Hello everyone,
I'm working on a feature to show user's list of followers. Since I use shadcn I decided to display that list inside a dialog (https://ui.shadcn.com/docs/components/dialog ) and inside the dialog content to use @tanstack/virtual. However, when I open that dialog the items aren't displayed (height is calculated properly). Only when I switch the screen/tab I get the elements.
I've found this issue on github: https://github.com/TanStack/virtual/issues/263 however, using a
ref callback
din't help much.
You can see the behaviour on the provided video.
Relevant project info:
"next": "^13.5.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"@tanstack/react-virtual": "^3.0.0-beta.65",
"next": "^13.5.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"@tanstack/react-virtual": "^3.0.0-beta.65",
4 Replies
wise-whiteOP•2y ago
import { Virtualizer, useVirtualizer } from "@tanstack/react-virtual";
import React from "react";
const useIsomorphicLayoutEffect =
typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;
interface Identifiable {
id: string | number;
}
export interface VirtualizedContentProps<TData> {
data: TData[];
hasPages?: boolean;
hasNextPage?: boolean;
isFetchingNextPage: boolean;
fetchNextPage: () => void;
reverse?: boolean;
estimateSize?: (index: number) => number;
}
export function useVirtualizedContent<TData extends Identifiable>({
data,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
hasPages,
reverse,
estimateSize = () => 56,
}: VirtualizedContentProps<TData>) {
const parentRef = React.useRef<HTMLDivElement | null>(null);
const virtualizerRef = React.useRef<Virtualizer<HTMLDivElement, Element>>();
const getItemKey = React.useCallback(
(index: number) => data[index]?.id,
[data],
);
const count = React.useMemo(
() => (hasPages && hasNextPage ? data.length + 1 : data.length),
[hasPages, hasNextPage, data.length],
);
const virtualizer = useVirtualizer({
count,
overscan: 5,
getScrollElement: () => parentRef.current,
estimateSize,
getItemKey,
});
useIsomorphicLayoutEffect(() => {
virtualizerRef.current = virtualizer;
});
// Fetch next page when scrolled to top
React.useEffect(() => {
const virtualItems = virtualizer.getVirtualItems();
const lastItem = virtualItems[virtualItems.length - 1];
if (!lastItem || !hasPages) {
return;
}
if (
lastItem.index >= data.length - 1 &&
hasNextPage &&
!isFetchingNextPage
) {
fetchNextPage();
}
}, [
hasPages,
hasNextPage,
fetchNextPage,
isFetchingNextPage,
virtualizer.getVirtualItems(),
data.length,
parentRef.current?.offsetTop,
]);
return {
parentRef,
virtualizer,
};
}
import { Virtualizer, useVirtualizer } from "@tanstack/react-virtual";
import React from "react";
const useIsomorphicLayoutEffect =
typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;
interface Identifiable {
id: string | number;
}
export interface VirtualizedContentProps<TData> {
data: TData[];
hasPages?: boolean;
hasNextPage?: boolean;
isFetchingNextPage: boolean;
fetchNextPage: () => void;
reverse?: boolean;
estimateSize?: (index: number) => number;
}
export function useVirtualizedContent<TData extends Identifiable>({
data,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
hasPages,
reverse,
estimateSize = () => 56,
}: VirtualizedContentProps<TData>) {
const parentRef = React.useRef<HTMLDivElement | null>(null);
const virtualizerRef = React.useRef<Virtualizer<HTMLDivElement, Element>>();
const getItemKey = React.useCallback(
(index: number) => data[index]?.id,
[data],
);
const count = React.useMemo(
() => (hasPages && hasNextPage ? data.length + 1 : data.length),
[hasPages, hasNextPage, data.length],
);
const virtualizer = useVirtualizer({
count,
overscan: 5,
getScrollElement: () => parentRef.current,
estimateSize,
getItemKey,
});
useIsomorphicLayoutEffect(() => {
virtualizerRef.current = virtualizer;
});
// Fetch next page when scrolled to top
React.useEffect(() => {
const virtualItems = virtualizer.getVirtualItems();
const lastItem = virtualItems[virtualItems.length - 1];
if (!lastItem || !hasPages) {
return;
}
if (
lastItem.index >= data.length - 1 &&
hasNextPage &&
!isFetchingNextPage
) {
fetchNextPage();
}
}, [
hasPages,
hasNextPage,
fetchNextPage,
isFetchingNextPage,
virtualizer.getVirtualItems(),
data.length,
parentRef.current?.offsetTop,
]);
return {
parentRef,
virtualizer,
};
}
interface Props extends VirtualizedContentProps<FollowUser> {
readonly title: string;
}
export function UserFollowList(props: PropsWithChildren<Props>) {
const { virtualizer, parentRef } = useVirtualizedContent(props);
const items = virtualizer.getVirtualItems();
return (
<Dialog>
<DialogTrigger asChild>{props.children}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{props.title}</DialogTitle>
</DialogHeader>
<div
ref={parentRef}
className="h-[400px] w-full overflow-auto px-2 py-0"
>
<div
className="relative w-full"
style={{ height: `${virtualizer.getTotalSize()}px` }}
>
<div className="absolute left-0 top-0 w-full">
{items.map((virtualRow) => {
const index = virtualRow.index;
const user = props.data[index];
const isLoaderRow = virtualRow.index > props.data.length - 1;
return (
<div
key={virtualRow.key}
className="absolute left-0 top-0 w-full py-2"
style={{
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
{isLoaderRow ? (
props.hasNextPage ? (
<div className="w-full p-2">
<Loader2 className="h-8 w-8 w-full animate-spin" />
</div>
) : (
<p className="text-foreground">Nothing more to load</p>
)
) : (
// loader row
)}
</div>
);
})}
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}
interface Props extends VirtualizedContentProps<FollowUser> {
readonly title: string;
}
export function UserFollowList(props: PropsWithChildren<Props>) {
const { virtualizer, parentRef } = useVirtualizedContent(props);
const items = virtualizer.getVirtualItems();
return (
<Dialog>
<DialogTrigger asChild>{props.children}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{props.title}</DialogTitle>
</DialogHeader>
<div
ref={parentRef}
className="h-[400px] w-full overflow-auto px-2 py-0"
>
<div
className="relative w-full"
style={{ height: `${virtualizer.getTotalSize()}px` }}
>
<div className="absolute left-0 top-0 w-full">
{items.map((virtualRow) => {
const index = virtualRow.index;
const user = props.data[index];
const isLoaderRow = virtualRow.index > props.data.length - 1;
return (
<div
key={virtualRow.key}
className="absolute left-0 top-0 w-full py-2"
style={{
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
{isLoaderRow ? (
props.hasNextPage ? (
<div className="w-full p-2">
<Loader2 className="h-8 w-8 w-full animate-spin" />
</div>
) : (
<p className="text-foreground">Nothing more to load</p>
)
) : (
// loader row
)}
</div>
);
})}
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}
// Allows parentRef to update when the dialog is open
const openChangeToggle = useToggle();
const { virtualizer, parentRef } = useVirtualizedContent(props);
const items = virtualizer.getVirtualItems();
return (
<Dialog onOpenChange={openChangeToggle.toggle}>
{/* Rest is the same */ }
// Allows parentRef to update when the dialog is open
const openChangeToggle = useToggle();
const { virtualizer, parentRef } = useVirtualizedContent(props);
const items = virtualizer.getVirtualItems();
return (
<Dialog onOpenChange={openChangeToggle.toggle}>
{/* Rest is the same */ }
conventional-tan•2y ago
Yes, the issue is that the scroll element is stored on ref, that's why you need the extra re-render. You can also use state for it.
wise-whiteOP•2y ago
Thanks for the suggestion. But this is perfectly fine for me 🙂
generous-apricot•10mo ago
@piecyk can you elaborate what you mean when you say you can use state for it? What is "it" in this scenario?
Figured something out --
Edit: I ran into rendering loop errors using directly above. I found using a callback with an optional update did the job:
const [_, setEnsureRefreshRef] = React.useState<HTMLDivElement | null>(null)
...
return (
....
<PopoverContent ref={setEnsureRefreshRef} // Ensures that when popover content becomes visible, the virtualization is refreshed>
...
const [_, setEnsureRefreshRef] = React.useState<HTMLDivElement | null>(null)
...
return (
....
<PopoverContent ref={setEnsureRefreshRef} // Ensures that when popover content becomes visible, the virtualization is refreshed>
...
const [popoverElement, setPopoverElement] = useState<HTMLDivElement | null>(null)
// Explicitly type the ref callback
const ensureRefreshingRefCallback = useCallback(
(element: HTMLDivElement | null) => {
if (popoverElement !== element) {
setPopoverElement(element)
}
},
[popoverElement]
)
const [popoverElement, setPopoverElement] = useState<HTMLDivElement | null>(null)
// Explicitly type the ref callback
const ensureRefreshingRefCallback = useCallback(
(element: HTMLDivElement | null) => {
if (popoverElement !== element) {
setPopoverElement(element)
}
},
[popoverElement]
)