fix(app): resync timeline after route reconnect (#36643)
This commit is contained in:
parent
456521f689
commit
7304bb2751
9 changed files with 846 additions and 0 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue