Skip to content

Commit fe04f0e

Browse files
authored
fix(app): treat the reachable end as the end under desktop zoom (#60034)
1 parent 410644a commit fe04f0e

3 files changed

Lines changed: 91 additions & 10 deletions

File tree

‎packages/app/component-tests/timeline-virtualizer.fixture.tsx‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,12 @@ import { render } from "solid-js/web"
55
import { LanguageProvider } from "../src/runtime/i18n/language"
66
import { createTimelineVirtualizer } from "../src/session/timeline/virtualizer"
77

8-
export function mountTimelineVirtualizer(input: { count: number; rowHeight: number; immediate?: boolean }) {
8+
export function mountTimelineVirtualizer(input: {
9+
count: number
10+
rowHeight: number
11+
immediate?: boolean
12+
viewportHeight?: number
13+
}) {
914
const host = document.createElement("main")
1015
host.dataset.testid = "timeline-virtualizer-fixture"
1116
host.dataset.scrolls = "0"
@@ -112,7 +117,7 @@ export function mountTimelineVirtualizer(input: { count: number; rowHeight: numb
112117
>
113118
Reconnect ready rows
114119
</button>
115-
<div ref={container} style={{ height: "180px", width: "400px" }}>
120+
<div ref={container} style={{ height: `${input.viewportHeight ?? 180}px`, width: "400px" }}>
116121
<timeline.View
117122
header={null}
118123
deferred={() => false}

‎packages/app/component-tests/timeline-virtualizer.spec.ts‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,40 @@ story("bounds the cheap suffix and reveals only ready measured rows", async ({ p
5555
})
5656
})
5757

58+
story("treats the reachable end as the end under page zoom", async ({ page }) => {
59+
// At 110% the browser stops scrolling more than a pixel short of scrollHeight - clientHeight. A mount that waited
60+
// for that distance to close stayed blank, or froze the renderer in a microtask loop, and scrolling back down to
61+
// the bottom never resumed following.
62+
await page.evaluate(async (fixture) => {
63+
document.documentElement.style.zoom = "1.1"
64+
const { mountTimelineVirtualizer } = await import(fixture)
65+
mountTimelineVirtualizer({ count: 4, rowHeight: 60.3, immediate: true, viewportHeight: 200.5 })
66+
}, fixture)
67+
const root = page.getByTestId("timeline-virtualizer-fixture")
68+
const content = root.locator("[data-timeline-virtual-content]")
69+
await root.getByRole("button", { name: "Complete Markdown", exact: true }).click()
70+
await expect(content).toHaveCSS("visibility", "visible")
71+
const viewport = root.locator("[data-scrollable]")
72+
expect(
73+
await viewport.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop),
74+
).toBeGreaterThan(1)
75+
76+
const controls = root.getByTestId("timeline-controls")
77+
await viewport.dispatchEvent("wheel", { deltaY: -1 })
78+
await expect(controls).toHaveAttribute("data-pinned", "false")
79+
80+
for (const top of [40, Number.MAX_SAFE_INTEGER])
81+
await viewport.evaluate(
82+
(element, top) =>
83+
new Promise((resolve) => {
84+
element.addEventListener("scroll", resolve, { once: true })
85+
element.scrollTop = top
86+
}),
87+
top,
88+
)
89+
await expect(controls).toHaveAttribute("data-pinned", "true")
90+
})
91+
5892
for (const input of [
5993
{ name: "offset-only", count: 1, rowHeight: 600, reveal: { scrollTop: 0 } },
6094
{ name: "zero-height", count: 4, rowHeight: 60, reveal: {} },

‎packages/app/src/session/timeline/virtualizer.tsx‎

Lines changed: 50 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ const pendingMarkdown = '[data-component="markdown"]:not([data-markdown-ready])'
4343
// exactly to the end, while a one-pixel nudge upward is a deliberate move away from it.
4444
const endEpsilon = 0.5
4545

46+
// Chrome rounds scrollHeight and clientHeight to whole pixels but scrolls in zoomed ones, so under page zoom (down to
47+
// 25%) the furthest offset it reaches can sit up to 4px short of scrollHeight - clientHeight.
48+
const zoomSlack = 4
49+
4650
const upwardKeys = new Set(["up", "page-up", "home"])
4751

4852
const cache = new Map<
@@ -216,6 +220,8 @@ export function createTimelineVirtualizer(input: Input) {
216220
},
217221
)
218222
let scrollTop = 0
223+
// A scroll arrived within the zoom slack of the end and may have reached it.
224+
let endPending = false
219225
let reportOffset: ((offset: number, scrolling: boolean) => void) | undefined
220226
let reportRect: ((rect: { width: number; height: number }) => void) | undefined
221227
let batchingColdSizes = false
@@ -267,6 +273,11 @@ export function createTimelineVirtualizer(input: Input) {
267273
flushTouchAdjustment()
268274

269275
if (!scrolling && touchStart === undefined) finishTouchScroll()
276+
277+
if (scrolling || touchStart !== undefined || !endPending) return
278+
endPending = false
279+
280+
if (root && !input.pinned() && !rendering.anchor && atReachableEnd(root)) input.onPin()
270281
})
271282
settleColdBottom()
272283
}
@@ -400,8 +411,7 @@ export function createTimelineVirtualizer(input: Input) {
400411
const root = listRoot()
401412

402413
// Reopening a settled scroll-to-end operation can fight subsequent keyboard scrolling.
403-
if (root && Math.abs(root.scrollHeight - root.clientHeight - root.scrollTop) > endEpsilon)
404-
virtualizer.scrollToEnd()
414+
if (root && !atReachableEnd(root)) virtualizer.scrollToEnd()
405415
})
406416
}
407417

