Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
74ab334
fix(virtual-core): retry clamped prepend anchor sync
prayaggaikwad96 Oct 13, 2026
aa1e1ec
test(marko-virtual): cover Latest during in-flight prepend
prayaggaikwad96 Oct 13, 2026
5362bef
test(marko-virtual): make prepend race assertion deterministic
prayaggaikwad96 Oct 13, 2026
f3ff795
fix(virtual-core): simplify clamped anchor detection
prayaggaikwad96 Oct 13, 2026
8765986
test(marko-virtual): format regression test
prayaggaikwad96 Oct 13, 2026
8fe6d6c
test(marko-virtual): fix regression test formatting
prayaggaikwad96 Oct 13, 2026
dbe1872
test(marko-virtual): separate regression cases
prayaggaikwad96 Oct 13, 2026
7f2a850
style(marko-virtual): format history loading test
prayaggaikwad96 Oct 13, 2026
b14d87d
chore: add virtual-core patch changeset
prayaggaikwad96 Oct 13, 2026
80ba5b2
chore: include Marko adapter in patch changeset
prayaggaikwad96 Oct 13, 2026
b67a2c8
test(virtual-core): cover clamped anchor sync after prepend
prayaggaikwad96 Oct 13, 2026
7e654ce
fix(virtual-core): cancel stale clamp retry on new scroll
prayaggaikwad96 Oct 13, 2026
b68a323
test(virtual-core): cover scroll overriding pending clamp retry
prayaggaikwad96 Oct 13, 2026
111c3bc
test(marko-virtual): gate prepend until after Latest click
prayaggaikwad96 Oct 13, 2026
5f3214f
style(marko-virtual): format gated prepend test
prayaggaikwad96 Oct 13, 2026
bb9c7cd
test(marko-virtual): arm prepend timer gate on demand
prayaggaikwad96 Oct 13, 2026
8848c9a
fix(virtual-core): discard pending anchor on explicit scroll
prayaggaikwad96 Oct 13, 2026
9dd259f
test(virtual-core): cover explicit scroll canceling queued anchor
prayaggaikwad96 Oct 13, 2026
5a660d7
ci: apply automated fixes
autofix-ci[bot] Oct 20, 2026
ca1701b
fix(virtual-core): account for held offset in clamped anchor detection
prayaggaikwad96 Oct 20, 2026
462100f
fix(virtual-core): expose clamped retry for adapter commits
prayaggaikwad96 Oct 20, 2026
ba2ba74
fix(marko-virtual): retry clamped anchor after sizer commit
prayaggaikwad96 Oct 20, 2026
5b6928d
fix(marko-virtual): retry window anchor after sizer commit
prayaggaikwad96 Oct 20, 2026
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.
100 changes: 94 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,110 @@ 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. A wall-clock sleep alone can
// let the prepend finish before the click on a slow CI worker.
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 the hook only immediately before scrolling to the top, so unrelated
// 180ms timers during app startup cannot be mistaken for this 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, guaranteeing the
// delayed content growth happens after the explicit end-scroll command.
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 new sizer after this lifecycle callback. Retry once
// on the next frame so the browser has the expanded scroll range.
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 new sizer after this lifecycle callback. Retry once
// on the next frame so the browser has the expanded scroll range.
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
16 changes: 14 additions & 2 deletions packages/virtual-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1146,7 +1146,12 @@ export class Virtualizer<
// A smooth scroll that has already landed (offset at its target,
// reconcile not yet retired it) still gets the anchor sync.
} else {
this._scrollToOffset(this.getScrollOffset(), {
// The consumer may not have committed the new sizer size yet. If the
// browser clamps this anchor sync to the old scroll max, keep the
// intended target so _willUpdate can retry after the sizer grows.
const target = this.getScrollOffset()
this._clampedAdjustment = this._clampedBy(target)
this._scrollToOffset(target, {
adjustments: undefined,
behavior: undefined,
})
Expand All @@ -1168,7 +1173,9 @@ export class Virtualizer<
// 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 = () => {
// Framework adapters may call this after their rendered sizer commits.
// The pending target is cleared by any newer explicit scroll command.
_retryClampedAdjustment = () => {
if (
this._clampedAdjustment === null ||
!this.scrollElement ||
Expand Down Expand Up @@ -2180,6 +2187,11 @@ export class Virtualizer<
behavior: ScrollBehavior,
kind: 'absolute' | 'relative',
) {
// A new explicit scroll command takes ownership of the viewport. Do not
// let a queued prepend/follow anchor or a previously clamped adjustment
// retry later and pull the user back toward its stale target.
this.pendingScrollAnchor = null
this._clampedAdjustment = null
if (kind === 'absolute') {
this._iosDeferredAdjustment = 0
}
Expand Down
108 changes: 104 additions & 4 deletions packages/virtual-core/tests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3899,12 +3899,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 @@ -3914,6 +3922,98 @@ 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.
// This simulates the adapter's layout effect running before the prepend's
// larger scrollable container has been committed.
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, which is 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 must supersede 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