fix(app): resync timeline after route reconnect (#36643)

This commit is contained in:
Luke Parker 2026-07-14 21:47:33 +10:00 committed by GitHub
commit 7304bb2751
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 846 additions and 0 deletions

View file

@ -72,6 +72,7 @@ import { useSync } from "@/context/sync"
import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
import { sessionTitle } from "@/utils/session-title"
import { scheduleConnectedMeasure } from "./measure"
import { observeElementOffsetReconnectAware } from "./observe-element-offset"
import { createTimelineProjection } from "./projection"
import { MessageComment, SummaryDiff, TimelineRow, TimelineRowMap } from "./rows"
import { filterVirtualIndexes } from "./virtual-items"
@ -408,6 +409,7 @@ export function MessageTimeline(props: {
return timelineRows().length
},
getScrollElement: () => listRoot() ?? null,
observeElementOffset: observeElementOffsetReconnectAware,
initialOffset: () => (props.shouldAnchorBottom() ? Number.MAX_SAFE_INTEGER : 0),
initialMeasurementsCache: initialMeasurements,
estimateSize: () => timelineFallbackItemSize,

View file

@ -0,0 +1,199 @@
import { expect, test } from "bun:test"
import { type Virtualizer } from "@tanstack/solid-virtual"
import { mutationNodesContainElement, observeElementOffsetReconnectAware } from "./observe-element-offset"
test("matches only the scroll element or an ancestor containing it", () => {
const route = document.createElement("section")
const viewport = document.createElement("div")
const child = document.createElement("div")
const sibling = document.createElement("div")
route.append(viewport)
viewport.append(child)
expect(mutationNodesContainElement([viewport], viewport)).toBe(true)
expect(mutationNodesContainElement([route], viewport)).toBe(true)
expect(mutationNodesContainElement([child, sibling], viewport)).toBe(false)
})
test("reports a divergent native offset once and ignores equal offsets and unrelated mutations", async () => {
const route = document.createElement("section")
const viewport = document.createElement("div")
const unrelated = document.createElement("div")
route.append(viewport)
document.body.append(route)
const instance = {
scrollElement: viewport,
targetWindow: window,
scrollOffset: 79_400,
options: {
horizontal: false,
isRtl: false,
isScrollingResetDelay: 0,
useScrollendEvent: false,
},
} as unknown as Virtualizer<HTMLDivElement, HTMLDivElement>
const calls: [number, boolean][] = []
const cleanup = observeElementOffsetReconnectAware(instance, (offset, isScrolling) => {
calls.push([offset, isScrolling])
instance.scrollOffset = offset
})
document.body.append(unrelated)
unrelated.remove()
await frames(2)
expect(calls).toEqual([])
route.remove()
document.body.append(route)
await new Promise((resolve) => setTimeout(resolve, 0))
await frames(3)
expect(calls).toEqual([[0, false]])
route.remove()
document.body.append(route)
await new Promise((resolve) => setTimeout(resolve, 0))
await frames(3)
expect(calls).toEqual([[0, false]])
cleanup?.()
route.remove()
})
test("keeps checking until stale reset-delay callbacks can no longer win", async () => {
const route = document.createElement("section")
const viewport = document.createElement("div")
route.append(viewport)
document.body.append(route)
const instance = {
scrollElement: viewport,
targetWindow: window,
scrollOffset: 79_400,
options: {
horizontal: false,
isRtl: false,
isScrollingResetDelay: 20,
useScrollendEvent: false,
},
} as unknown as Virtualizer<HTMLDivElement, HTMLDivElement>
const calls: number[] = []
const cleanup = observeElementOffsetReconnectAware(instance, (offset) => {
calls.push(offset)
instance.scrollOffset = offset
})
route.remove()
document.body.append(route)
await new Promise((resolve) => setTimeout(resolve, 0))
await frames(1)
expect(instance.scrollOffset).toBe(0)
instance.scrollOffset = 79_400
await new Promise((resolve) => setTimeout(resolve, 25))
await frames(3)
expect(instance.scrollOffset).toBe(0)
expect(calls).toEqual([0, 0])
cleanup?.()
route.remove()
})
test.each([
{ name: "LTR", isRtl: false, expected: 240 },
{ name: "RTL", isRtl: true, expected: -240 },
])("reports the TanStack horizontal $name offset after reconnect", async ({ isRtl, expected }) => {
const route = document.createElement("section")
const viewport = document.createElement("div")
route.append(viewport)
document.body.append(route)
viewport.scrollLeft = 240
const instance = {
scrollElement: viewport,
targetWindow: window,
scrollOffset: 0,
options: {
horizontal: true,
isRtl,
isScrollingResetDelay: 0,
useScrollendEvent: false,
},
} as unknown as Virtualizer<HTMLDivElement, HTMLDivElement>
const calls: [number, boolean][] = []
const cleanup = observeElementOffsetReconnectAware(instance, (offset, isScrolling) => {
calls.push([offset, isScrolling])
instance.scrollOffset = offset
})
route.remove()
document.body.append(route)
await new Promise((resolve) => setTimeout(resolve, 0))
await frames(3)
expect(calls).toEqual([[expected, false]])
cleanup?.()
route.remove()
})
test("cleanup suppresses an already queued delegated offset callback", async () => {
const viewport = document.createElement("div")
document.body.append(viewport)
viewport.scrollTop = 100
const instance = {
scrollElement: viewport,
targetWindow: window,
scrollOffset: 0,
options: {
horizontal: false,
isRtl: false,
isScrollingResetDelay: 10,
useScrollendEvent: false,
},
} as unknown as Virtualizer<HTMLDivElement, HTMLDivElement>
const calls: [number, boolean][] = []
const cleanup = observeElementOffsetReconnectAware(instance, (offset, isScrolling) =>
calls.push([offset, isScrolling]),
)
viewport.dispatchEvent(new Event("scroll"))
cleanup?.()
await new Promise((resolve) => setTimeout(resolve, 25))
expect(calls).toEqual([[100, true]])
viewport.remove()
})
test("cleanup cancels reconnect checks and delegated offset observation", async () => {
const route = document.createElement("section")
const viewport = document.createElement("div")
route.append(viewport)
document.body.append(route)
const instance = {
scrollElement: viewport,
targetWindow: window,
scrollOffset: 0,
options: {
horizontal: false,
isRtl: false,
isScrollingResetDelay: 50,
useScrollendEvent: false,
},
} as unknown as Virtualizer<HTMLDivElement, HTMLDivElement>
const calls: number[] = []
const cleanup = observeElementOffsetReconnectAware(instance, (offset) => calls.push(offset))
route.remove()
document.body.append(route)
await new Promise((resolve) => setTimeout(resolve, 0))
cleanup?.()
instance.scrollOffset = 100
viewport.dispatchEvent(new Event("scroll"))
await frames(4)
expect(calls).toEqual([])
route.remove()
})
async function frames(count: number) {
for (let index = 0; index < count; index++) {
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
}
}

View file

@ -0,0 +1,73 @@
import { observeElementOffset, type Virtualizer } from "@tanstack/solid-virtual"
export function observeElementOffsetReconnectAware<TScrollElement extends Element, TItemElement extends Element>(
instance: Virtualizer<TScrollElement, TItemElement>,
callback: (offset: number, isScrolling: boolean) => void,
) {
let active = true
const deliver = (offset: number, isScrolling: boolean) => {
if (!active) return
callback(offset, isScrolling)
}
const cleanupOffset = observeElementOffset(instance, deliver)
const element = instance.scrollElement
const targetWindow = instance.targetWindow
const root = element?.closest("main") ?? element?.ownerDocument.body
if (!element || !targetWindow || !root)
return () => {
active = false
cleanupOffset?.()
}
let removed = false
let frame: number | undefined
const clearCheck = () => {
if (frame === undefined) return
targetWindow.cancelAnimationFrame(frame)
frame = undefined
}
const startCheck = () => {
clearCheck()
const deadline = targetWindow.performance.now() + instance.options.isScrollingResetDelay
let framesAfterDeadline = 0
const check = (time: number) => {
frame = undefined
if (element.isConnected) {
const offset = instance.options.horizontal
? element.scrollLeft * (instance.options.isRtl ? -1 : 1)
: element.scrollTop
if (instance.scrollOffset === null || Math.abs(offset - instance.scrollOffset) > 1) deliver(offset, false)
}
if (time >= deadline) framesAfterDeadline += 1
if (framesAfterDeadline >= 2) return
frame = targetWindow.requestAnimationFrame(check)
}
frame = targetWindow.requestAnimationFrame(check)
}
const observer = new targetWindow.MutationObserver((records) => {
if (!active) return
records.forEach((record) => {
if (record.target === element || element.contains(record.target)) return
if (mutationNodesContainElement(record.removedNodes, element)) {
removed = true
clearCheck()
}
if (!removed || !element.isConnected || !mutationNodesContainElement(record.addedNodes, element)) return
removed = false
startCheck()
})
})
// Session routes are replaced below persistent main; body is the fallback for isolated hosts.
observer.observe(root, { childList: true, subtree: true })
return () => {
active = false
observer.disconnect()
clearCheck()
cleanupOffset?.()
}
}
export function mutationNodesContainElement(nodes: Iterable<Node>, element: Element) {
return [...nodes].some((node) => node === element || node.contains(element))
}