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..b145ead3ee77 100644
--- a/packages/app/component-tests/timeline-virtualizer.spec.ts
+++ b/packages/app/component-tests/timeline-virtualizer.spec.ts
@@ -55,6 +55,40 @@ story("bounds the cheap suffix and reveals only ready measured rows", async ({ p
})
})
+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, 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: 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 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 [
{ 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..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,19 +621,39 @@ 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
+ 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`
- 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.
if (virtualizer.scrollOffset !== root.scrollTop) reportOffset?.(root.scrollTop, false)
+
+ return true
}
const pendingMeasurements = () => {
@@ -655,12 +685,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
+ // 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 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.
- 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) {
@@ -837,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()