Skip to content
Open
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-scrolling-issue-1258.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/virtual-core': patch
---

Fix scroll adjustment issue when reaching scroll limit.
5 changes: 5 additions & 0 deletions packages/react-virtual/e2e/app/chat/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ function App() {
const firstMessageIndexRef = React.useRef(0)
const nextMessageIndexRef = React.useRef(initialMessages.length)

const paddingEnd = Number(
new URLSearchParams(window.location.search).get('paddingEnd') ?? 0,
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const virtualizer = useVirtualizer({
count: messages.length,
getScrollElement: () => parentRef.current,
Expand All @@ -34,6 +38,7 @@ function App() {
followOnAppend: true,
scrollEndThreshold: 4,
overscan: 4,
paddingEnd,
})

React.useLayoutEffect(() => {
Expand Down
12 changes: 12 additions & 0 deletions packages/react-virtual/e2e/app/test/chat.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,3 +158,15 @@ test('chat mode keeps streaming bottom message pinned as it grows', async ({

await expect(page.locator('[data-testid="message-m-29"]')).toBeVisible()
})

test('chat mode keeps streaming bottom message pinned as it grows with paddingEnd', async ({
page,
}) => {
await page.goto('/chat/?paddingEnd=80')
await waitForEnd(page)

await page.click('#grow-last')
await waitForEnd(page)

await expect(page.locator('[data-testid="message-m-29"]')).toBeVisible()
})
106 changes: 76 additions & 30 deletions packages/virtual-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,7 @@ export class Virtualizer<
// value when the diff is < 1.5 px, distinguishing it from a real user
// scroll. The +0.5 over Math.abs lets us also absorb the +1 / -1 cases.
private _intendedScrollOffset: number | null = null
private _maxScrollOffsetAtWrite: number | null = null
shouldAdjustScrollPositionOnItemSizeChange:
| undefined
| ((
Expand Down Expand Up @@ -480,32 +481,31 @@ export class Virtualizer<
// it. We can't call getItemKey(index) here because items may
// have been removed since this node was rendered β€” the index
// could be stale and out-of-bounds in the user's data array
// (regression test in e2e/.../stale-index.spec.ts, fix #1148).
// The === comparison naturally handles the React-replaced-
// a-node-for-the-same-key case: that entry now points to a
// different node, so this loop won't match.
for (const [cacheKey, cachedNode] of this.elementsCache) {
if (cachedNode === node) {
this.elementsCache.delete(cacheKey)
break
}
}
return
}

if (!this.isIndexInRange(index)) return
// (regression test in e2e/.../stale-index.spec.ts, fix #1148).
// The === comparison naturally handles the React-replaced-
// a-node-for-the-same-key case: that entry now points to a
// different node, so this loop won't match.
for (const [cacheKey, cachedNode] of this.elementsCache) {
if (cachedNode === node) {
this.elementsCache.delete(cacheKey)
break
}
}
return
}
if (!this.isIndexInRange(index)) return
if (this.shouldMeasureDuringScroll(index)) {
this.resizeItem(
index,
this.options.measureElement(node, entry, this),
)
}
}
this.options.useAnimationFrameWithResizeObserver
? requestAnimationFrame(run)
: run()
})

if (this.shouldMeasureDuringScroll(index)) {
this.resizeItem(
index,
this.options.measureElement(node, entry, this),
)
}
}
this.options.useAnimationFrameWithResizeObserver
? requestAnimationFrame(run)
: run()
})
}))
}

Expand Down Expand Up @@ -853,11 +853,14 @@ export class Virtualizer<
// self-write β€” by the time the user has moved 1.5 px, the
// intended value will already have been consumed by a prior
// scroll event and cleared.
const intendedOffset = this._intendedScrollOffset
const maxAtWrite = this._maxScrollOffsetAtWrite

if (
this._intendedScrollOffset !== null &&
Math.abs(offset - this._intendedScrollOffset) < 1.5
intendedOffset !== null &&
Math.abs(offset - intendedOffset) < 1.5
) {
offset = this._intendedScrollOffset
offset = intendedOffset
}
this._intendedScrollOffset = null

Expand All @@ -882,12 +885,31 @@ export class Virtualizer<
// screen, and the post-touchend grace window has expired.
this._flushIosDeferredIfReady()

// Check if we hit the scroll limit we recorded at write time.
// If we landed exactly on that limit but are still short of the
// intended offset, the scroll container hadn't grown yet when the
// write was issued (e.g. paddingEnd + a growing last item). Keep
// _maxScrollOffsetAtWrite set so _willUpdate can re-issue the write
// once React has committed the new sizer size and there is room.
if (
intendedOffset !== null &&
maxAtWrite !== null &&
offset === maxAtWrite &&
offset < intendedOffset
) {
// Leave _maxScrollOffsetAtWrite intact β€” _willUpdate will clear it
// and re-issue once getMaxScrollOffset() has grown.
} else {
this._maxScrollOffsetAtWrite = null
}
Comment thread
GianlucaGuarini marked this conversation as resolved.

if (this.scrollState) {
this.scheduleScrollReconcile()
}
this.maybeNotify()
}),
)
}),
)


// Touch event listeners (iOS-aware deferral). We attach unconditionally
// β€” the listeners are passive and cheap; on non-touch devices they
Expand Down Expand Up @@ -980,6 +1002,29 @@ export class Virtualizer<
this.scrollToEnd({ behavior: followOnAppend })
}
}

// Re-issue a previously clamped scroll write now that React has committed
// and the sizer may have grown. This handles the case where
// applyScrollAdjustment wrote a scrollTop that the browser clamped because
// the sizer hadn't caught up yet (e.g. paddingEnd + a growing last item).
// _maxScrollOffsetAtWrite is kept set by the scroll callback when it detects
// a clamped self-write; we clear it here once we can satisfy the offset.
if (
this._maxScrollOffsetAtWrite !== null &&
this._intendedScrollOffset !== null &&
this.scrollElement &&
this.options.enabled
) {
const newMax = this.getMaxScrollOffset()
if (newMax > this._maxScrollOffsetAtWrite) {
const intended = this._intendedScrollOffset
this._maxScrollOffsetAtWrite = null
this._scrollToOffset(intended, {
adjustments: undefined,
behavior: undefined,
})
}
}
}

// Apply any accumulated iOS-deferred scroll adjustment, but only when we're
Expand Down Expand Up @@ -1970,6 +2015,7 @@ export class Virtualizer<
// Record the intended logical scroll target so the next scroll event
// can reconcile against subpixel rounding by the browser.
this._intendedScrollOffset = offset + (adjustments ?? 0)
this._maxScrollOffsetAtWrite = this.getMaxScrollOffset()
this.options.scrollToFn(offset, { behavior, adjustments }, this)
}

Expand Down