Skip to content

measureElement(el) does not update item size when element height changes dynamically #1262

Description

@KobetsDev

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

  1. Render a virtualized list with dynamic item heights
  2. Add a new item that initially renders as a loading skeleton (e.g., 24px height)
  3. Replace the skeleton with actual content (e.g., 152px height)
  4. Observe the element's height change via ResizeObserver
  5. 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

  • I agree to follow this project's Code of Conduct
  • I understand that if my bug cannot be reliable reproduced in a debuggable environment, it will probably not be fixed and this issue may even be closed.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions