diff --git a/.changeset/tidy-points-heal.md b/.changeset/tidy-points-heal.md
new file mode 100644
index 000000000..7f6f63bfa
--- /dev/null
+++ b/.changeset/tidy-points-heal.md
@@ -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.
diff --git a/packages/marko-virtual/e2e/app/e2e/chat-pretext.spec.ts b/packages/marko-virtual/e2e/app/e2e/chat-pretext.spec.ts
index 824449dab..41658d362 100644
--- a/packages/marko-virtual/e2e/app/e2e/chat-pretext.spec.ts
+++ b/packages/marko-virtual/e2e/app/e2e/chat-pretext.spec.ts
@@ -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()
diff --git a/packages/marko-virtual/src/tags/virtualizer/index.marko b/packages/marko-virtual/src/tags/virtualizer/index.marko
index 720ff6e54..6920a1ba0 100644
--- a/packages/marko-virtual/src/tags/virtualizer/index.marko
+++ b/packages/marko-virtual/src/tags/virtualizer/index.marko
@@ -65,6 +65,7 @@ export interface Input {
| null)/>
void) | null)/>
+
onMount() {
@@ -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
diff --git a/packages/marko-virtual/src/tags/window-virtualizer/index.marko b/packages/marko-virtual/src/tags/window-virtualizer/index.marko
index 995c02680..c68ead9be 100644
--- a/packages/marko-virtual/src/tags/window-virtualizer/index.marko
+++ b/packages/marko-virtual/src/tags/window-virtualizer/index.marko
@@ -62,6 +62,7 @@ export interface Input {
| null)/>
void) | null)/>
+
onMount() {
@@ -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
diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts
index d007c63da..0a38683cd 100644
--- a/packages/virtual-core/src/index.ts
+++ b/packages/virtual-core/src/index.ts
@@ -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 })
@@ -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 ||
diff --git a/packages/virtual-core/tests/anchor-end.test.ts b/packages/virtual-core/tests/anchor-end.test.ts
index 62175d780..6dc475589 100644
--- a/packages/virtual-core/tests/anchor-end.test.ts
+++ b/packages/virtual-core/tests/anchor-end.test.ts
@@ -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) {
@@ -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({