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

Recover the bottom pin when the browser clamps an end-anchored scroll compensation write. `resizeItem` compensates a size change by writing `scrollTop` before the consumer has committed the new total size, so when the grown item does not itself extend the scroll range the browser clamps the write to the old maximum and the viewport is left short of the end with no scroll event to correct it. Two cases hit this: `paddingEnd > 0` with a growing last item, where the overflowing item only extends `scrollHeight` to its own end and the clamp lands exactly `paddingEnd` short ([#1258](https://github.com/TanStack/virtual/issues/1258)); and a row above the last one growing while the last row keeps its size, under `directDomUpdates` ([#1266](https://github.com/TanStack/virtual/issues/1266)). A compensation write whose target exceeds the scroll maximum at write time is now recorded as clamped and re-issued once the sizer has grown β€” right after `notify` for consumers that size the container synchronously in `onChange`, and from `_willUpdate` for consumers that size it during a render. The clamped read-back keeps the retry pending; any other scroll event cancels it, so a user reading history is never yanked.
10 changes: 10 additions & 0 deletions packages/react-virtual/e2e/app/chat-resize/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>
89 changes: 89 additions & 0 deletions packages/react-virtual/e2e/app/chat-resize/main.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// Exact reproduction of #1266, adapted from PR #1265 by @tigerBeA: an
// end-anchored direct-DOM chat where a row ABOVE the last one grows. The last
// row is exactly the viewport height and keeps its size, so its overflow cannot
// extend the scroll range before the sizer is updated, and with `useFlushSync:
// false` and an unchanged range nothing re-renders after the resize.
import React from 'react'
import { createRoot } from 'react-dom/client'
import { useVirtualizer } from '@tanstack/react-virtual'

const VIEWPORT_HEIGHT = 300
const initialMessages = Array.from({ length: 8 }, (_, index) => ({
id: `m-${index}`,
height: index === 7 ? VIEWPORT_HEIGHT : 50,
}))

function App() {
const [messages, setMessages] = React.useState(initialMessages)
const parentRef = React.useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
count: messages.length,
getScrollElement: () => parentRef.current,
getItemKey: (index) => messages[index]!.id,
estimateSize: (index) => initialMessages[index]!.height,
anchorTo: 'end',
followOnAppend: true,
scrollEndThreshold: 4,
overscan: 4,
directDomUpdates: true,
useFlushSync: false,
})

React.useLayoutEffect(() => {
virtualizer.scrollToEnd()
}, [virtualizer])

return (
<div>
<button
id="grow-previous"
onClick={() => {
setMessages((current) =>
current.map((message, index) =>
index === current.length - 2
? { ...message, height: message.height + 24 }
: message,
),
)
}}
>
Grow previous
</button>
<div
ref={parentRef}
id="scroll-container"
style={{
height: VIEWPORT_HEIGHT,
width: 420,
overflow: 'auto',
overflowAnchor: 'none',
}}
>
<div
ref={virtualizer.containerRef}
style={{ position: 'relative', width: '100%' }}
>
{virtualizer.getVirtualItems().map((item) => (
<div
key={item.key}
ref={virtualizer.measureElement}
data-index={item.index}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
}}
>
<div style={{ height: messages[item.index]!.height }}>
Message {messages[item.index]!.id}
</div>
</div>
))}
</div>
</div>
</div>
)
}

createRoot(document.getElementById('root')!).render(<App />)
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
34 changes: 34 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,37 @@ 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()
})

