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) =>