diff --git a/.changeset/fix-direct-dom-prepend-scroll-clamp.md b/.changeset/fix-direct-dom-prepend-scroll-clamp.md new file mode 100644 index 000000000..45db6a8d2 --- /dev/null +++ b/.changeset/fix-direct-dom-prepend-scroll-clamp.md @@ -0,0 +1,5 @@ +--- +'@tanstack/react-virtual': patch +--- + +Fix a gap at the top of the list after an end-anchored prepend in `directDomUpdates` mode. The prepend grows the total size and bumps `scrollOffset` to the new bottom in the same pass, but the size container's height was written _after_ `_willUpdate` synced the scroll position — so the browser clamped the `scrollTop` write to the stale (shorter) `scrollHeight`, leaving whitespace at the top until the next scroll. The container is now grown before the scroll sync. Only affected `directDomUpdates` mode (React-rendered sizers receive their height during render). diff --git a/packages/react-virtual/src/index.tsx b/packages/react-virtual/src/index.tsx index a78fbdf60..737ad53fc 100644 --- a/packages/react-virtual/src/index.tsx +++ b/packages/react-virtual/src/index.tsx @@ -103,11 +103,15 @@ function useVirtualizerBase< directRef.current.enabled = directDomUpdates directRef.current.mode = directDomUpdatesMode - // Writes container size + item positions to the DOM. Idempotent — guarded - // by lastSize / lastPositions. Called from onChange (covers scroll-driven - // updates) and from a layout effect (covers post-render commits when refs - // have just registered new items in elementsCache). - const applyDirectStyles = ( + // Writes the size container's total extent to the DOM. Idempotent — guarded + // by lastSize. Split out from applyDirectStyles so it can run *before* the + // scroll-position sync in the _willUpdate effect: an end-anchored prepend + // grows the total and bumps scrollOffset in the same pass, and if scrollTop + // is written before the container has grown the browser clamps it to the + // stale (shorter) scrollHeight, leaving a gap at the top until the next + // scroll (visible only in directDomUpdates mode — React-rendered sizers get + // their height during render). + const applyContainerSize = ( instance: Virtualizer, ) => { const state = directRef.current @@ -119,6 +123,19 @@ function useVirtualizerBase< const sizeAxis = instance.options.horizontal ? 'width' : 'height' state.container.style[sizeAxis] = `${totalSize}px` } + } + + // Writes container size + item positions to the DOM. Idempotent — guarded + // by lastSize / lastPositions. Called from onChange (covers scroll-driven + // updates) and from a layout effect (covers post-render commits when refs + // have just registered new items in elementsCache). + const applyDirectStyles = ( + instance: Virtualizer, + ) => { + const state = directRef.current + if (!state.enabled || !state.container) return + + applyContainerSize(instance) const horizontal = !!instance.options.horizontal const useTransform = state.mode === 'transform' @@ -205,6 +222,13 @@ function useVirtualizerBase< }, []) useIsomorphicLayoutEffect(() => { + // Grow the size container to the new total BEFORE _willUpdate syncs the + // scroll position. On an end-anchored prepend the scroll target lands at + // the new bottom; if the container is still at its old (shorter) height the + // browser clamps the scrollTop write and the list is left with a gap at the + // top until the next scroll. Positions are written afterwards by the + // applyDirectStyles effect below. + applyContainerSize(instance) return instance._willUpdate() })