diff --git a/packages/app/component-tests/timeline-virtualizer.fixture.tsx b/packages/app/component-tests/timeline-virtualizer.fixture.tsx
index 95d87d34782..c25e0d34a74 100644
--- a/packages/app/component-tests/timeline-virtualizer.fixture.tsx
+++ b/packages/app/component-tests/timeline-virtualizer.fixture.tsx
@@ -19,7 +19,7 @@ export function mountTimelineVirtualizer(input: {
document.body.appendChild(host)
function Fixture() {
- const [state, setState] = createStore({ pinned: true, ready: false })
+ const [state, setState] = createStore({ pinned: true, ready: false, grown: false })
const rows = Array.from(
{ length: input.count },
@@ -31,6 +31,7 @@ export function mountTimelineVirtualizer(input: {
let viewport!: HTMLDivElement
let content!: HTMLDivElement
let container!: HTMLDivElement
+ let scrollToEnd = () => {}
const timeline = createTimelineVirtualizer({
sessionKey: () => "cold-reveal-fixture",
@@ -66,6 +67,9 @@ export function mountTimelineVirtualizer(input: {
onUserScroll: () => {},
onHistoryScroll: () => {},
canRenderImmediately: () => input.immediate ?? false,
+ setScrollToEnd: (fn) => {
+ scrollToEnd = fn
+ },
})
const resize = new ResizeObserver((entries) => {
@@ -118,6 +122,12 @@ export function mountTimelineVirtualizer(input: {
>
Reconnect ready rows
+
+
{row().userMessageID}
diff --git a/packages/app/component-tests/timeline-virtualizer.spec.ts b/packages/app/component-tests/timeline-virtualizer.spec.ts
index 994570d6946..bece9cd84f0 100644
--- a/packages/app/component-tests/timeline-virtualizer.spec.ts
+++ b/packages/app/component-tests/timeline-virtualizer.spec.ts
@@ -89,6 +89,45 @@ story("treats the reachable end as the end under page zoom", async ({ page }) =>
await expect(controls).toHaveAttribute("data-pinned", "true")
})
+story("leaves a reader who scrolled up in place after a scroll to the zoomed end", async ({ page }) => {
+ // At 110% the end the browser reaches sits more than a pixel short of scrollHeight - clientHeight. TanStack kept
+ // scrolling toward that max on every frame for five seconds, so a row that grew meanwhile pulled the reader back down.
+ await page.evaluate(async (fixture) => {
+ document.documentElement.style.zoom = "1.1"
+ const { mountTimelineVirtualizer } = await import(fixture)
+ mountTimelineVirtualizer({ count: 40, rowHeight: 59.3, immediate: true, viewportHeight: 200.5 })
+ }, fixture)
+ const root = page.getByTestId("timeline-virtualizer-fixture")
+ await root.getByRole("button", { name: "Complete Markdown", exact: true }).click()
+ await expect(root.locator("[data-timeline-virtual-content]")).toHaveCSS("visibility", "visible")
+ const viewport = root.locator("[data-scrollable]")
+ const distance = () => viewport.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop)
+ expect(await distance()).toBeGreaterThan(1.01)
+
+ // A scroll command settles or scrolls again on its next frame.
+ const frames = () =>
+ page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))))
+
+ await root.getByRole("button", { name: "Scroll to end", exact: true }).click()
+ await frames()
+ // The wheel and the scroll it causes arrive together. A wheel alone would leave an earlier arrival at the end free to
+ // pin again once that scroll settles.
+ await viewport.evaluate(
+ (element) =>
+ new Promise((resolve) => {
+ element.addEventListener("scroll", resolve, { once: true })
+ element.dispatchEvent(new WheelEvent("wheel", { deltaY: -1, bubbles: true }))
+ element.scrollTop -= 40
+ }),
+ )
+ await expect(root.getByTestId("timeline-controls")).toHaveAttribute("data-pinned", "false")
+ const height = await viewport.evaluate((element) => element.scrollHeight)
+ await root.getByRole("button", { name: "Grow last row", exact: true }).click()
+ await expect.poll(() => viewport.evaluate((element) => element.scrollHeight)).toBeGreaterThan(height + 30)
+ await frames()
+ expect(await distance()).toBeGreaterThan(70)
+})
+
for (const input of [
{ name: "offset-only", count: 1, rowHeight: 600, reveal: { scrollTop: 0 } },
{ name: "zero-height", count: 4, rowHeight: 60, reveal: {} },
diff --git a/patches/@tanstack%2Fvirtual-core@3.17.8.patch b/patches/@tanstack%2Fvirtual-core@3.17.8.patch
index 345344693bd..80aa0baddcd 100644
--- a/patches/@tanstack%2Fvirtual-core@3.17.8.patch
+++ b/patches/@tanstack%2Fvirtual-core@3.17.8.patch
@@ -1,5 +1,5 @@
diff --git a/dist/cjs/index.cjs b/dist/cjs/index.cjs
-index e470032a9572b3ced764ca02238a8c6be435a9d4..4c25e03b5db221a78793083d97970b2d90e28efa 100644
+index e470032a9572b3ced764ca02238a8c6be435a9d4..902c47808560403bae7d365b83b25c3dc2b9d580 100644
--- a/dist/cjs/index.cjs
+++ b/dist/cjs/index.cjs
@@ -289,7 +289,7 @@ class Virtualizer {
@@ -162,6 +162,21 @@ index e470032a9572b3ced764ca02238a8c6be435a9d4..4c25e03b5db221a78793083d97970b2d
snapshot.push({
index: item.index,
key: item.key,
+@@ -1174,10 +1195,12 @@ class Virtualizer {
+ const targetOffset = offsetInfo ? offsetInfo[0] : this.scrollState.lastTargetOffset;
+ const STABLE_FRAMES = 1;
+ const targetChanged = targetOffset !== this.scrollState.lastTargetOffset;
+- if (!targetChanged && utils.approxEqual(targetOffset, this.getScrollOffset())) {
++ const reached = utils.approxEqual(targetOffset, this.getScrollOffset());
++ const restsAtEnd = targetOffset === this.getMaxScrollOffset() && targetOffset > this.getScrollOffset() && targetOffset - this.getScrollOffset() <= 4;
++ if (!targetChanged && (reached || restsAtEnd)) {
+ this.scrollState.stableFrames++;
+ if (this.scrollState.stableFrames >= STABLE_FRAMES) {
+- if (this.getScrollOffset() !== targetOffset) {
++ if (reached && this.getScrollOffset() !== targetOffset) {
+ this._scrollToOffset(targetOffset, {
+ adjustments: void 0,
+ behavior: "auto"
diff --git a/dist/cjs/index.d.cts b/dist/cjs/index.d.cts
index cc1001b8a14d06aa73a3a9e8f59870557ee9e7d6..70b202ed436ce1f3283a8465a81faf2e6e2e7983 100644
--- a/dist/cjs/index.d.cts
@@ -189,7 +204,7 @@ index 6b43c0aea7ed9eeef75cbfb1351fcbd243913bdd..7be2680967934ddfbc4583a210a3d11e
getVirtualIndexes: {
(): number[];
diff --git a/dist/esm/index.js b/dist/esm/index.js
-index 2495b26cf2c3589213546b3958eaadf2eb6b751d..1398bc7dd33690a8fe3280343b6aabdeaea6435a 100644
+index 2495b26cf2c3589213546b3958eaadf2eb6b751d..5fb5162c2a6fae89ce004631dfe2d9ff28ba98a7 100644
--- a/dist/esm/index.js
+++ b/dist/esm/index.js
@@ -287,7 +287,7 @@ class Virtualizer {
@@ -352,8 +367,23 @@ index 2495b26cf2c3589213546b3958eaadf2eb6b751d..1398bc7dd33690a8fe3280343b6aabde
snapshot.push({
index: item.index,
key: item.key,
+@@ -1172,10 +1193,12 @@ class Virtualizer {
+ const targetOffset = offsetInfo ? offsetInfo[0] : this.scrollState.lastTargetOffset;
+ const STABLE_FRAMES = 1;
+ const targetChanged = targetOffset !== this.scrollState.lastTargetOffset;
+- if (!targetChanged && approxEqual(targetOffset, this.getScrollOffset())) {
++ const reached = approxEqual(targetOffset, this.getScrollOffset());
++ const restsAtEnd = targetOffset === this.getMaxScrollOffset() && targetOffset > this.getScrollOffset() && targetOffset - this.getScrollOffset() <= 4;
++ if (!targetChanged && (reached || restsAtEnd)) {
+ this.scrollState.stableFrames++;
+ if (this.scrollState.stableFrames >= STABLE_FRAMES) {
+- if (this.getScrollOffset() !== targetOffset) {
++ if (reached && this.getScrollOffset() !== targetOffset) {
+ this._scrollToOffset(targetOffset, {
+ adjustments: void 0,
+ behavior: "auto"
diff --git a/src/index.ts b/src/index.ts
-index dc6f1010c4d4758de9c46fb8d69209e582e47171..9299287d72ce79d5a597cdc3440339ee148e6385 100644
+index dc6f1010c4d4758de9c46fb8d69209e582e47171..3c1fb0c56d1666c7c33d4f8303a345c87e6c23cf 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -567,7 +567,7 @@ export class Virtualizer<
@@ -510,7 +540,36 @@ index dc6f1010c4d4758de9c46fb8d69209e582e47171..9299287d72ce79d5a597cdc3440339ee
if (this._iosTouchEndTimerId !== null && this.targetWindow != null) {
this.targetWindow.clearTimeout(this._iosTouchEndTimerId)
this._iosTouchEndTimerId = null
-@@ -1410,16 +1428,25 @@ export class Virtualizer<
+@@ -1058,7 +1076,18 @@ export class Virtualizer<
+
+ const targetChanged = targetOffset !== this.scrollState.lastTargetOffset
+
+- if (!targetChanged && approxEqual(targetOffset, this.getScrollOffset())) {
++ const reached = approxEqual(targetOffset, this.getScrollOffset())
++ // Under page zoom the browser rounds scrollHeight and clientHeight to whole
++ // pixels but scrolls in zoomed ones, so the furthest offset it reaches can
++ // rest up to 4px short of the max they give. No write moves the view past
++ // it: waiting there for the max only polls every frame until the safety
++ // valve, and a resize meanwhile pulls a reader who scrolled away back down.
++ const restsAtEnd =
++ targetOffset === this.getMaxScrollOffset() &&
++ targetOffset > this.getScrollOffset() &&
++ targetOffset - this.getScrollOffset() <= 4
++
++ if (!targetChanged && (reached || restsAtEnd)) {
+ this.scrollState.stableFrames++
+ if (this.scrollState.stableFrames >= STABLE_FRAMES) {
+ // Final-pass exact landing. The reconcile-stable check uses a 1.01px
+@@ -1067,7 +1096,7 @@ export class Virtualizer<
+ // commit the exact target so consumers calling scrollToIndex(N)
+ // end up at the EXACT computed position of item N — matching
+ // virtuoso's 0px landing accuracy rather than our prior 0.5-1px.
+- if (this.getScrollOffset() !== targetOffset) {
++ if (reached && this.getScrollOffset() !== targetOffset) {
+ this._scrollToOffset(targetOffset, {
+ adjustments: undefined,
+ behavior: 'auto',
+@@ -1410,16 +1439,25 @@ export class Virtualizer<
this.getSize(),
this.getScrollOffset(),
this.options.lanes,
@@ -538,7 +597,7 @@ index dc6f1010c4d4758de9c46fb8d69209e582e47171..9299287d72ce79d5a597cdc3440339ee
lanes,
// Pass the typed array so binary search + forward-walk can read
// start/end directly from Float64Array, skipping the Proxy traps.
-@@ -1937,12 +1964,13 @@ export class Virtualizer<
+@@ -1937,12 +1975,13 @@ export class Virtualizer<
takeSnapshot = (): Array => {
const snapshot: Array = []
if (this.itemSizeCache.size === 0) return snapshot