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
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ exports[`scroll behaviour snapshot > anchorEnd web 1`] = `
"0: 9c30ad29 d7e16aa1 2054f343 52eea5f6 29228cee 2b34f59e 4594e9e3 2a95e498 dbc275c5 d1814a7b",
"10: 77f37f3c aef54094 56c4a6ad d5af8722 59bfcd8d 83082b83 05d160db 100c06e5 d1336903 7f3041e0",
"20: 6616fe9d f780f908 13bd300f 30643489 ec2d1b0c ebb40e0f 63ed23da 68a18267 253fe61c 09a1874e",
"30: 5e6688c5 009cbdfe 8c8bdd3e 46527f28 f7133699 9b9b908c e14b74d0 8f93c51a 552b4b5b 660f3603",
"30: 5e6688c5 009cbdfe 8c8bdd3e 46527f28 a18ac097 9b9b908c e14b74d0 8f93c51a 552b4b5b 660f3603",
"40: 93dd81a7 c9c39478 622e6d21 4d60c709 e721cb06 5b6d2738 868d9e7b f3ef7742 2aa59586 886aae03",
"50: d2429922 8cc11ea8 81d0357b cfd20837 8fcc29b1 3cea4ec5 27af96a0 e46aa4df 4bc85495 18bcf75b",
]
Expand Down
106 changes: 102 additions & 4 deletions packages/virtual-core/tests/anchor-end.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,12 +98,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 =
scrollMargin + nextMessages.length * itemSize + paddingEnd
virtualizer._willUpdate()
if (growSizer) {
;(scrollElement as any).scrollHeight =
scrollMargin + 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 @@ -113,6 +121,96 @@ 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