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