mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-21 18:26:09 +00:00
fix(app): resync timeline after route reconnect (#36643)
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Timeline Suspense Reproduction</title>
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #171717;
|
||||
color: #f5f5f5;
|
||||
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
}
|
||||
|
||||
#root {
|
||||
padding: 24px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main id="root"></main>
|
||||
<script type="module" src="/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,315 @@
|
||||
import { createResource, createSignal, For, onMount, Suspense } from "solid-js"
|
||||
import { render } from "solid-js/web"
|
||||
import { createVirtualizer, observeElementOffset, observeElementRect } from "@tanstack/solid-virtual"
|
||||
import { observeElementOffsetReconnectAware } from "../../../src/pages/session/timeline/observe-element-offset"
|
||||
|
||||
const rowCount = 2_000
|
||||
const rowHeight = 40
|
||||
const parameters = new URLSearchParams(location.search)
|
||||
const resourceMode = parameters.get("resource") === "guard" ? "guard" : "baseline"
|
||||
const reconnectMode = parameters.get("reconnect") === "candidate" ? "candidate" : "baseline"
|
||||
|
||||
type MutationEvent = {
|
||||
kind: "removed" | "added"
|
||||
callbackTime: number
|
||||
callbackFrame: number
|
||||
routeConnectedInCallback: boolean
|
||||
nativeOffsetInCallback: number
|
||||
}
|
||||
|
||||
type Snapshot = {
|
||||
mode: {
|
||||
resource: "baseline" | "guard"
|
||||
reconnect: "baseline" | "candidate"
|
||||
}
|
||||
operation: {
|
||||
sequence: number
|
||||
phase: string
|
||||
time: number
|
||||
frame: number
|
||||
}
|
||||
resourceState: string
|
||||
routeConnected: boolean
|
||||
viewportConnected: boolean
|
||||
viewportOwnedByRoute: boolean
|
||||
sameRoute: boolean
|
||||
sameViewport: boolean
|
||||
sameSurface: boolean
|
||||
sameMountedRows: boolean
|
||||
nativeOffset: number
|
||||
coreOffset: number
|
||||
rangeStart: number
|
||||
rangeEnd: number
|
||||
indexes: number[]
|
||||
domIndexes: number[]
|
||||
logicalSurfaceHeight: number
|
||||
renderedSurfaceHeight: number
|
||||
viewportClientHeight: number
|
||||
viewportScrollHeight: number
|
||||
visibleRows: number
|
||||
minimumRowTop: number
|
||||
domScrollEvents: number
|
||||
lastScrollTrusted: boolean
|
||||
coreOffsetCallbackCalls: number
|
||||
offsetCallbackSources: "observer"[]
|
||||
rectObserverCallbacks: number
|
||||
ignoredDetachedZeroRects: number
|
||||
syntheticScrollDispatches: number
|
||||
mutationEvents: MutationEvent[]
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
timelineSuspense: {
|
||||
prepare: () => Promise<Snapshot>
|
||||
trigger: () => void
|
||||
resolve: () => void
|
||||
frames: (count?: number) => Promise<void>
|
||||
snapshot: () => Snapshot
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [refresh, setRefresh] = createSignal(false)
|
||||
let resolveResource: (() => void) | undefined
|
||||
const [resource] = createResource(
|
||||
refresh,
|
||||
(version) =>
|
||||
new Promise<string>((resolve) => {
|
||||
resolveResource = () => resolve(`settled-${version}`)
|
||||
}),
|
||||
{ initialValue: "settled" },
|
||||
)
|
||||
|
||||
function Route() {
|
||||
let route: HTMLElement | undefined
|
||||
let viewport: HTMLDivElement | undefined
|
||||
let surface: HTMLDivElement | undefined
|
||||
let initialRoute: HTMLElement | undefined
|
||||
let initialViewport: HTMLDivElement | undefined
|
||||
let initialSurface: HTMLDivElement | undefined
|
||||
let initialRows: HTMLElement[] = []
|
||||
let phase = "mounting"
|
||||
let browserFrame = 0
|
||||
let snapshotSequence = 0
|
||||
let domScrollEvents = 0
|
||||
let lastScrollTrusted = false
|
||||
let coreOffsetCallbackCalls = 0
|
||||
let rectObserverCallbacks = 0
|
||||
let ignoredDetachedZeroRects = 0
|
||||
const offsetCallbackSources: "observer"[] = []
|
||||
const mutationEvents: MutationEvent[] = []
|
||||
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
|
||||
count: rowCount,
|
||||
getScrollElement: () => viewport ?? null,
|
||||
estimateSize: () => rowHeight,
|
||||
initialRect: { width: 900, height: 600 },
|
||||
overscan: 2,
|
||||
observeElementRect: (instance, callback) =>
|
||||
observeElementRect(instance, (rect) => {
|
||||
rectObserverCallbacks++
|
||||
// A fixed 600px viewport has no usable geometry while detached. Keep the last connected rect.
|
||||
if (!instance.scrollElement?.isConnected && rect.height === 0) {
|
||||
ignoredDetachedZeroRects++
|
||||
return
|
||||
}
|
||||
callback(rect)
|
||||
}),
|
||||
observeElementOffset: (instance, callback) => {
|
||||
const deliver = (offset: number, isScrolling: boolean) => {
|
||||
coreOffsetCallbackCalls++
|
||||
offsetCallbackSources.push("observer")
|
||||
callback(offset, isScrolling)
|
||||
}
|
||||
if (reconnectMode === "candidate") return observeElementOffsetReconnectAware(instance, deliver)
|
||||
return observeElementOffset(instance, deliver)
|
||||
},
|
||||
})
|
||||
|
||||
const frames = async (count = 2) => {
|
||||
for (let index = 0; index < count; index++) {
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
|
||||
}
|
||||
}
|
||||
const mountedRows = () => [...(surface?.querySelectorAll<HTMLElement>("[data-row-index]") ?? [])]
|
||||
const snapshot = (): Snapshot => {
|
||||
const rows = mountedRows()
|
||||
const view = viewport?.getBoundingClientRect()
|
||||
const visibleRows =
|
||||
viewport?.isConnected && view
|
||||
? rows.filter((row) => {
|
||||
const rect = row.getBoundingClientRect()
|
||||
return rect.bottom > view.top && rect.top < view.bottom
|
||||
}).length
|
||||
: 0
|
||||
return {
|
||||
mode: { resource: resourceMode, reconnect: reconnectMode },
|
||||
operation: {
|
||||
sequence: ++snapshotSequence,
|
||||
phase,
|
||||
time: performance.now(),
|
||||
frame: browserFrame,
|
||||
},
|
||||
resourceState: resource.state,
|
||||
routeConnected: route?.isConnected ?? false,
|
||||
viewportConnected: viewport?.isConnected ?? false,
|
||||
viewportOwnedByRoute: !!route && !!viewport && route.contains(viewport),
|
||||
sameRoute: route === initialRoute,
|
||||
sameViewport: viewport === initialViewport,
|
||||
sameSurface: surface === initialSurface,
|
||||
sameMountedRows:
|
||||
initialRows.length > 0 &&
|
||||
initialRows.length === rows.length &&
|
||||
initialRows.every((row, index) => row === rows[index]),
|
||||
nativeOffset: viewport?.scrollTop ?? -1,
|
||||
coreOffset: virtualizer.scrollOffset ?? -1,
|
||||
rangeStart: virtualizer.range?.startIndex ?? -1,
|
||||
rangeEnd: virtualizer.range?.endIndex ?? -1,
|
||||
indexes: virtualizer.getVirtualItems().map((item) => item.index),
|
||||
domIndexes: rows.map((row) => Number(row.dataset.rowIndex)),
|
||||
logicalSurfaceHeight: Number.parseFloat(surface?.style.height ?? "-1"),
|
||||
renderedSurfaceHeight: surface?.getBoundingClientRect().height ?? -1,
|
||||
viewportClientHeight: viewport?.clientHeight ?? -1,
|
||||
viewportScrollHeight: viewport?.scrollHeight ?? -1,
|
||||
visibleRows,
|
||||
minimumRowTop:
|
||||
rows.length && view ? Math.min(...rows.map((row) => row.getBoundingClientRect().top - view.top)) : -1,
|
||||
domScrollEvents,
|
||||
lastScrollTrusted,
|
||||
coreOffsetCallbackCalls,
|
||||
offsetCallbackSources: [...offsetCallbackSources],
|
||||
rectObserverCallbacks,
|
||||
ignoredDetachedZeroRects,
|
||||
syntheticScrollDispatches: 0,
|
||||
mutationEvents: mutationEvents.map((event) => ({ ...event })),
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (!route || !viewport || !surface) throw new Error("Timeline fixture did not mount")
|
||||
const routeRoot = route.parentElement
|
||||
if (!routeRoot) throw new Error("Timeline route root did not mount")
|
||||
initialRoute = route
|
||||
initialViewport = viewport
|
||||
initialSurface = surface
|
||||
viewport.addEventListener("scroll", (event) => {
|
||||
domScrollEvents++
|
||||
lastScrollTrusted = event.isTrusted
|
||||
})
|
||||
const countFrames = () => {
|
||||
browserFrame++
|
||||
requestAnimationFrame(countFrames)
|
||||
}
|
||||
requestAnimationFrame(countFrames)
|
||||
new MutationObserver((records) => {
|
||||
const callbackTime = performance.now()
|
||||
records.forEach((record) => {
|
||||
;([...(record.removedNodes ?? [])] as Node[]).forEach((node) => {
|
||||
if (node !== route) return
|
||||
phase = "detached"
|
||||
mutationEvents.push({
|
||||
kind: "removed",
|
||||
callbackTime,
|
||||
callbackFrame: browserFrame,
|
||||
routeConnectedInCallback: route.isConnected,
|
||||
nativeOffsetInCallback: viewport.scrollTop,
|
||||
})
|
||||
})
|
||||
;([...(record.addedNodes ?? [])] as Node[]).forEach((node) => {
|
||||
if (node !== route) return
|
||||
phase = "reinserted"
|
||||
mutationEvents.push({
|
||||
kind: "added",
|
||||
callbackTime,
|
||||
callbackFrame: browserFrame,
|
||||
routeConnectedInCallback: route.isConnected,
|
||||
nativeOffsetInCallback: viewport.scrollTop,
|
||||
})
|
||||
})
|
||||
})
|
||||
}).observe(routeRoot, { childList: true })
|
||||
window.timelineSuspense = {
|
||||
prepare: async () => {
|
||||
phase = "preparing"
|
||||
await frames(2)
|
||||
viewport.scrollTop = viewport.scrollHeight
|
||||
await frames(3)
|
||||
await new Promise((resolve) => setTimeout(resolve, 200))
|
||||
await frames(2)
|
||||
initialRows = mountedRows()
|
||||
phase = "prepared"
|
||||
return snapshot()
|
||||
},
|
||||
trigger: () => {
|
||||
phase = "triggering"
|
||||
setRefresh(true)
|
||||
},
|
||||
resolve: () => {
|
||||
if (!resolveResource) throw new Error("Resource is not pending")
|
||||
phase = "resolving"
|
||||
resolveResource()
|
||||
},
|
||||
frames,
|
||||
snapshot,
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<section ref={route} data-route style={{ width: "900px", margin: "0 auto" }}>
|
||||
<span aria-hidden="true" style={{ display: "none" }}>
|
||||
{resourceMode === "guard" && resource.state === "refreshing" ? resource.latest : resource()}
|
||||
</span>
|
||||
<div
|
||||
ref={viewport}
|
||||
data-viewport
|
||||
style={{
|
||||
height: "600px",
|
||||
overflow: "auto",
|
||||
"overflow-anchor": "none",
|
||||
position: "relative",
|
||||
background: "#202020",
|
||||
outline: "1px solid #3f3f46",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={surface}
|
||||
data-surface
|
||||
style={{ height: `${virtualizer.getTotalSize()}px`, position: "relative", "overflow-anchor": "none" }}
|
||||
>
|
||||
<For each={virtualizer.getVirtualItems()}>
|
||||
{(item) => (
|
||||
<div
|
||||
data-row-index={item.index}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "0",
|
||||
left: "0",
|
||||
width: "100%",
|
||||
height: `${item.size}px`,
|
||||
transform: `translateY(${item.start}px)`,
|
||||
padding: "10px 14px",
|
||||
border: "0 solid #333",
|
||||
"border-bottom-width": "1px",
|
||||
}}
|
||||
>
|
||||
logical row {item.index}
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<Suspense>
|
||||
<Route />
|
||||
</Suspense>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
render(() => <App />, document.getElementById("root")!)
|
||||
@@ -0,0 +1,34 @@
|
||||
import { defineConfig, devices } from "@playwright/test"
|
||||
|
||||
const port = Number(process.env.PLAYWRIGHT_TIMELINE_SUSPENSE_PORT ?? 4317)
|
||||
|
||||
export default defineConfig({
|
||||
testDir: ".",
|
||||
testMatch: "timeline-suspense.repro.ts",
|
||||
outputDir: "../../test-results/timeline-suspense",
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
retries: 0,
|
||||
reporter: "line",
|
||||
timeout: 30_000,
|
||||
expect: {
|
||||
timeout: 10_000,
|
||||
},
|
||||
webServer: {
|
||||
command: `bunx vite --config vite.config.ts --host 127.0.0.1 --port ${port} --strictPort`,
|
||||
cwd: import.meta.dirname,
|
||||
url: `http://127.0.0.1:${port}`,
|
||||
reuseExistingServer: false,
|
||||
},
|
||||
use: {
|
||||
baseURL: `http://127.0.0.1:${port}`,
|
||||
trace: "retain-on-failure",
|
||||
screenshot: "only-on-failure",
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -0,0 +1,179 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
page.on("pageerror", (error) => console.error(error))
|
||||
await page.goto("/")
|
||||
await expect.poll(() => page.evaluate(() => !!window.timelineSuspense)).toBe(true)
|
||||
})
|
||||
|
||||
test("desired: preserves visible timeline continuity across descendant resource suspension", async ({ page }) => {
|
||||
await page.goto("/?reconnect=candidate")
|
||||
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().mode.reconnect)).toBe("candidate")
|
||||
const before = await prepare(page)
|
||||
await triggerBaselineSuspension(page)
|
||||
const pending = await page.evaluate(() => window.timelineSuspense.snapshot())
|
||||
expect(pending.nativeOffset).toBe(0)
|
||||
expect(pending.coreOffset).toBe(before.coreOffset)
|
||||
expect(pending.indexes).toEqual(before.indexes)
|
||||
expect(pending.sameRoute).toBe(true)
|
||||
expect(pending.sameViewport).toBe(true)
|
||||
expect(pending.sameSurface).toBe(true)
|
||||
expect(pending.sameMountedRows).toBe(true)
|
||||
|
||||
await resolveSuspension(page)
|
||||
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().coreOffset)).toBe(0)
|
||||
await page.waitForTimeout(250)
|
||||
await page.evaluate(() => window.timelineSuspense.frames(2))
|
||||
const after = await page.evaluate(() => window.timelineSuspense.snapshot())
|
||||
expect(after.sameRoute).toBe(true)
|
||||
expect(after.sameViewport).toBe(true)
|
||||
expect(after.sameSurface).toBe(true)
|
||||
expect(after.nativeOffset).toBe(0)
|
||||
expect(after.coreOffset).toBe(0)
|
||||
expect(after.rangeStart).toBeLessThan(10)
|
||||
expect(after.visibleRows, diagnostic({ before, pending, after })).toBeGreaterThan(0)
|
||||
expect(after.domScrollEvents).toBe(before.domScrollEvents)
|
||||
expect(after.coreOffsetCallbackCalls).toBe(before.coreOffsetCallbackCalls + 1)
|
||||
expect(after.offsetCallbackSources.at(-1)).toBe("observer")
|
||||
expect(after.syntheticScrollDispatches).toBe(0)
|
||||
})
|
||||
|
||||
test("forensic: proves detached same-node viewport leaves TanStack's bottom range blank until a real scroll", async ({
|
||||
page,
|
||||
}) => {
|
||||
const before = await prepare(page)
|
||||
const beforeRows = before.domIndexes
|
||||
expect(before.mode).toEqual({ resource: "baseline", reconnect: "baseline" })
|
||||
expect(before.logicalSurfaceHeight).toBe(80_000)
|
||||
expect(before.renderedSurfaceHeight).toBe(80_000)
|
||||
expect(before.viewportClientHeight).toBe(600)
|
||||
expect(before.viewportScrollHeight).toBe(80_000)
|
||||
expect(before.rangeStart).toBeGreaterThan(1_900)
|
||||
expect(before.nativeOffset).toBe(before.coreOffset)
|
||||
expect(before.visibleRows).toBeGreaterThan(0)
|
||||
|
||||
await triggerBaselineSuspension(page)
|
||||
const pending = await page.evaluate(() => window.timelineSuspense.snapshot())
|
||||
expect(pending.resourceState).toBe("refreshing")
|
||||
expect(pending.routeConnected).toBe(false)
|
||||
expect(pending.viewportConnected).toBe(false)
|
||||
expect(pending.viewportOwnedByRoute).toBe(true)
|
||||
expect(pending.nativeOffset).toBe(0)
|
||||
expect(pending.coreOffset).toBe(before.coreOffset)
|
||||
expect(pending.rangeStart).toBe(before.rangeStart)
|
||||
expect(pending.rangeEnd).toBe(before.rangeEnd)
|
||||
expect(pending.indexes).toEqual(before.indexes)
|
||||
expect(pending.domIndexes).toEqual(beforeRows)
|
||||
expect(pending.sameMountedRows).toBe(true)
|
||||
expect(pending.domScrollEvents).toBe(before.domScrollEvents)
|
||||
expect(pending.coreOffsetCallbackCalls).toBe(before.coreOffsetCallbackCalls)
|
||||
expect(pending.ignoredDetachedZeroRects).toBeGreaterThan(before.ignoredDetachedZeroRects)
|
||||
expect(pending.mutationEvents).toHaveLength(1)
|
||||
expect(pending.mutationEvents[0]).toMatchObject({
|
||||
kind: "removed",
|
||||
routeConnectedInCallback: false,
|
||||
nativeOffsetInCallback: 0,
|
||||
})
|
||||
expect(pending.mutationEvents[0]!.callbackTime).toBeLessThanOrEqual(pending.operation.time)
|
||||
expect(pending.mutationEvents[0]!.callbackFrame).toBeLessThanOrEqual(pending.operation.frame)
|
||||
|
||||
const after = await resolveSuspension(page)
|
||||
expect(after.resourceState).toBe("ready")
|
||||
expect(after.routeConnected).toBe(true)
|
||||
expect(after.viewportConnected).toBe(true)
|
||||
expect(after.viewportOwnedByRoute).toBe(true)
|
||||
expect(after.sameRoute).toBe(true)
|
||||
expect(after.sameViewport).toBe(true)
|
||||
expect(after.sameSurface).toBe(true)
|
||||
expect(after.sameMountedRows).toBe(true)
|
||||
expect(after.nativeOffset).toBe(0)
|
||||
expect(after.coreOffset).toBe(before.coreOffset)
|
||||
expect(after.rangeStart).toBe(before.rangeStart)
|
||||
expect(after.rangeEnd).toBe(before.rangeEnd)
|
||||
expect(after.indexes).toEqual(before.indexes)
|
||||
expect(after.domIndexes).toEqual(beforeRows)
|
||||
expect(after.domScrollEvents).toBe(before.domScrollEvents)
|
||||
expect(after.coreOffsetCallbackCalls).toBe(before.coreOffsetCallbackCalls)
|
||||
expect(after.mutationEvents).toHaveLength(2)
|
||||
expect(after.mutationEvents[1]).toMatchObject({
|
||||
kind: "added",
|
||||
routeConnectedInCallback: true,
|
||||
nativeOffsetInCallback: 0,
|
||||
})
|
||||
expect(after.mutationEvents[1]!.callbackTime).toBeLessThanOrEqual(after.operation.time)
|
||||
expect(after.mutationEvents[1]!.callbackFrame).toBeLessThanOrEqual(after.operation.frame)
|
||||
expect(after.visibleRows).toBe(0)
|
||||
expect(after.minimumRowTop).toBeGreaterThan(50_000)
|
||||
expect(after.syntheticScrollDispatches).toBe(0)
|
||||
|
||||
await page.locator("[data-viewport]").hover()
|
||||
await page.mouse.wheel(0, 80)
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => {
|
||||
const value = window.timelineSuspense.snapshot()
|
||||
return value.nativeOffset > 0 && value.coreOffset === value.nativeOffset
|
||||
}),
|
||||
)
|
||||
.toBe(true)
|
||||
await page.evaluate(() => window.timelineSuspense.frames(2))
|
||||
const recovered = await page.evaluate(() => window.timelineSuspense.snapshot())
|
||||
expect(recovered.domScrollEvents).toBeGreaterThan(after.domScrollEvents)
|
||||
expect(recovered.coreOffsetCallbackCalls).toBeGreaterThan(after.coreOffsetCallbackCalls)
|
||||
expect(recovered.offsetCallbackSources.at(-1)).toBe("observer")
|
||||
expect(recovered.lastScrollTrusted).toBe(true)
|
||||
expect(recovered.rangeStart).toBeLessThan(10)
|
||||
expect(recovered.visibleRows).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test("matrix: fixture-only settled-resource guard keeps the route connected", async ({ page }) => {
|
||||
await page.goto("/?resource=guard")
|
||||
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().mode.resource)).toBe("guard")
|
||||
const before = await prepare(page)
|
||||
|
||||
await page.evaluate(() => window.timelineSuspense.trigger())
|
||||
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().resourceState)).toBe("refreshing")
|
||||
await page.evaluate(() => window.timelineSuspense.frames(3))
|
||||
const pending = await page.evaluate(() => window.timelineSuspense.snapshot())
|
||||
expect(pending.routeConnected).toBe(true)
|
||||
expect(pending.mutationEvents).toEqual([])
|
||||
expect(pending.nativeOffset).toBe(before.nativeOffset)
|
||||
expect(pending.coreOffset).toBe(before.coreOffset)
|
||||
expect(pending.visibleRows).toBeGreaterThan(0)
|
||||
|
||||
const after = await resolveSuspension(page)
|
||||
expect(after.routeConnected).toBe(true)
|
||||
expect(after.nativeOffset).toBe(before.nativeOffset)
|
||||
expect(after.coreOffset).toBe(before.coreOffset)
|
||||
expect(after.visibleRows).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
async function prepare(page: Page) {
|
||||
const before = await page.evaluate(() => window.timelineSuspense.prepare())
|
||||
expect(before.routeConnected).toBe(true)
|
||||
expect(before.viewportConnected).toBe(true)
|
||||
expect(before.viewportOwnedByRoute).toBe(true)
|
||||
expect(before.sameMountedRows).toBe(true)
|
||||
expect(before.rangeStart).toBeGreaterThan(1_900)
|
||||
expect(before.nativeOffset).toBe(before.coreOffset)
|
||||
return before
|
||||
}
|
||||
|
||||
async function triggerBaselineSuspension(page: Page) {
|
||||
await page.evaluate(() => window.timelineSuspense.trigger())
|
||||
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().resourceState)).toBe("refreshing")
|
||||
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().routeConnected)).toBe(false)
|
||||
await page.evaluate(() => window.timelineSuspense.frames(3))
|
||||
}
|
||||
|
||||
async function resolveSuspension(page: Page) {
|
||||
await page.evaluate(() => window.timelineSuspense.resolve())
|
||||
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().resourceState)).toBe("ready")
|
||||
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().routeConnected)).toBe(true)
|
||||
await page.evaluate(() => window.timelineSuspense.frames(3))
|
||||
return page.evaluate(() => window.timelineSuspense.snapshot())
|
||||
}
|
||||
|
||||
function diagnostic(value: unknown) {
|
||||
return JSON.stringify(value, null, 2)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "vite"
|
||||
import solid from "vite-plugin-solid"
|
||||
|
||||
export default defineConfig({
|
||||
root: import.meta.dirname,
|
||||
plugins: [solid()],
|
||||
})
|
||||
@@ -10,6 +10,9 @@
|
||||
"./performance/timeline-stability/fixture.test.ts",
|
||||
"./performance/timeline-stability/fixture.ts",
|
||||
"./performance/unit/visual-stability.test.ts",
|
||||
"./reproduction/timeline-suspense/**/*.ts",
|
||||
"./reproduction/timeline-suspense/**/*.tsx",
|
||||
"../src/pages/session/timeline/observe-element-offset.ts",
|
||||
"./regression/new-session-panel-corner.spec.ts",
|
||||
"./regression/session-timeline-context-resize.spec.ts",
|
||||
"./utils/**/*.ts"
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user