@@ -611,19 +621,39 @@ export function createTimelineVirtualizer(input: Input) {
611621
let contentObserver: MutationObserver | undefined
612622
let viewportObserver: ResizeObserver | undefined
613623

624+
// Within the zoom slack only a write finds the end the browser reaches: one that does not move proves the view is
625+
// already there, and one that does is undone before anything paints.
626+
function atReachableEnd(root: HTMLDivElement) {
627+
const top = root.scrollTop
628+
const distance = root.scrollHeight - root.clientHeight - top
629+
630+
if (distance <= endEpsilon) return true
631+
632+
if (distance > zoomSlack) return false
633+
root.scrollTop = root.scrollHeight
634+
635+
if (root.scrollTop === top) return true
636+
root.scrollTop = top
637+
638+
return false
639+
}
640+
641+
// Returns whether the view now rests at its end with that offset reported.
614642
const pinColdBottom = () => {
615-
if (!active()) return
643+
if (!active()) return false
616644
const root = listRoot()
617645

618-
if (!input.pinned() || !virtualContent || !root) return
646+
if (!input.pinned() || !virtualContent || !root) return false
619647
// scrollToEnd computes its target from the DOM, not the new size cache.
620648
virtualContent.style.height = `${virtualizer.getTotalSize()}px`
621649

622-
if (Math.abs(root.scrollHeight - root.clientHeight - root.scrollTop) > endEpsilon) virtualizer.scrollToEnd()
650+
if (!atReachableEnd(root)) virtualizer.scrollToEnd()
623651

624652
// Report after core size adjustments finish so they cannot apply a delta
625653
// twice. This avoids waiting a frame for the native scroll event.
626654
if (virtualizer.scrollOffset !== root.scrollTop) reportOffset?.(root.scrollTop, false)
655+
656+
return true
627657
}
628658

629659
const pendingMeasurements = () => {
@@ -655,12 +685,20 @@ export function createTimelineVirtualizer(input: Input) {
655685
})
656686

657687
if (pendingSizes.size || pendingMeasurements()) return
658-
pinColdBottom()
688+
const pinned = input.pinned()
659689

660-
if (input.pinned() && Math.abs(root.scrollHeight - root.clientHeight - root.scrollTop) > 1) return
690+
// A pinned view rests at the end pinColdBottom reaches. Under page zoom that end can be more than a pixel short
691+
// of scrollHeight - clientHeight, and TanStack keeps the offset it wrote rather than the one reached. Waiting
692+
// for either distance to close never ends: each pass reported the offset again, which queued the next pass as
693+
// a microtask, and the renderer froze.
694+
if (pinned && !pinColdBottom()) return
661695

662696
// The scroll event must update the range before newly exposed rows can reveal.
663-
if (root.scrollHeight > root.clientHeight && Math.abs((virtualizer.scrollOffset ?? 0) - root.scrollTop) > 1)
697+
if (
698+
!pinned &&
699+
root.scrollHeight > root.clientHeight &&
700+
Math.abs((virtualizer.scrollOffset ?? 0) - root.scrollTop) > 1
701+
)
664702
return
665703

666704
if (rendering.initialTail) {
@@ -837,9 +875,13 @@ export function createTimelineVirtualizer(input: Input) {
837875
const atEnd = maxScroll - scrollTop <= endEpsilon
838876
const arrived = scrollTop > previousTop + endEpsilon || maxScroll < previousMaxScroll
839877

878+
if (scrollTop < previousTop) endPending = false
879+
840880
// An anchor holds its tool even when reaching it lands at the end; only the user's own scroll lets it go.
841881
if (maxScroll <= 1 || (atEnd && arrived && !rendering.anchor)) input.onPin()
842882
else if ((pointerHeld || touchScrolling) && scrollTop < previousTop - endEpsilon) input.onUnpin()
883+
// Probing the zoomed end now would cancel a scroll still in motion, so it waits for the scroll to settle.
884+
else if (arrived && !rendering.anchor && maxScroll - scrollTop <= zoomSlack) endPending = true
843885
settleColdBottom()
844886
input.onScheduleScrollState(root)
845887
input.onHistoryScroll()

0 commit comments

Comments
 (0)