Skip to content
6 changes: 6 additions & 0 deletions .changeset/tidy-points-heal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@tanstack/virtual-core': patch
'@tanstack/marko-virtual': patch
---

Retry a clamped end-anchor sync after the scrollable content grows during a prepend.
98 changes: 92 additions & 6 deletions packages/marko-virtual/e2e/app/e2e/chat-pretext.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,22 +193,108 @@ test('scrolling near the top auto-loads older history', async ({ page }) => {
.toBeGreaterThan(heightBefore)
})

// FIXME(#1267): deterministic on CI. The click lands before the 180ms auto history
// load fires, the prepend then resolves after the jump, and the adapter's anchor
// write is clamped against the not-yet-grown sizer — the view strands one prepend
// (~870px) above the bottom. Re-enable once #1267 is fixed.
test.fixme('Latest returns to the bottom and status flips back to At latest', async ({
test('Latest returns to the bottom and status flips back to At latest after an in-flight prepend', async ({
page,
}) => {
// Hold the example's 180ms history timer so the test controls the ordering:
// click Latest first, then release the prepend.
await page.addInitScript(() => {
const nativeSetTimeout = window.setTimeout.bind(window)
const testWindow = window as typeof window & {
__armHistoryPrependHold?: () => void
__historyPrependIsHeld?: () => boolean
__releaseHistoryPrepend?: () => void
}
let shouldHoldPrepend = false
let releasePrepend: (() => void) | null = null
let holdingTimer: number | null = null

testWindow.__armHistoryPrependHold = () => {
shouldHoldPrepend = true
}
testWindow.__historyPrependIsHeld = () => releasePrepend !== null
testWindow.__releaseHistoryPrepend = () => {
if (!releasePrepend) {
throw new Error('No history prepend timer is being held')
}
if (holdingTimer !== null) window.clearTimeout(holdingTimer)
const callback = releasePrepend
releasePrepend = null
holdingTimer = null
callback()
}

window.setTimeout = ((
handler: TimerHandler,
timeout?: number,
...args: unknown[]
) => {
if (
shouldHoldPrepend &&
timeout === 180 &&
typeof handler === 'function' &&
releasePrepend === null
) {
shouldHoldPrepend = false
const callback = handler as (...callbackArgs: unknown[]) => void
releasePrepend = () => callback(...args)
holdingTimer = nativeSetTimeout(() => {}, 60_000)
return holdingTimer
}
return nativeSetTimeout(handler, timeout, ...args)
}) as typeof window.setTimeout
})

await page.goto('/chat-pretext')
await waitForPin(page)
await page.waitForTimeout(400)
// Arm only immediately before scrolling to the top so startup timers do not
// get mistaken for the history prepend.
await page.evaluate(() => {
;(
window as Window & {
__armHistoryPrependHold?: () => void
}
).__armHistoryPrependHold?.()
})
await page.locator('.messages').evaluate((el) => {
el.scrollTop = 0
})
await expect(page.locator('[data-testid="status"]')).toHaveText(
/Reading history|Loading history/,
'Loading history',
)
await expect
.poll(() =>
page.evaluate(
() =>
(
window as Window & {
__historyPrependIsHeld?: () => boolean
}
).__historyPrependIsHeld?.() ?? false,
),
)
.toBe(true)

const heightBeforeClick = await page
.locator('.messages')
.evaluate((el) => el.scrollHeight)
await page.locator('[data-testid="latest"]').click()

// Release the prepend only after Latest has been clicked.
await page.evaluate(() => {
;(
window as Window & {
__releaseHistoryPrepend?: () => void
}
).__releaseHistoryPrepend?.()
})

await expect
.poll(() => page.locator('.messages').evaluate((el) => el.scrollHeight), {
timeout: 3000,
})
.toBeGreaterThan(heightBeforeClick)
await waitForPin(page)
await expect(page.locator('[data-testid="status"]')).toHaveText('At latest')
await expect(page.locator('[data-key^="message-4"]').last()).toBeVisible()
Expand Down
15 changes: 15 additions & 0 deletions packages/marko-virtual/src/tags/virtualizer/index.marko
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export interface Input {
<let/range = (seed.range as { startIndex: number; endIndex: number } | null)/>
<let/v = (null as Virtualizer<Element, Element> | null)/>
<let/cleanup = (null as (() => void) | null)/>
<let/retryFrame = (null as number | null)/>

<lifecycle<{}>
onMount() {
Expand Down Expand Up @@ -97,9 +98,23 @@ export interface Input {
current.setOptions({ ...current.options, ...buildOptions(input, notify) })
current._willUpdate()
notify()

// Marko commits the expanded sizer after this lifecycle callback. Retry
// the clamped anchor on the next frame, when the browser can reach it.
if (retryFrame !== null && current.targetWindow) {
current.targetWindow.cancelAnimationFrame(retryFrame)
}
retryFrame = current.targetWindow?.requestAnimationFrame(() => {
retryFrame = null
current._retryClampedAdjustment()
}) ?? null
}

onDestroy() {
if (retryFrame !== null && v?.targetWindow) {
v.targetWindow.cancelAnimationFrame(retryFrame)
}
retryFrame = null
cleanup?.()
v = null
cleanup = null
Expand Down
15 changes: 15 additions & 0 deletions packages/marko-virtual/src/tags/window-virtualizer/index.marko
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export interface Input {
<let/range = (seed.range as { startIndex: number; endIndex: number } | null)/>
<let/v = (null as Virtualizer<Window, Element> | null)/>
<let/cleanup = (null as (() => void) | null)/>
<let/retryFrame = (null as number | null)/>

<lifecycle<{}>
onMount() {
Expand Down Expand Up @@ -94,9 +95,23 @@ export interface Input {
current.setOptions({ ...current.options, ...buildOptions(input, notify) })
current._willUpdate()
notify()

// Marko commits the expanded sizer after this lifecycle callback. Retry
// the clamped anchor on the next frame, when the browser can reach it.
if (retryFrame !== null && current.targetWindow) {
current.targetWindow.cancelAnimationFrame(retryFrame)
}
retryFrame = current.targetWindow?.requestAnimationFrame(() => {
retryFrame = null
current._retryClampedAdjustment()
}) ?? null
}

onDestroy() {
if (retryFrame !== null && v?.targetWindow) {
v.targetWindow.cancelAnimationFrame(retryFrame)
}
retryFrame = null
cleanup?.()
v = null
cleanup = null
Expand Down
9 changes: 7 additions & 2 deletions packages/virtual-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1250,7 +1250,12 @@ export class Virtualizer<
// reconcile not yet retired it) still gets the anchor sync.
} else {
this._followOffsetTarget(anchor.delta)
this._scrollToOffset(this.getScrollOffset())
// The consumer may not have committed the expanded sizer yet. If
// the browser clamps this anchor sync, keep the target for the
// next update after the sizer has grown.
const target = this.getScrollOffset()
this._clampedAdjustment = this._clampedBy(target)
this._scrollToOffset(target)
}
} else {
this.scrollToEnd({ behavior: anchor.behavior })
Expand Down Expand Up @@ -1295,7 +1300,7 @@ export class Virtualizer<
// absence of one leave `_clampedAdjustment` set, so timing does not matter.
// While the element cannot take a write (`_canWriteScroll`) the record is
// kept for the next opportunity, the latest being the tail closing.
private _retryClampedAdjustment = () => {
_retryClampedAdjustment = () => {
if (
this._clampedAdjustment === null ||
!this.scrollElement ||
Expand Down
45 changes: 41 additions & 4 deletions packages/virtual-core/tests/anchor-end.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,12 +94,20 @@ function createChatVirtualizer({
virtualizer,
scrollElement,
scrollToFn,
setMessages(nextMessages: Array<{ id: string }>) {
setMessages(
nextMessages: Array<{ id: string }>,
{
growSizer = true,
update = true,
}: { growSizer?: boolean; update?: boolean } = {},
) {
currentMessages = nextMessages
virtualizer.setOptions(makeOptions())
;(scrollElement as any).scrollHeight =
nextMessages.length * itemSize + paddingEnd
virtualizer._willUpdate()
if (growSizer) {
;(scrollElement as any).scrollHeight =
nextMessages.length * itemSize + paddingEnd
}
if (update) virtualizer._willUpdate()
},
// Simulate the browser's scroll event after a write (or a gesture).
emitScroll(nextOffset: number, isScrolling = false) {
Expand All @@ -109,6 +117,35 @@ function createChatVirtualizer({
}
}

test('#1267: end-anchor sync retries after the sizer grows following a prepend', () => {
const messages = Array.from({ length: 5 }, (_, i) => ({ id: `m-${i}` }))
const { virtualizer, scrollElement, scrollToFn, setMessages } =
createChatVirtualizer({
messages,
offset: 50,
})

// The new anchor target is 150, but the old sizer still only allows 50.
setMessages([{ id: 'm--2' }, { id: 'm--1' }, ...messages], {
growSizer: false,
})

expect(scrollToFn).toHaveBeenCalledTimes(1)
expect(scrollToFn.mock.calls[0]![0]).toBe(150)
expect(virtualizer['_clampedAdjustment']).toEqual({
target: 150,
maxAtWrite: 50,
})

// Once the consumer commits the larger sizer, the target can be reached.
;(scrollElement as any).scrollHeight = 350
virtualizer._willUpdate()

expect(scrollToFn).toHaveBeenCalledTimes(2)
expect(scrollToFn.mock.calls[1]![0]).toBe(150)
expect(virtualizer['_clampedAdjustment']).toBeNull()
})

test('anchorTo:end keeps visible content stable when older items are prepended', () => {
const messages = Array.from({ length: 5 }, (_, i) => ({ id: `m-${i}` }))
const { setMessages, scrollToFn } = createChatVirtualizer({
Expand Down