From 33f0a01a0521e277a1f9679c5c0db0409b5f26f1 Mon Sep 17 00:00:00 2001 From: Damian Pieczynski Date: Mon, 20 Jul 2026 13:19:39 +0200 Subject: [PATCH 1/2] fix(react-virtual): grow size container before scroll sync on end-anchored prepend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In directDomUpdates mode the size container's height was written by the applyDirectStyles layout effect, which runs *after* the _willUpdate effect syncs the scroll position. On an end-anchored prepend, _willUpdate writes scrollTop to the new bottom while the container is still at its old, shorter height, so the browser clamps the write to the stale scrollHeight and the list is left with a gap at the top until the next scroll. Grow the container to the new total size before _willUpdate runs. React-rendered sizers are unaffected — they receive their height during render. Co-Authored-By: Claude Opus 4.8 --- .../fix-direct-dom-prepend-scroll-clamp.md | 5 +++ packages/react-virtual/src/index.tsx | 34 ++++++++++++++++--- 2 files changed, 34 insertions(+), 5 deletions(-) create mode 100644 .changeset/fix-direct-dom-prepend-scroll-clamp.md 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..9219d2b71 --- /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() }) From b8c2cb34c589ba04b0360985cab63421066b12d1 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 20 Jul 2026 11:20:48 +0000 Subject: [PATCH 2/2] ci: apply automated fixes --- .changeset/fix-direct-dom-prepend-scroll-clamp.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/fix-direct-dom-prepend-scroll-clamp.md b/.changeset/fix-direct-dom-prepend-scroll-clamp.md index 9219d2b71..45db6a8d2 100644 --- a/.changeset/fix-direct-dom-prepend-scroll-clamp.md +++ b/.changeset/fix-direct-dom-prepend-scroll-clamp.md @@ -2,4 +2,4 @@ '@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). +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).