From 734d94205eba6a01a09e74097d812981a6cd6a33 Mon Sep 17 00:00:00 2001
From: Luke Parker <10430890+Hona@users.noreply.github.com>
Date: Thu, 29 Oct 2026 07:41:04 +1000
Subject: [PATCH 1/3] fix(app): stop the timeline freezing on open under
desktop zoom
---
.../timeline-virtualizer.fixture.tsx | 9 ++++++--
.../timeline-virtualizer.spec.ts | 19 +++++++++++++++++
.../app/src/session/timeline/virtualizer.tsx | 21 ++++++++++++++-----
3 files changed, 42 insertions(+), 7 deletions(-)
diff --git a/packages/app/component-tests/timeline-virtualizer.fixture.tsx b/packages/app/component-tests/timeline-virtualizer.fixture.tsx
index 2f9b2e91785f..d7b667667fa7 100644
--- a/packages/app/component-tests/timeline-virtualizer.fixture.tsx
+++ b/packages/app/component-tests/timeline-virtualizer.fixture.tsx
@@ -5,7 +5,12 @@ import { render } from "solid-js/web"
import { LanguageProvider } from "../src/runtime/i18n/language"
import { createTimelineVirtualizer } from "../src/session/timeline/virtualizer"
-export function mountTimelineVirtualizer(input: { count: number; rowHeight: number; immediate?: boolean }) {
+export function mountTimelineVirtualizer(input: {
+ count: number
+ rowHeight: number
+ immediate?: boolean
+ viewportHeight?: number
+}) {
const host = document.createElement("main")
host.dataset.testid = "timeline-virtualizer-fixture"
host.dataset.scrolls = "0"
@@ -112,7 +117,7 @@ export function mountTimelineVirtualizer(input: { count: number; rowHeight: numb
>
Reconnect ready rows
-
+
false}
diff --git a/packages/app/component-tests/timeline-virtualizer.spec.ts b/packages/app/component-tests/timeline-virtualizer.spec.ts
index 7a5ba2579c63..5d2ed928f76d 100644
--- a/packages/app/component-tests/timeline-virtualizer.spec.ts
+++ b/packages/app/component-tests/timeline-virtualizer.spec.ts
@@ -55,6 +55,25 @@ story("bounds the cheap suffix and reveals only ready measured rows", async ({ p
})
})
+story("reveals at the reachable end under page zoom", async ({ page }) => {
+ // At 110% the browser stops scrolling more than a pixel short of scrollHeight - clientHeight. A mount that waited
+ // for that distance to close stayed blank, or froze the renderer in a microtask loop.
+ await page.evaluate(async (fixture) => {
+ document.documentElement.style.zoom = "1.1"
+ const { mountTimelineVirtualizer } = await import(fixture)
+ mountTimelineVirtualizer({ count: 2, rowHeight: 60.3, immediate: true, viewportHeight: 200.5 })
+ }, fixture)
+ const root = page.getByTestId("timeline-virtualizer-fixture")
+ const content = root.locator("[data-timeline-virtual-content]")
+ await root.getByRole("button", { name: "Complete Markdown", exact: true }).click()
+ await expect(content).toHaveCSS("visibility", "visible")
+ expect(
+ await root
+ .locator("[data-scrollable]")
+ .evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop),
+ ).toBeGreaterThan(1)
+})
+
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/packages/app/src/session/timeline/virtualizer.tsx b/packages/app/src/session/timeline/virtualizer.tsx
index b31e361c34eb..aec97070c737 100644
--- a/packages/app/src/session/timeline/virtualizer.tsx
+++ b/packages/app/src/session/timeline/virtualizer.tsx
@@ -611,11 +611,12 @@ export function createTimelineVirtualizer(input: Input) {
let contentObserver: MutationObserver | undefined
let viewportObserver: ResizeObserver | undefined
+ // Returns whether the view now rests at its end with that offset reported.
const pinColdBottom = () => {
- if (!active()) return
+ if (!active()) return false
const root = listRoot()
- if (!input.pinned() || !virtualContent || !root) return
+ if (!input.pinned() || !virtualContent || !root) return false
// scrollToEnd computes its target from the DOM, not the new size cache.
virtualContent.style.height = `${virtualizer.getTotalSize()}px`
@@ -624,6 +625,8 @@ export function createTimelineVirtualizer(input: Input) {
// Report after core size adjustments finish so they cannot apply a delta
// twice. This avoids waiting a frame for the native scroll event.
if (virtualizer.scrollOffset !== root.scrollTop) reportOffset?.(root.scrollTop, false)
+
+ return true
}
const pendingMeasurements = () => {
@@ -655,12 +658,20 @@ export function createTimelineVirtualizer(input: Input) {
})
if (pendingSizes.size || pendingMeasurements()) return
- pinColdBottom()
+ const pinned = input.pinned()
- if (input.pinned() && Math.abs(root.scrollHeight - root.clientHeight - root.scrollTop) > 1) return
+ // Writing the end lands on the browser's reachable end. Under page zoom that can be more than a pixel short
+ // of scrollHeight - clientHeight, and TanStack keeps the offset it wrote rather than the one reached. Waiting
+ // for either distance to close never ends: each pass reports the offset again, which queues the next pass as
+ // a microtask, and the renderer freezes.
+ if (pinned && !pinColdBottom()) return
// The scroll event must update the range before newly exposed rows can reveal.
- if (root.scrollHeight > root.clientHeight && Math.abs((virtualizer.scrollOffset ?? 0) - root.scrollTop) > 1)
+ if (
+ !pinned &&
+ root.scrollHeight > root.clientHeight &&
+ Math.abs((virtualizer.scrollOffset ?? 0) - root.scrollTop) > 1
+ )
return
if (rendering.initialTail) {
From ad6eb7f26cd97a8a14f38e593a3c016f7827f249 Mon Sep 17 00:00:00 2001
From: Luke Parker <10430890+Hona@users.noreply.github.com>
Date: Thu, 29 Oct 2026 08:00:27 +1000
Subject: [PATCH 2/3] fix(app): resume following at the reachable end under
desktop zoom
---
.../timeline-virtualizer.spec.ts | 26 ++++++++---
.../app/src/session/timeline/virtualizer.tsx | 43 ++++++++++++++++---
2 files changed, 57 insertions(+), 12 deletions(-)
diff --git a/packages/app/component-tests/timeline-virtualizer.spec.ts b/packages/app/component-tests/timeline-virtualizer.spec.ts
index 5d2ed928f76d..cdf6fafb2c6a 100644
--- a/packages/app/component-tests/timeline-virtualizer.spec.ts
+++ b/packages/app/component-tests/timeline-virtualizer.spec.ts
@@ -55,23 +55,37 @@ story("bounds the cheap suffix and reveals only ready measured rows", async ({ p
})
})
-story("reveals at the reachable end under page zoom", async ({ page }) => {
+story("treats the reachable end as the end under page zoom", async ({ page }) => {
// At 110% the browser stops scrolling more than a pixel short of scrollHeight - clientHeight. A mount that waited
- // for that distance to close stayed blank, or froze the renderer in a microtask loop.
+ // for that distance to close stayed blank, or froze the renderer in a microtask loop, and scrolling back down to
+ // the bottom never resumed following.
await page.evaluate(async (fixture) => {
document.documentElement.style.zoom = "1.1"
const { mountTimelineVirtualizer } = await import(fixture)
- mountTimelineVirtualizer({ count: 2, rowHeight: 60.3, immediate: true, viewportHeight: 200.5 })
+ mountTimelineVirtualizer({ count: 4, rowHeight: 60.3, immediate: true, viewportHeight: 200.5 })
}, fixture)
const root = page.getByTestId("timeline-virtualizer-fixture")
const content = root.locator("[data-timeline-virtual-content]")
await root.getByRole("button", { name: "Complete Markdown", exact: true }).click()
await expect(content).toHaveCSS("visibility", "visible")
+ const viewport = root.locator("[data-scrollable]")
expect(
- await root
- .locator("[data-scrollable]")
- .evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop),
+ await viewport.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop),
).toBeGreaterThan(1)
+
+ const controls = root.getByTestId("timeline-controls")
+ await viewport.dispatchEvent("wheel", { deltaY: -1 })
+ await expect(controls).toHaveAttribute("data-pinned", "false")
+ for (const top of [40, Number.MAX_SAFE_INTEGER])
+ await viewport.evaluate(
+ (element, top) =>
+ new Promise((resolve) => {
+ element.addEventListener("scroll", resolve, { once: true })
+ element.scrollTop = top
+ }),
+ top,
+ )
+ await expect(controls).toHaveAttribute("data-pinned", "true")
})
for (const input of [
diff --git a/packages/app/src/session/timeline/virtualizer.tsx b/packages/app/src/session/timeline/virtualizer.tsx
index aec97070c737..281d427c4c00 100644
--- a/packages/app/src/session/timeline/virtualizer.tsx
+++ b/packages/app/src/session/timeline/virtualizer.tsx
@@ -43,6 +43,10 @@ const pendingMarkdown = '[data-component="markdown"]:not([data-markdown-ready])'
// exactly to the end, while a one-pixel nudge upward is a deliberate move away from it.
const endEpsilon = 0.5
+// Chrome rounds scrollHeight and clientHeight to whole pixels but scrolls in zoomed ones, so under page zoom (down to
+// 25%) the furthest offset it reaches can sit up to 4px short of scrollHeight - clientHeight.
+const zoomSlack = 4
+
const upwardKeys = new Set(["up", "page-up", "home"])
const cache = new Map<
@@ -216,6 +220,8 @@ export function createTimelineVirtualizer(input: Input) {
},
)
let scrollTop = 0
+ // A scroll arrived within the zoom slack of the end and may have reached it.
+ let endPending = false
let reportOffset: ((offset: number, scrolling: boolean) => void) | undefined
let reportRect: ((rect: { width: number; height: number }) => void) | undefined
let batchingColdSizes = false
@@ -267,6 +273,11 @@ export function createTimelineVirtualizer(input: Input) {
flushTouchAdjustment()
if (!scrolling && touchStart === undefined) finishTouchScroll()
+
+ if (scrolling || touchStart !== undefined || !endPending) return
+ endPending = false
+
+ if (root && !input.pinned() && !rendering.anchor && atReachableEnd(root)) input.onPin()
})
settleColdBottom()
}
@@ -400,8 +411,7 @@ export function createTimelineVirtualizer(input: Input) {
const root = listRoot()
// Reopening a settled scroll-to-end operation can fight subsequent keyboard scrolling.
- if (root && Math.abs(root.scrollHeight - root.clientHeight - root.scrollTop) > endEpsilon)
- virtualizer.scrollToEnd()
+ if (root && !atReachableEnd(root)) virtualizer.scrollToEnd()
})
}
@@ -611,6 +621,23 @@ export function createTimelineVirtualizer(input: Input) {
let contentObserver: MutationObserver | undefined
let viewportObserver: ResizeObserver | undefined
+ // Within the zoom slack only a write finds the end the browser reaches: one that does not move proves the view is
+ // already there, and one that does is undone before anything paints.
+ function atReachableEnd(root: HTMLDivElement) {
+ const top = root.scrollTop
+ const distance = root.scrollHeight - root.clientHeight - top
+
+ if (distance <= endEpsilon) return true
+
+ if (distance > zoomSlack) return false
+ root.scrollTop = root.scrollHeight
+
+ if (root.scrollTop === top) return true
+ root.scrollTop = top
+
+ return false
+ }
+
// Returns whether the view now rests at its end with that offset reported.
const pinColdBottom = () => {
if (!active()) return false
@@ -620,7 +647,7 @@ export function createTimelineVirtualizer(input: Input) {
// scrollToEnd computes its target from the DOM, not the new size cache.
virtualContent.style.height = `${virtualizer.getTotalSize()}px`
- if (Math.abs(root.scrollHeight - root.clientHeight - root.scrollTop) > endEpsilon) virtualizer.scrollToEnd()
+ if (!atReachableEnd(root)) virtualizer.scrollToEnd()
// Report after core size adjustments finish so they cannot apply a delta
// twice. This avoids waiting a frame for the native scroll event.
@@ -660,10 +687,10 @@ export function createTimelineVirtualizer(input: Input) {
if (pendingSizes.size || pendingMeasurements()) return
const pinned = input.pinned()
- // Writing the end lands on the browser's reachable end. Under page zoom that can be more than a pixel short
+ // A pinned view rests at the end pinColdBottom reaches. Under page zoom that end can be more than a pixel short
// of scrollHeight - clientHeight, and TanStack keeps the offset it wrote rather than the one reached. Waiting
- // for either distance to close never ends: each pass reports the offset again, which queues the next pass as
- // a microtask, and the renderer freezes.
+ // for either distance to close never ends: each pass reported the offset again, which queued the next pass as
+ // a microtask, and the renderer froze.
if (pinned && !pinColdBottom()) return
// The scroll event must update the range before newly exposed rows can reveal.
@@ -848,9 +875,13 @@ export function createTimelineVirtualizer(input: Input) {
const atEnd = maxScroll - scrollTop <= endEpsilon
const arrived = scrollTop > previousTop + endEpsilon || maxScroll < previousMaxScroll
+ if (scrollTop < previousTop) endPending = false
+
// An anchor holds its tool even when reaching it lands at the end; only the user's own scroll lets it go.
if (maxScroll <= 1 || (atEnd && arrived && !rendering.anchor)) input.onPin()
else if ((pointerHeld || touchScrolling) && scrollTop < previousTop - endEpsilon) input.onUnpin()
+ // Probing the zoomed end now would cancel a scroll still in motion, so it waits for the scroll to settle.
+ else if (arrived && !rendering.anchor && maxScroll - scrollTop <= zoomSlack) endPending = true
settleColdBottom()
input.onScheduleScrollState(root)
input.onHistoryScroll()
From b53f3222a2e068c4ac6abfdf1558469c175e7afc Mon Sep 17 00:00:00 2001
From: Luke Parker <10430890+Hona@users.noreply.github.com>
Date: Thu, 29 Oct 2026 08:02:29 +1000
Subject: [PATCH 3/3] test(app): space the zoom re-follow loop
---
packages/app/component-tests/timeline-virtualizer.spec.ts | 1 +
1 file changed, 1 insertion(+)
diff --git a/packages/app/component-tests/timeline-virtualizer.spec.ts b/packages/app/component-tests/timeline-virtualizer.spec.ts
index cdf6fafb2c6a..b145ead3ee77 100644
--- a/packages/app/component-tests/timeline-virtualizer.spec.ts
+++ b/packages/app/component-tests/timeline-virtualizer.spec.ts
@@ -76,6 +76,7 @@ story("treats the reachable end as the end under page zoom", async ({ page }) =>
const controls = root.getByTestId("timeline-controls")
await viewport.dispatchEvent("wheel", { deltaY: -1 })
await expect(controls).toHaveAttribute("data-pinned", "false")
+
for (const top of [40, Number.MAX_SAFE_INTEGER])
await viewport.evaluate(
(element, top) =>