Describe the bug
Title
measureElement(el) does not update item size when element height changes dynamically
Body
Describe the bug
When an item's content changes height dynamically (e.g., a loading skeleton replaced with actual content, or streaming text growing), calling rowVirtualizer.measureElement(el) manually does not update the virtualizer's internal size cache. The item's virtualItem.size remains stale, causing:
- Incorrect
totalSize calculation
- Overlapping items
- Scroll position issues
- New messages appearing below the visible area
Calling rowVirtualizer.measure() (full re-measure) does work correctly, confirming the issue is specific to the targeted measureElement call.
Minimal reproducible scenario
- Render a virtualized list with dynamic item heights
- Add a new item that initially renders as a loading skeleton (e.g., 24px height)
- Replace the skeleton with actual content (e.g., 152px height)
- Observe the element's height change via
ResizeObserver
- Call
rowVirtualizer.measureElement(el) when height changes
Actual behavior
- The DOM element's
offsetHeight correctly reflects the new height
virtualItem.size remains at the old value (e.g., 24 instead of 152)
totalSize does not change
- Items overlap or scroll position breaks
Your minimal, reproducible example
none
Steps to reproduce
// VirtualComponent.tsx
type IVirtualComponent = {
start: number;
index: number;
id: string;
measureElement: (element: HTMLElement | null) => void;
};
export const VirtualComponent: FC<IVirtualComponent> = memo(({ start, id, index, measureElement }) => {
return (
<Box
data-index={index}
data-message-id={id}
ref={measureElement}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${start}px)`,
willChange: "transform",
}}
>
<Box key={id}>
<Message id={id} />
</Box>
</Box>
);
});
// Messages.tsx
import { type FC, useCallback, useEffect, useLayoutEffect, useMemo, useRef } from "react";
import { useSelector } from "react-redux";
import { useVirtualizer } from "@tanstack/react-virtual";
import { isNotNil } from "ramda";
import { Box, Text, useScrollViewContext } from "@my-ui-lib";
import { messageIdsByChatIdFactory } from "$selectors";
import { chatsSlice, type IRootState } from "$store";
import { useNextChatContext } from "../../context";
import { MessageSkeleton } from "./MessageSkeleton";
import { useChatPagination } from "./useChatPagination";
import { useMarkAsRead } from "./useMarkAsRead";
import { VirtualComponent } from "./VirtualComponent";
// ============================================================================
// CONSTANTS
// ============================================================================
/** Высота лоадера. */
const LOADER_HEIGHT = 136 + 8; // + gap
/** Оценки высот для virtualizer. */
const MESSAGE_ESTIMATE = 100;
/** Порог «у низа», px. */
const END_THRESHOLD = 80;
/** Порог догрузки истории. */
const TOP_TRIGGER = 120;
/** Расстояние до низа для isAtBottom. */
const BOTTOM_THRESHOLD = 50;
export const Messages: FC = () => {
const { chatId } = useNextChatContext();
const scrollBarRef = useScrollViewContext();
const activeChat = useSelector((s: IRootState) => chatsSlice.entitySelectors.selectById(s, chatId));
const isNewChat = isNotNil(activeChat) && !activeChat.lastMessage;
const messageIdsSelector = useMemo(() => messageIdsByChatIdFactory(chatId), [chatId]);
const messageIds = useSelector(messageIdsSelector);
const lastMessageId = (messageIds.at(-1) as string) ?? null;
const { hasMore, hasError, isFetching, loadNextPage, isFetchingRef } = useChatPagination(
chatId,
isNewChat,
messageIds.length,
);
const hasMoreRef = useRef(hasMore);
const prevIsFetchingRef = useRef(isFetching);
const isAtBottom = scrollBarRef.current
? scrollBarRef.current.scrollHeight - scrollBarRef.current.scrollTop - scrollBarRef.current.clientHeight <
BOTTOM_THRESHOLD
: false;
const showError = hasError && !isNewChat;
useMarkAsRead({ chatId, lastMessageId, isAtBottom, historyLoaded: !!messageIds.length });
const getItemKey = (index: number) => messageIds[index];
const rowVirtualizer = useVirtualizer({
count: messageIds.length,
getScrollElement: () => scrollBarRef.current,
estimateSize: () => MESSAGE_ESTIMATE,
getItemKey: getItemKey,
anchorTo: "end",
followOnAppend: true,
scrollEndThreshold: END_THRESHOLD,
overscan: 12,
useFlushSync: false,
gap: 8,
});
useLayoutEffect(() => {
if (!lastMessageId) return;
const el = document.querySelector(`[data-message-id="${lastMessageId}"]`) as HTMLElement | null;
if (!el) return;
let lastHeight = el.offsetHeight;
const observer = new ResizeObserver(() => {
const nextHeight = el.offsetHeight;
if (nextHeight === lastHeight) return;
console.log("lastHeight", lastHeight); // -> 24
console.log("nextHeight", nextHeight); // -> 152
lastHeight = nextHeight;
rowVirtualizer.measureElement(el);
});
observer.observe(el);
return () => observer.disconnect();
}, [lastMessageId, rowVirtualizer]);
const virtualItems = rowVirtualizer.getVirtualItems();
useLayoutEffect(() => {
hasMoreRef.current = hasMore;
}, [hasMore]);
useEffect(() => {
requestAnimationFrame(() => {
if (scrollBarRef.current) {
scrollBarRef.current.scrollTop = scrollBarRef.current.scrollHeight;
}
});
}, []);
useEffect(() => {
const wasFetching = prevIsFetchingRef.current;
const nowFetching = isFetching;
if (wasFetching !== nowFetching && scrollBarRef.current) {
const scrollEl = scrollBarRef.current;
if (nowFetching) {
scrollEl.scrollTop += LOADER_HEIGHT;
} else {
scrollEl.scrollTop -= LOADER_HEIGHT;
}
}
prevIsFetchingRef.current = isFetching;
}, [isFetching]);
useEffect(() => {
if (scrollBarRef.current) {
scrollBarRef.current.addEventListener("scroll", handleScroll, { passive: true });
return () => {
scrollBarRef.current?.removeEventListener("scroll", handleScroll);
};
}
}, [scrollBarRef]);
const handleScroll = () => {
const el = scrollBarRef.current;
if (!el) return;
if (isFetchingRef.current || !hasMoreRef.current) return;
const isShortList = el.scrollHeight <= el.clientHeight;
const isNearTop = el.scrollTop < TOP_TRIGGER;
if (isShortList || isNearTop) {
loadNextPage();
}
};
const measureElementRef = useCallback(
(node: HTMLElement | null) => {
rowVirtualizer.measureElement(node);
},
[rowVirtualizer],
);
return (
<Box>
{showError && (
<Text fontWeight="semibold" t="14_20">
Упс что-то пошло не так...
<br />
Мы знаем о проблеме и работаем над её решением!
</Text>
)}
{isFetching && (
<Box style={{ height: `${LOADER_HEIGHT}px`, width: "100%" }}>
<MessageSkeleton />
</Box>
)}
<Box
ref={rowVirtualizer.containerRef}
style={{
width: "100%",
position: "relative",
paddingTop: isFetching ? `${LOADER_HEIGHT}px` : 0,
transition: "padding-top 0.2s ease-out",
height: `${rowVirtualizer.getTotalSize()}px`,
}}
>
{virtualItems.map((virtualItem) => {
const id = messageIds[virtualItem.index]!;
return (
<VirtualComponent
id={id}
index={virtualItem.index}
key={virtualItem.key}
measureElement={measureElementRef}
start={virtualItem.start}
/>
);
})}
</Box>
</Box>
);
};
Expected behavior
measureElement(el) should update the item's size in the virtualizer's cache, recalculate positions of subsequent items, and update totalSize.
How often does this bug happen?
No response
Screenshots or Videos
No response
Platform
- Windows 10
- Chrome 148.0.7778.215
- React 18.3.1
tanstack-virtual version
3.14.10
TypeScript version
5.9.3
Additional context
This issue is particularly relevant for chat applications where:
Messages start as loading skeletons and are replaced with actual content
Streaming responses grow in height over time
Images load asynchronously and change item height
Terms & Code of Conduct
Describe the bug
Title
Body
Describe the bug
When an item's content changes height dynamically (e.g., a loading skeleton replaced with actual content, or streaming text growing), calling
rowVirtualizer.measureElement(el)manually does not update the virtualizer's internal size cache. The item'svirtualItem.sizeremains stale, causing:totalSizecalculationCalling
rowVirtualizer.measure()(full re-measure) does work correctly, confirming the issue is specific to the targetedmeasureElementcall.Minimal reproducible scenario
ResizeObserverrowVirtualizer.measureElement(el)when height changesActual behavior
offsetHeightcorrectly reflects the new heightvirtualItem.sizeremains at the old value (e.g., 24 instead of 152)totalSizedoes not changeYour minimal, reproducible example
none
Steps to reproduce
Expected behavior
measureElement(el)should update the item's size in the virtualizer's cache, recalculate positions of subsequent items, and updatetotalSize.How often does this bug happen?
No response
Screenshots or Videos
No response
Platform
tanstack-virtual version
3.14.10
TypeScript version
5.9.3
Additional context
This issue is particularly relevant for chat applications where:
Messages start as loading skeletons and are replaced with actual content
Streaming responses grow in height over time
Images load asynchronously and change item height
Terms & Code of Conduct