From 74ab3348894e529bad105963d1e03932cf4a8dbc Mon Sep 17 00:00:00 2001 From: Prayag Gaikwad Date: Tue, 13 Oct 2026 19:40:29 +0530 Subject: [PATCH 01/23] fix(virtual-core): retry clamped prepend anchor sync --- packages/virtual-core/src/index.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index cb5011392..1d7bc66de 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -1146,7 +1146,16 @@ 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() + const maxAtWrite = this.getMaxScrollOffset() + this._clampedAdjustment = + maxAtWrite !== null && target > maxAtWrite + 0.5 + ? { target, maxAtWrite } + : null + this._scrollToOffset(target, { adjustments: undefined, behavior: undefined, }) From aa1e1ec3f9add36d6c8964bbefbb60ed24e4262f Mon Sep 17 00:00:00 2001 From: Prayag Gaikwad Date: Tue, 13 Oct 2026 19:40:32 +0530 Subject: [PATCH 02/23] test(marko-virtual): cover Latest during in-flight prepend --- .../marko-virtual/e2e/app/e2e/chat-pretext.spec.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) 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..a8268f263 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,24 @@ 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, }) => { await page.goto('/chat-pretext') await waitForPin(page) + + // Arm the automatic history load, then trigger it and click Latest before + // the delayed prepend lands. This reproduces the stale-sizer scroll clamp. + await page.waitForTimeout(400) await page.locator('.messages').evaluate((el) => { el.scrollTop = 0 }) + await page.waitForTimeout(30) await expect(page.locator('[data-testid="status"]')).toHaveText( /Reading history|Loading history/, ) await page.locator('[data-testid="latest"]').click() + await waitForPin(page) await expect(page.locator('[data-testid="status"]')).toHaveText('At latest') await expect(page.locator('[data-key^="message-4"]').last()).toBeVisible() From 5362bef2c9f7a2bf59c3815d2e50914d95517cc1 Mon Sep 17 00:00:00 2001 From: Prayag Gaikwad Date: Tue, 13 Oct 2026 19:40:46 +0530 Subject: [PATCH 03/23] test(marko-virtual): make prepend race assertion deterministic --- .../e2e/app/e2e/chat-pretext.spec.ts | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) 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 a8268f263..1c349de93 100644 --- a/packages/marko-virtual/e2e/app/e2e/chat-pretext.spec.ts +++ b/packages/marko-virtual/e2e/app/e2e/chat-pretext.spec.ts @@ -198,24 +198,30 @@ test('Latest returns to the bottom and status flips back to At latest after an i }) => { await page.goto('/chat-pretext') await waitForPin(page) - - // Arm the automatic history load, then trigger it and click Latest before - // the delayed prepend lands. This reproduces the stale-sizer scroll clamp. await page.waitForTimeout(400) await page.locator('.messages').evaluate((el) => { el.scrollTop = 0 }) + await expect(page.locator('[data-testid="status"]')).toHaveText('Loading history') + + const heightBeforeClick = await page + .locator('.messages') + .evaluate((el) => el.scrollHeight) + // Click before the delayed history prepend resolves, reproducing the race. await page.waitForTimeout(30) - await expect(page.locator('[data-testid="status"]')).toHaveText( - /Reading history|Loading history/, - ) await page.locator('[data-testid="latest"]').click() + // Confirm that the prepend actually landed after Latest was clicked before + // checking the final scroll position; otherwise the test could pass early. + 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() }) - test('a reply streamed from the server grows progressively and stays pinned', async ({ page, }) => { From f3ff79505213a1acca7617ae067c7fc35a480259 Mon Sep 17 00:00:00 2001 From: Prayag Gaikwad Date: Tue, 13 Oct 2026 19:41:06 +0530 Subject: [PATCH 04/23] fix(virtual-core): simplify clamped anchor detection --- packages/virtual-core/src/index.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index 1d7bc66de..224568992 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -1152,9 +1152,7 @@ export class Virtualizer< const target = this.getScrollOffset() const maxAtWrite = this.getMaxScrollOffset() this._clampedAdjustment = - maxAtWrite !== null && target > maxAtWrite + 0.5 - ? { target, maxAtWrite } - : null + target > maxAtWrite + 0.5 ? { target, maxAtWrite } : null this._scrollToOffset(target, { adjustments: undefined, behavior: undefined, From 87659868b8ddf25e881001debef1ccb3a9ac7d23 Mon Sep 17 00:00:00 2001 From: Prayag Gaikwad Date: Tue, 13 Oct 2026 19:41:10 +0530 Subject: [PATCH 05/23] test(marko-virtual): format regression test --- .../e2e/app/e2e/chat-pretext.spec.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) 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 1c349de93..eea6dd15c 100644 --- a/packages/marko-virtual/e2e/app/e2e/chat-pretext.spec.ts +++ b/packages/marko-virtual/e2e/app/e2e/chat-pretext.spec.ts @@ -172,10 +172,10 @@ test('prepending history keeps the visible message anchored in place', async ({ expect(Math.abs(afterBox!.y - beforeBox!.y)).toBeLessThanOrEqual(2) }) -test('scrolling near the top auto-loads older history', async ({ page }) => { - await page.goto('/chat-pretext') - await waitForPin(page) - await page.waitForTimeout(400) +test('scrolling near the top auto-loads older history', async ({ page }) => { + await page.goto('/chat-pretext') + await waitForPin(page) + await page.waitForTimeout(400) const heightBefore = await page .locator('.messages') .evaluate((el) => el.scrollHeight) @@ -193,7 +193,9 @@ test('scrolling near the top auto-loads older history', async ({ page }) => { .toBeGreaterThan(heightBefore) }) -test('Latest returns to the bottom and status flips back to At latest after an in-flight prepend', async ({ +test( + 'Latest returns to the bottom and status flips back to At latest after an in-flight prepend', + async ({ page, }) => { await page.goto('/chat-pretext') @@ -202,7 +204,9 @@ test('Latest returns to the bottom and status flips back to At latest after an i await page.locator('.messages').evaluate((el) => { el.scrollTop = 0 }) - await expect(page.locator('[data-testid="status"]')).toHaveText('Loading history') + await expect(page.locator('[data-testid="status"]')).toHaveText( + 'Loading history', + ) const heightBeforeClick = await page .locator('.messages') From 8fe6d6c4e197e9ca05d04ef3427dc18922cda92a Mon Sep 17 00:00:00 2001 From: Prayag Gaikwad Date: Tue, 13 Oct 2026 19:41:15 +0530 Subject: [PATCH 06/23] test(marko-virtual): fix regression test formatting --- .../e2e/app/e2e/chat-pretext.spec.ts | 57 +++++++++---------- 1 file changed, 28 insertions(+), 29 deletions(-) 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 eea6dd15c..e8c698310 100644 --- a/packages/marko-virtual/e2e/app/e2e/chat-pretext.spec.ts +++ b/packages/marko-virtual/e2e/app/e2e/chat-pretext.spec.ts @@ -195,37 +195,36 @@ test('scrolling near the top auto-loads older history', async ({ page }) => { test( 'Latest returns to the bottom and status flips back to At latest after an in-flight prepend', - async ({ - page, -}) => { - await page.goto('/chat-pretext') - await waitForPin(page) - await page.waitForTimeout(400) - await page.locator('.messages').evaluate((el) => { - el.scrollTop = 0 - }) - await expect(page.locator('[data-testid="status"]')).toHaveText( - 'Loading history', - ) + async ({ page }) => { + await page.goto('/chat-pretext') + await waitForPin(page) + await page.waitForTimeout(400) + await page.locator('.messages').evaluate((el) => { + el.scrollTop = 0 + }) + await expect(page.locator('[data-testid="status"]')).toHaveText( + 'Loading history', + ) - const heightBeforeClick = await page - .locator('.messages') - .evaluate((el) => el.scrollHeight) - // Click before the delayed history prepend resolves, reproducing the race. - await page.waitForTimeout(30) - await page.locator('[data-testid="latest"]').click() + const heightBeforeClick = await page + .locator('.messages') + .evaluate((el) => el.scrollHeight) + // Click before the delayed history prepend resolves, reproducing the race. + await page.waitForTimeout(30) + await page.locator('[data-testid="latest"]').click() - // Confirm that the prepend actually landed after Latest was clicked before - // checking the final scroll position; otherwise the test could pass early. - 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() -}) + // Confirm that the prepend actually landed after Latest was clicked before + // checking the final scroll position; otherwise the test could pass early. + 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() + }, +) test('a reply streamed from the server grows progressively and stays pinned', async ({ page, }) => { From dbe187275e0c7569b0ec1fc73d2b86dad51bc6ef Mon Sep 17 00:00:00 2001 From: Prayag Gaikwad Date: Tue, 13 Oct 2026 19:41:20 +0530 Subject: [PATCH 07/23] test(marko-virtual): separate regression cases --- packages/marko-virtual/e2e/app/e2e/chat-pretext.spec.ts | 1 + 1 file changed, 1 insertion(+) 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 e8c698310..3ecd51807 100644 --- a/packages/marko-virtual/e2e/app/e2e/chat-pretext.spec.ts +++ b/packages/marko-virtual/e2e/app/e2e/chat-pretext.spec.ts @@ -225,6 +225,7 @@ test( await expect(page.locator('[data-key^="message-4"]').last()).toBeVisible() }, ) + test('a reply streamed from the server grows progressively and stays pinned', async ({ page, }) => { From 7f2a850406fa9f77f904f9ba9c6b52bebfec0358 Mon Sep 17 00:00:00 2001 From: Prayag Gaikwad Date: Tue, 13 Oct 2026 19:48:43 +0530 Subject: [PATCH 08/23] style(marko-virtual): format history loading test --- packages/marko-virtual/e2e/app/e2e/chat-pretext.spec.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 3ecd51807..c158f4c28 100644 --- a/packages/marko-virtual/e2e/app/e2e/chat-pretext.spec.ts +++ b/packages/marko-virtual/e2e/app/e2e/chat-pretext.spec.ts @@ -172,10 +172,10 @@ test('prepending history keeps the visible message anchored in place', async ({ expect(Math.abs(afterBox!.y - beforeBox!.y)).toBeLessThanOrEqual(2) }) -test('scrolling near the top auto-loads older history', async ({ page }) => { - await page.goto('/chat-pretext') - await waitForPin(page) - await page.waitForTimeout(400) +test('scrolling near the top auto-loads older history', async ({ page }) => { + await page.goto('/chat-pretext') + await waitForPin(page) + await page.waitForTimeout(400) const heightBefore = await page .locator('.messages') .evaluate((el) => el.scrollHeight) From b14d87d52957add60790a2f4411b6c11fc5a204c Mon Sep 17 00:00:00 2001 From: Prayag Gaikwad Date: Tue, 13 Oct 2026 19:49:53 +0530 Subject: [PATCH 09/23] chore: add virtual-core patch changeset --- .changeset/tidy-points-heal.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/tidy-points-heal.md diff --git a/.changeset/tidy-points-heal.md b/.changeset/tidy-points-heal.md new file mode 100644 index 000000000..b75051b8b --- /dev/null +++ b/.changeset/tidy-points-heal.md @@ -0,0 +1,5 @@ +--- +'@tanstack/virtual-core': patch +--- + +Retry a clamped end-anchor sync after the scrollable content grows during a prepend. From 80ba5b2fec3b2e6546f547cfcf252260ee0a4da9 Mon Sep 17 00:00:00 2001 From: Prayag Gaikwad Date: Tue, 13 Oct 2026 19:50:19 +0530 Subject: [PATCH 10/23] chore: include Marko adapter in patch changeset --- .changeset/tidy-points-heal.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.changeset/tidy-points-heal.md b/.changeset/tidy-points-heal.md index b75051b8b..7f6f63bfa 100644 --- a/.changeset/tidy-points-heal.md +++ b/.changeset/tidy-points-heal.md @@ -1,5 +1,6 @@ --- '@tanstack/virtual-core': patch +'@tanstack/marko-virtual': patch --- Retry a clamped end-anchor sync after the scrollable content grows during a prepend. From b67a2c8877ec444efc5dd2ecdca8160544bd176a Mon Sep 17 00:00:00 2001 From: Prayag Gaikwad Date: Tue, 13 Oct 2026 19:52:07 +0530 Subject: [PATCH 11/23] test(virtual-core): cover clamped anchor sync after prepend --- packages/virtual-core/tests/index.test.ts | 42 +++++++++++++++++++++-- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/packages/virtual-core/tests/index.test.ts b/packages/virtual-core/tests/index.test.ts index 290d810a3..05f6baa6a 100644 --- a/packages/virtual-core/tests/index.test.ts +++ b/packages/virtual-core/tests/index.test.ts @@ -3899,11 +3899,16 @@ function createChatVirtualizer({ virtualizer, scrollElement, scrollToFn, - setMessages(nextMessages: Array<{ id: string }>) { + setMessages( + nextMessages: Array<{ id: string }>, + { growSizer = true }: { growSizer?: boolean } = {}, + ) { currentMessages = nextMessages virtualizer.setOptions(makeOptions()) - ;(scrollElement as any).scrollHeight = - nextMessages.length * itemSize + paddingEnd + if (growSizer) { + ;(scrollElement as any).scrollHeight = + nextMessages.length * itemSize + paddingEnd + } virtualizer._willUpdate() }, // Simulate the browser's scroll event after a write (or a gesture). @@ -3914,6 +3919,37 @@ 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('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({ From 7e654ce7d5c4c41120efa80cd40d00a3bd87cc57 Mon Sep 17 00:00:00 2001 From: Prayag Gaikwad Date: Tue, 13 Oct 2026 20:05:43 +0530 Subject: [PATCH 12/23] fix(virtual-core): cancel stale clamp retry on new scroll --- packages/virtual-core/src/index.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index 224568992..15b5ae8a3 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -2187,6 +2187,10 @@ export class Virtualizer< behavior: ScrollBehavior, kind: 'absolute' | 'relative', ) { + // A new explicit scroll command takes ownership of the viewport. Do not + // let a previously clamped prepend/resize compensation retry later and + // pull the user back toward its stale target. + this._clampedAdjustment = null if (kind === 'absolute') { this._iosDeferredAdjustment = 0 } From b68a323e09422c9106b6f6f5382d060e7f63c202 Mon Sep 17 00:00:00 2001 From: Prayag Gaikwad Date: Tue, 13 Oct 2026 20:05:48 +0530 Subject: [PATCH 13/23] test(virtual-core): cover scroll overriding pending clamp retry --- packages/virtual-core/tests/index.test.ts | 27 +++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/packages/virtual-core/tests/index.test.ts b/packages/virtual-core/tests/index.test.ts index 05f6baa6a..eb641c1bb 100644 --- a/packages/virtual-core/tests/index.test.ts +++ b/packages/virtual-core/tests/index.test.ts @@ -3950,6 +3950,33 @@ test('#1267: end-anchor sync retries after the sizer grows following a prepend', 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('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({ From 111c3bcd3f26f3d2db61a26a28838136c6282dce Mon Sep 17 00:00:00 2001 From: Prayag Gaikwad Date: Tue, 13 Oct 2026 20:12:26 +0530 Subject: [PATCH 14/23] test(marko-virtual): gate prepend until after Latest click --- .../e2e/app/e2e/chat-pretext.spec.ts | 63 +++++++++++++++++-- 1 file changed, 59 insertions(+), 4 deletions(-) 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 c158f4c28..7a3cd1285 100644 --- a/packages/marko-virtual/e2e/app/e2e/chat-pretext.spec.ts +++ b/packages/marko-virtual/e2e/app/e2e/chat-pretext.spec.ts @@ -196,6 +196,45 @@ test('scrolling near the top auto-loads older history', async ({ page }) => { 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 & { + __historyPrependIsHeld?: () => boolean + __releaseHistoryPrepend?: () => void + } + let releasePrepend: (() => void) | null = null + let holdingTimer: number | null = null + + 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 ( + timeout === 180 && + typeof handler === 'function' && + releasePrepend === null + ) { + 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) @@ -205,16 +244,32 @@ test( await expect(page.locator('[data-testid="status"]')).toHaveText( '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) - // Click before the delayed history prepend resolves, reproducing the race. - await page.waitForTimeout(30) await page.locator('[data-testid="latest"]').click() - // Confirm that the prepend actually landed after Latest was clicked before - // checking the final scroll position; otherwise the test could pass early. + // 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, From 5f3214f8f347dcfd2de0a3c3fa412adc092f5203 Mon Sep 17 00:00:00 2001 From: Prayag Gaikwad Date: Tue, 13 Oct 2026 20:13:27 +0530 Subject: [PATCH 15/23] style(marko-virtual): format gated prepend test --- packages/marko-virtual/e2e/app/e2e/chat-pretext.spec.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 7a3cd1285..57c6b4260 100644 --- a/packages/marko-virtual/e2e/app/e2e/chat-pretext.spec.ts +++ b/packages/marko-virtual/e2e/app/e2e/chat-pretext.spec.ts @@ -220,7 +220,11 @@ test( callback() } - window.setTimeout = ((handler: TimerHandler, timeout?: number, ...args: unknown[]) => { + window.setTimeout = (( + handler: TimerHandler, + timeout?: number, + ...args: unknown[] + ) => { if ( timeout === 180 && typeof handler === 'function' && From bb9c7cdac87f16c16e121c56f816ad7e7f2b5c58 Mon Sep 17 00:00:00 2001 From: Prayag Gaikwad Date: Tue, 13 Oct 2026 20:31:25 +0530 Subject: [PATCH 16/23] test(marko-virtual): arm prepend timer gate on demand --- .../e2e/app/e2e/chat-pretext.spec.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) 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 57c6b4260..b496d657f 100644 --- a/packages/marko-virtual/e2e/app/e2e/chat-pretext.spec.ts +++ b/packages/marko-virtual/e2e/app/e2e/chat-pretext.spec.ts @@ -202,12 +202,17 @@ test( 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) { @@ -226,10 +231,12 @@ test( ...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) @@ -242,6 +249,15 @@ test( 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 }) From 8848c9a42ce15bee2c0a9a47974508fafab82d01 Mon Sep 17 00:00:00 2001 From: Prayag Gaikwad Date: Tue, 13 Oct 2026 21:10:51 +0530 Subject: [PATCH 17/23] fix(virtual-core): discard pending anchor on explicit scroll --- packages/virtual-core/src/index.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index 15b5ae8a3..891c46095 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -2188,8 +2188,9 @@ export class Virtualizer< kind: 'absolute' | 'relative', ) { // A new explicit scroll command takes ownership of the viewport. Do not - // let a previously clamped prepend/resize compensation retry later and - // pull the user back toward its stale target. + // 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 From 9dd259fcf0b8282bf3b023b4240bf003560f9ca6 Mon Sep 17 00:00:00 2001 From: Prayag Gaikwad Date: Tue, 13 Oct 2026 21:11:11 +0530 Subject: [PATCH 18/23] test(virtual-core): cover explicit scroll canceling queued anchor --- packages/virtual-core/tests/index.test.ts | 42 +++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/packages/virtual-core/tests/index.test.ts b/packages/virtual-core/tests/index.test.ts index eb641c1bb..2ebcdfa3e 100644 --- a/packages/virtual-core/tests/index.test.ts +++ b/packages/virtual-core/tests/index.test.ts @@ -3901,7 +3901,10 @@ function createChatVirtualizer({ scrollToFn, setMessages( nextMessages: Array<{ id: string }>, - { growSizer = true }: { growSizer?: boolean } = {}, + { + growSizer = true, + update = true, + }: { growSizer?: boolean; update?: boolean } = {}, ) { currentMessages = nextMessages virtualizer.setOptions(makeOptions()) @@ -3909,7 +3912,7 @@ function createChatVirtualizer({ ;(scrollElement as any).scrollHeight = nextMessages.length * itemSize + paddingEnd } - virtualizer._willUpdate() + if (update) virtualizer._willUpdate() }, // Simulate the browser's scroll event after a write (or a gesture). emitScroll(nextOffset: number, isScrolling = false) { @@ -3977,6 +3980,41 @@ test('#1267: a new explicit scroll cancels a pending clamped anchor retry', () = 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({ From 5a660d7f4bca7145114f4a2af2f1c518c46f7039 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 10:46:20 +0000 Subject: [PATCH 19/23] ci: apply automated fixes --- .../e2e/app/e2e/chat-pretext.spec.ts | 199 +++++++++--------- packages/virtual-core/tests/index.test.ts | 1 - 2 files changed, 100 insertions(+), 100 deletions(-) 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 b496d657f..bf870e62f 100644 --- a/packages/marko-virtual/e2e/app/e2e/chat-pretext.spec.ts +++ b/packages/marko-virtual/e2e/app/e2e/chat-pretext.spec.ts @@ -193,113 +193,114 @@ test('scrolling near the top auto-loads older history', async ({ page }) => { .toBeGreaterThan(heightBefore) }) -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 +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() + 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 - }) + 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( - 'Loading history', - ) - await expect - .poll(() => - page.evaluate( - () => - (window as Window & { + 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( + 'Loading history', + ) + await expect + .poll(() => + page.evaluate( + () => + ( + window as Window & { __historyPrependIsHeld?: () => boolean - }).__historyPrependIsHeld?.() ?? false, - ), - ) - .toBe(true) + } + ).__historyPrependIsHeld?.() ?? false, + ), + ) + .toBe(true) - const heightBeforeClick = await page - .locator('.messages') - .evaluate((el) => el.scrollHeight) - await page.locator('[data-testid="latest"]').click() + 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?.() - }) + // 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() - }, -) + 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() +}) test('a reply streamed from the server grows progressively and stays pinned', async ({ page, diff --git a/packages/virtual-core/tests/index.test.ts b/packages/virtual-core/tests/index.test.ts index 2ebcdfa3e..522cf7a8d 100644 --- a/packages/virtual-core/tests/index.test.ts +++ b/packages/virtual-core/tests/index.test.ts @@ -3971,7 +3971,6 @@ test('#1267: a new explicit scroll cancels a pending clamped anchor retry', () = virtualizer.scrollToOffset(20) expect(virtualizer['_clampedAdjustment']).toBeNull() expect(scrollToFn).toHaveBeenCalledTimes(2) - ;(scrollElement as any).scrollHeight = 350 virtualizer._willUpdate() From ca1701b373cb2e686ab3fbdd06d14ffcd8367fe7 Mon Sep 17 00:00:00 2001 From: Prayag Gaikwad Date: Tue, 20 Oct 2026 16:50:49 +0530 Subject: [PATCH 20/23] fix(virtual-core): account for held offset in clamped anchor detection --- packages/virtual-core/src/index.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index 891c46095..63989b186 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -1150,9 +1150,7 @@ export class Virtualizer< // 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() - const maxAtWrite = this.getMaxScrollOffset() - this._clampedAdjustment = - target > maxAtWrite + 0.5 ? { target, maxAtWrite } : null + this._clampedAdjustment = this._clampedBy(target) this._scrollToOffset(target, { adjustments: undefined, behavior: undefined, From 462100f1cbb31fd97b8e3bf6cd3a40108afbc383 Mon Sep 17 00:00:00 2001 From: Prayag Gaikwad Date: Tue, 20 Oct 2026 16:54:08 +0530 Subject: [PATCH 21/23] fix(virtual-core): expose clamped retry for adapter commits --- packages/virtual-core/src/index.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index 63989b186..3a76cd559 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -1173,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 || From ba2ba74add558e9ed731a274ab8135746c33fbdf Mon Sep 17 00:00:00 2001 From: Prayag Gaikwad Date: Tue, 20 Oct 2026 16:54:12 +0530 Subject: [PATCH 22/23] fix(marko-virtual): retry clamped anchor after sizer commit --- .../src/tags/virtualizer/index.marko | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/marko-virtual/src/tags/virtualizer/index.marko b/packages/marko-virtual/src/tags/virtualizer/index.marko index 720ff6e54..9430a41f4 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 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 From 5b6928dd2d7d78afba114c0a23dcbe621e14e1a4 Mon Sep 17 00:00:00 2001 From: Prayag Gaikwad Date: Tue, 20 Oct 2026 16:54:20 +0530 Subject: [PATCH 23/23] fix(marko-virtual): retry window anchor after sizer commit --- .../src/tags/window-virtualizer/index.marko | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/marko-virtual/src/tags/window-virtualizer/index.marko b/packages/marko-virtual/src/tags/window-virtualizer/index.marko index 995c02680..1946d2707 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 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