// #1266 β€” adapted from PR #1265 by @tigerBeA. Direct DOM updates, no flushSync,
// and a row ABOVE the last one grows: the compensation write is clamped against
// the old scroll range, and with an unchanged range nothing re-renders afterwards,
// so only the post-notify retry in core can recover the lost distance.
test('direct DOM chat stays pinned when a previous message grows without a re-render', async ({
page,
}) => {
await page.goto('/chat-resize/')
await waitForEnd(page)
// Let the initial scrollToEnd's isScrolling debounce settle first. Its reset
// triggers a re-render that would run _willUpdate and mask a missing retry.
await page.waitForTimeout(300)
const before = await getScrollState(page)

await page.click('#grow-previous')
await expect
.poll(async () => (await getScrollState(page)).scrollHeight)
.toBe(before.scrollHeight + 24)
await waitForEnd(page)
expect((await getScrollState(page)).scrollTop).toBe(before.scrollTop + 24)
})
1 change: 1 addition & 0 deletions packages/react-virtual/e2e/app/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export default defineConfig({
scroll: path.resolve(__dirname, 'scroll/index.html'),
'scroll-anchor': path.resolve(__dirname, 'scroll-anchor/index.html'),
chat: path.resolve(__dirname, 'chat/index.html'),
'chat-resize': path.resolve(__dirname, 'chat-resize/index.html'),
'measure-element': path.resolve(
__dirname,
'measure-element/index.html',
Expand Down
72 changes: 72 additions & 0 deletions packages/virtual-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,16 @@ 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
// A compensation write from `applyScrollAdjustment` whose target exceeded
// the element's scroll max at the moment of the write. The browser clamps
// such a write because the consumer's sizer has not grown yet: an
// end-anchored item growing at the bottom only extends `scrollHeight` to
// its own end, so with `paddingEnd > 0` the clamp lands exactly
// `paddingEnd` short of the target (#1258). `_willUpdate` re-issues the
// write once the sizer has caught up; the clamped read-back keeps it
// pending, any other scroll event (a real gesture) cancels it.
private _clampedAdjustment: { target: number; maxAtWrite: number } | null =
null
shouldAdjustScrollPositionOnItemSizeChange:
| undefined
| ((
Expand Down Expand Up @@ -706,6 +716,18 @@ export class Virtualizer<
this._iosDeferredAdjustment += delta
return false
} else {
const target = this.getScrollOffset() + this.scrollAdjustments + delta
// Guarded so a bare test double without `scrollHeight` / `document`
// does not crash in `getMaxScrollOffset`.
const el = this.scrollElement
const maxAtWrite =
el !== null && ('scrollHeight' in el || 'document' in el)
? this.getMaxScrollOffset()
: null
this._clampedAdjustment =
maxAtWrite !== null && target > maxAtWrite + 0.5
? { target, maxAtWrite }
: null
this._scrollToOffset(this.getScrollOffset(), {
adjustments: (this.scrollAdjustments += delta),
behavior,
Expand Down Expand Up @@ -787,6 +809,7 @@ export class Virtualizer<
this._iosDeferredAdjustment = 0
this._iosTouching = false
this._iosJustTouchEnded = false
this._clampedAdjustment = null
this.scrollElement = null
this.targetWindow = null
}
Expand Down Expand Up @@ -861,6 +884,17 @@ export class Virtualizer<
}
this._intendedScrollOffset = null

// A pending clamped compensation write (#1258) survives only its
// own read-back, which the browser reports at the scroll max we
// saw at write time. Anything else is a real gesture (or the write
// landing after all), so drop it rather than yank the user later.
if (
this._clampedAdjustment !== null &&
Math.abs(offset - this._clampedAdjustment.maxAtWrite) >= 1.5
) {
this._clampedAdjustment = null
}

this.scrollAdjustments = 0
// If the offset hasn't moved, this is the echo of our own
// adjustment write β€” `applyScrollAdjustment` already folded it
Expand Down Expand Up @@ -980,6 +1014,39 @@ export class Virtualizer<
this.scrollToEnd({ behavior: followOnAppend })
}
}

// The consumer has committed the new total size by now, so a clamped
// compensation write may have room (#1258).
this._retryClampedAdjustment()
}

// Re-issue a compensation write the browser clamped because the sizer had
// not grown yet (#1258, #1266). Called after `notify` in `resizeItem`,
// which covers consumers that size the container synchronously inside
// `onChange` (direct DOM updates, flushSync renders β€” where no re-render
// may follow at all), and from `_willUpdate` for consumers that size it
// during an asynchronous render. Both the clamped read-back and the
// absence of one leave `_clampedAdjustment` set, so timing does not matter.
private _retryClampedAdjustment = () => {
if (
this._clampedAdjustment === null ||
!this.scrollElement ||
!this.options.enabled
) {
return
}
const { target, maxAtWrite } = this._clampedAdjustment
const max = this.getMaxScrollOffset()
if (max > maxAtWrite + 0.5) {
// Still short (the sizer grew only partially): stay pending against
// the new max so the next opportunity retries.
this._clampedAdjustment =
target > max + 0.5 ? { target, maxAtWrite: max } : null
this._scrollToOffset(target, {
adjustments: undefined,
behavior: undefined,
})
}
}

// Apply any accumulated iOS-deferred scroll adjustment, but only when we're
Expand Down Expand Up @@ -1646,6 +1713,11 @@ export class Virtualizer<
// land in one paint. When nothing moved (or the write was deferred on
// iOS), keep the cheaper async notify.
this.notify(adjustedSync)
// A consumer that grows the sizer synchronously inside `onChange`
// (direct DOM updates) may never re-render when the range is
// unchanged, so retry a clamped write here rather than only in
// `_willUpdate` (#1266).
this._retryClampedAdjustment()
}
}

Expand Down
Loading
Loading