Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -112,7 +117,7 @@ export function mountTimelineVirtualizer(input: { count: number; rowHeight: numb
>
Reconnect ready rows
</button>
<div ref={container} style={{ height: "180px", width: "400px" }}>
<div ref={container} style={{ height: `${input.viewportHeight ?? 180}px`, width: "400px" }}>
<timeline.View
header={null}
deferred={() => false}
Expand Down
34 changes: 34 additions & 0 deletions packages/app/component-tests/timeline-virtualizer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {} },
Expand Down
58 changes: 50 additions & 8 deletions packages/app/src/session/timeline/virtualizer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
}
Expand Down Expand Up @@ -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()
})
}

Expand Down Expand Up @@ -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 = () => {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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()
Expand Down
Loading