fix(transcript): hold the top after an immediate jump to the first message (#1302)
Some checks are pending
Publish help / build (push) Waiting to run
Publish help / deploy (push) Blocked by required conditions

On long transcripts, First from the bottom loaded the oldest window but left
the reader at its bottom, needing a second click. The jump wrote offset 0, then
Virtua applied a size compensation computed against its previous offset (the
replaced window's position), carrying the reader back down.

An immediate jump to the top now holds the first live row at its top offset
through the reader settlement, as key jumps hold their row, for the 30-frame
window. A gesture releases it, and so does any focus move: the top landing
reveals nothing, unlike in-place reveals, whose focus rule is unchanged.

The session fixture now serves ascending (oldest) pages from the first records,
like the native API. List-fixture tests cover a late compensation after a top
jump (fails without the hold) and Tab within the first row (fails without the
focus release); a session test covers First into an overlapping oldest window.
On the reporting conversation, First landed at the oldest window's bottom
without the fix and at its top in three runs with it. Gatekeeper review approved
after its focus finding was fixed.
This commit is contained in:
Pascal André 2026-11-05 21:14:21 +01:00 • committed by GitHub
parent fc1b9e72b2
commit 30527e58dd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 99 additions and 6 deletions

View file

@ -350,7 +350,11 @@ and does so even if the prompt is then rejected (the saved passage is not restor
In-place reveals (`alignElement`) are reader-settlement holds on the revealed
element; key jumps hold their row the same way at offset 0, or the block their
optional selector finds in it on that frame (a timeline marker's own block). While
optional selector finds in it on that frame (a timeline marker's own block). An
immediate jump to the top holds the first live row at its top offset: right after
a window replacement Virtua can apply a size compensation computed against its
previous offset after the jump's write, which would otherwise carry the reader back
down (a First jump from the bottom of a long transcript). While
a jump's row is not rendered, the hold rewrites Virtua's current estimate of its
offset instead, since measuring the rows around a distant estimate can carry the
row out of the rendered range; once rendered it is aligned. Both last a fixed

View file

@ -533,8 +533,21 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
runBottomPinFrame()
}
/** The anchor an immediate jump to the top holds, told apart from in-place reveals. */
let topHold: { key: string; offset: number } | undefined
function performScrollToTop(immediate: boolean, source: ScrollReason) {
writeScroll(source, immediate ? { type: "dom", top: 0, record: true } : { type: "native", top: 0, behavior: "smooth", record: false })
if (!immediate) return
// Like a key jump, the landing is held while rows are measured: right after a
// window replacement Virtua can still apply a size compensation computed
// against its previous offset, which would carry the reader back down.
const handle = virtuaHandle(), first = retiredCount()
const item = virtualItems()[first]
if (handle && item !== undefined) {
topHold = { key: props.getKey(item, first), offset: handle.getItemOffset(first + HEADER_ROWS) }
readerSettlement.hold(topHold)
}
}
function performScrollToKey(key: string, source: ScrollReason) {
@ -1052,10 +1065,11 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
}
markUserScrollIntent(intent.direction, intent.distance)
}
// Moving focus elsewhere (Tab, assistive technology) ends an in-place reveal's hold.
// Moving focus elsewhere (Tab, assistive technology) ends an in-place reveal's
// hold; the top landing reveals nothing, so any focus move ends it.
const handleFocusIn = (event: FocusEvent) => {
const held = readerSettlement.held()
if (held && !itemElements.get(held.key)?.contains(event.target as Node)) readerSettlement.cancel()
if (held && (held === topHold || !itemElements.get(held.key)?.contains(event.target as Node))) readerSettlement.cancel()
}
const detachGestureRouting = attachScrollGestureRouting(element, handleWheelIntent)
element.addEventListener("focusin", handleFocusIn)

View file

@ -32,6 +32,7 @@ render(() => <Show when={visible()}><VirtualFollowList items={items} getKey={ite
/></Show>, document.getElementById("root")!)
;(window as any).fixture = {
bottom: () => api?.scrollToBottom({ immediate: true }),
top: () => api?.scrollToTop({ immediate: true }),
follow: () => { api?.scrollToBottom({ immediate: true }); api?.setAutoScroll(true) },
middle: () => api?.scrollToKey("row-100"),
nearTail: () => api?.scrollToKey("row-180"),

View file

@ -50,8 +50,17 @@ const client: any = {
},
model: { default: async () => ({ data: model }) },
permission: { list: async () => [] },
message: { list: async ({ cursor, limit = 200 }: { cursor?: string; limit?: number } = {}) => {
message: { list: async ({ cursor, limit = 200, order }: { cursor?: string; limit?: number; order?: "asc" | "desc" } = {}) => {
calls++
// The oldest window reads forward from the first record, like the native
// ascending page, whose boundary cursor promises no older page.
if (order === "asc" || cursor?.startsWith("asc:")) {
const offset = Number(cursor?.slice(4) ?? 0)
return { data: nativeMessages.slice(offset, offset + limit), cursor: {
previous: "asc-boundary",
...(offset + limit < nativeMessages.length ? { next: `asc:${offset + limit}` } : {}),
} }
}
const offset = Number(cursor ?? 0), messages = nativeMessages.slice().reverse()
return { data: messages.slice(offset, offset + limit), cursor: offset + limit < messages.length ? { next: String(offset + limit) } : {} }
} },

View file

@ -2245,8 +2245,7 @@ test("a First jump during a pending send still lands on the first message", asyn
await frames(page, 120)
await page.locator(".message-scroll-controls").hover()
await page.getByRole("button", { name: "Scroll to first message", exact: true }).click()
// The fixture serves this window from its newest records; what matters is that
// the loaded window replaces the latest one and is then entered at its top.
// The oldest window replaces the latest one and is then entered at its top.
await page.waitForFunction(() => (window as any).fixture.snapshot().window.kind !== "latest")
await frames(page, 60)
const state = await page.evaluate(() => {
@ -2259,6 +2258,72 @@ test("a First jump during a pending send still lands on the first message", asyn
}, { history: 620 })
})
// Right after a window replacement Virtua can apply a size compensation computed
// against its previous offset, after the jump's write (long real transcripts):
// an immediate jump to the top holds its landing while the rows settle.
test("an immediate jump to the top holds its landing against a late compensation", async () => {
await open(async page => {
await page.evaluate(() => (window as any).fixture.bottom())
await frames(page)
await page.evaluate(() => new Promise<void>(resolve => {
;(window as any).fixture.top()
requestAnimationFrame(() => {
// The stale compensation lands one frame after the jump's write.
document.querySelector<HTMLElement>(".message-stream")!.scrollTop = 5000
resolve()
})
}))
await frames(page, 10)
assert.equal(await page.locator(".message-stream").evaluate(el => el.scrollTop), 0)
}, { name: "list" })
})
// The top landing reveals nothing: moving focus within the first message (Tab to
// one of its controls) scrolls to that control and ends the hold.
test("focus moving within the first row ends the top hold", async () => {
await open(async page => {
await page.evaluate(() => (window as any).fixture.resizeRow("row-0", 2400))
await page.evaluate(() => (window as any).fixture.bottom())
await frames(page)
await page.evaluate(() => (window as any).fixture.top())
await frames(page, 2)
await page.evaluate(() => {
const content = document.querySelector<HTMLElement>('[data-virtual-follow-key="row-0"]')!.firstElementChild as HTMLElement
content.style.position = "relative"
const button = document.createElement("button")
button.textContent = "Inside the first row"
button.style.cssText = "position:absolute;top:2000px"
content.append(button)
button.focus()
})
await frames(page, 40)
const state = await page.evaluate(() => {
const button = document.activeElement as HTMLElement, el = document.querySelector<HTMLElement>(".message-stream")!
const rect = button.getBoundingClientRect(), bounds = el.getBoundingClientRect()
return { scrollTop: el.scrollTop, visible: rect.top >= bounds.top && rect.bottom <= bounds.bottom }
})
assert.ok(state.scrollTop > 1000 && state.visible, JSON.stringify(state))
}, { name: "list" })
})
// Below two pages the oldest and latest windows share messages: one First jump
// from the bottom must still land on the first message, not on the bottom of
// the oldest window where the shared rows sit.
test("a First jump into an overlapping oldest window lands on the first message", async () => {
await open(async page => {
await page.locator(".message-scroll-controls").hover()
await page.getByRole("button", { name: "Scroll to first message", exact: true }).click()
await page.waitForFunction(() => (window as any).fixture.snapshot().window.kind !== "latest")
await frames(page, 60)
const state = await page.evaluate(() => {
const el = document.querySelector<HTMLElement>(".message-stream")!, bounds = el.getBoundingClientRect(), key = (window as any).fixture.snapshot().ids[0]
const first = el.querySelector(`[data-virtual-follow-key="${key}"]`)
return { key, scrollTop: el.scrollTop, scrollHeight: el.scrollHeight, firstTop: first ? Math.round(first.getBoundingClientRect().top - bounds.top) : null }
})
assert.ok(state.scrollTop <= 2 && state.firstTop !== null && Math.abs(state.firstTop) <= 2, JSON.stringify(state))
}, { history: 205 })
})
const AT_BOTTOM = { scrollTop: 0, atBottom: true, followModeType: "following" }
test("an at-bottom restoration that loses follow without a gesture reports cancellation and stops restoring", async () => {