Bläddra i källkod

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

Luke Parker 1 månad sedan
förälder
incheckning
7304bb2751

+ 34 - 0
packages/app/e2e/reproduction/timeline-suspense/index.html

@@ -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>

+ 315 - 0
packages/app/e2e/reproduction/timeline-suspense/main.tsx

@@ -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")!)

+ 34 - 0
packages/app/e2e/reproduction/timeline-suspense/playwright.config.ts

@@ -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"] },
+    },
+  ],
+})

+ 179 - 0
packages/app/e2e/reproduction/timeline-suspense/timeline-suspense.repro.ts

@@ -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)
+}

+ 7 - 0
packages/app/e2e/reproduction/timeline-suspense/vite.config.ts

@@ -0,0 +1,7 @@
+import { defineConfig } from "vite"
+import solid from "vite-plugin-solid"
+
+export default defineConfig({
+  root: import.meta.dirname,
+  plugins: [solid()],
+})

+ 3 - 0
packages/app/e2e/tsconfig.json

@@ -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"

+ 2 - 0
packages/app/src/pages/session/timeline/message-timeline.tsx

@@ -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,

+ 199 - 0
packages/app/src/pages/session/timeline/observe-element-offset.test.ts

@@ -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()))
+  }
+}

+ 73 - 0
packages/app/src/pages/session/timeline/observe-element-offset.ts

@@ -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))
+}