Skip to content
Draft
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
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
105 changes: 101 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,95 @@ 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('#1267: a new explicit scroll cancels a pending clamped anchor retry', () => {
const messages = Array.from({ length: 5 }, (_, i) => ({ id: `m-${i}` }))
const { virtualizer, scrollElement, scrollToFn, setMessages } =
createChatVirtualizer({
messages,
offset: 50,
})

setMessages([{ id: 'm--2' }, { id: 'm--1' }, ...messages], {
growSizer: false,
})
expect(virtualizer['_clampedAdjustment']).not.toBeNull()

// A newer explicit command must take ownership rather than be overwritten
// when the old prepend's sizer eventually grows.
virtualizer.scrollToOffset(20)
expect(virtualizer['_clampedAdjustment']).toBeNull()
expect(scrollToFn).toHaveBeenCalledTimes(2)
;(scrollElement as any).scrollHeight = 350
virtualizer._willUpdate()

// No third write to the stale prepend target is allowed.
expect(scrollToFn).toHaveBeenCalledTimes(2)
expect(scrollToFn.mock.calls[1]![0]).toBe(20)
})

test('#1267: an explicit scroll cancels a queued prepend anchor before update', () => {
const messages = Array.from({ length: 5 }, (_, i) => ({ id: `m-${i}` }))
const { virtualizer, scrollElement, scrollToFn, setMessages } = createChatVirtualizer({
messages,
offset: 50,
})

// Queue the prepend anchor but delay _willUpdate, where that anchor would
// otherwise be written to the DOM.
setMessages([{ id: 'm--2' }, { id: 'm--1' }, ...messages], {
growSizer: false,
update: false,
})
expect(virtualizer['pendingScrollAnchor']).toEqual({
kind: 'anchor',
delta: 100,
})

// A new explicit destination supersedes the not-yet-applied anchor.
virtualizer.scrollToOffset(20)
expect(virtualizer['pendingScrollAnchor']).toBeNull()
expect(scrollToFn).toHaveBeenCalledTimes(1)
expect(scrollToFn.mock.calls[0]![0]).toBe(20)

virtualizer._willUpdate()
expect(scrollToFn).toHaveBeenCalledTimes(1)

// Growing the sizer later must not resurrect the discarded anchor either.
;(scrollElement as any).scrollHeight = 350
virtualizer._willUpdate()
expect(scrollToFn).toHaveBeenCalledTimes(1)
expect(scrollToFn.mock.calls[0]![0]).toBe(20)
})

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