Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-direct-dom-prepend-scroll-clamp.md
Original file line number Diff line number Diff line change
@@ -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).
34 changes: 29 additions & 5 deletions packages/react-virtual/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<TScrollElement, TItemElement>,
) => {
const state = directRef.current
Expand All @@ -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<TScrollElement, TItemElement>,
) => {
const state = directRef.current
if (!state.enabled || !state.container) return

applyContainerSize(instance)

const horizontal = !!instance.options.horizontal
const useTransform = state.mode === 'transform'
Expand Down Expand Up @@ -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()
})

Expand Down
Loading