main.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315
  1. import { createResource, createSignal, For, onMount, Suspense } from "solid-js"
  2. import { render } from "solid-js/web"
  3. import { createVirtualizer, observeElementOffset, observeElementRect } from "@tanstack/solid-virtual"
  4. import { observeElementOffsetReconnectAware } from "../../../src/pages/session/timeline/observe-element-offset"
  5. const rowCount = 2_000
  6. const rowHeight = 40
  7. const parameters = new URLSearchParams(location.search)
  8. const resourceMode = parameters.get("resource") === "guard" ? "guard" : "baseline"
  9. const reconnectMode = parameters.get("reconnect") === "candidate" ? "candidate" : "baseline"
  10. type MutationEvent = {
  11. kind: "removed" | "added"
  12. callbackTime: number
  13. callbackFrame: number
  14. routeConnectedInCallback: boolean
  15. nativeOffsetInCallback: number
  16. }
  17. type Snapshot = {
  18. mode: {
  19. resource: "baseline" | "guard"
  20. reconnect: "baseline" | "candidate"
  21. }
  22. operation: {
  23. sequence: number
  24. phase: string
  25. time: number
  26. frame: number
  27. }
  28. resourceState: string
  29. routeConnected: boolean
  30. viewportConnected: boolean
  31. viewportOwnedByRoute: boolean
  32. sameRoute: boolean
  33. sameViewport: boolean
  34. sameSurface: boolean
  35. sameMountedRows: boolean
  36. nativeOffset: number
  37. coreOffset: number
  38. rangeStart: number
  39. rangeEnd: number
  40. indexes: number[]
  41. domIndexes: number[]
  42. logicalSurfaceHeight: number
  43. renderedSurfaceHeight: number
  44. viewportClientHeight: number
  45. viewportScrollHeight: number
  46. visibleRows: number
  47. minimumRowTop: number
  48. domScrollEvents: number
  49. lastScrollTrusted: boolean
  50. coreOffsetCallbackCalls: number
  51. offsetCallbackSources: "observer"[]
  52. rectObserverCallbacks: number
  53. ignoredDetachedZeroRects: number
  54. syntheticScrollDispatches: number
  55. mutationEvents: MutationEvent[]
  56. }
  57. declare global {
  58. interface Window {
  59. timelineSuspense: {
  60. prepare: () => Promise<Snapshot>
  61. trigger: () => void
  62. resolve: () => void
  63. frames: (count?: number) => Promise<void>
  64. snapshot: () => Snapshot
  65. }
  66. }
  67. }
  68. function App() {
  69. const [refresh, setRefresh] = createSignal(false)
  70. let resolveResource: (() => void) | undefined
  71. const [resource] = createResource(
  72. refresh,
  73. (version) =>
  74. new Promise<string>((resolve) => {
  75. resolveResource = () => resolve(`settled-${version}`)
  76. }),
  77. { initialValue: "settled" },
  78. )
  79. function Route() {
  80. let route: HTMLElement | undefined
  81. let viewport: HTMLDivElement | undefined
  82. let surface: HTMLDivElement | undefined
  83. let initialRoute: HTMLElement | undefined
  84. let initialViewport: HTMLDivElement | undefined
  85. let initialSurface: HTMLDivElement | undefined
  86. let initialRows: HTMLElement[] = []
  87. let phase = "mounting"
  88. let browserFrame = 0
  89. let snapshotSequence = 0
  90. let domScrollEvents = 0
  91. let lastScrollTrusted = false
  92. let coreOffsetCallbackCalls = 0
  93. let rectObserverCallbacks = 0
  94. let ignoredDetachedZeroRects = 0
  95. const offsetCallbackSources: "observer"[] = []
  96. const mutationEvents: MutationEvent[] = []
  97. const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
  98. count: rowCount,
  99. getScrollElement: () => viewport ?? null,
  100. estimateSize: () => rowHeight,
  101. initialRect: { width: 900, height: 600 },
  102. overscan: 2,
  103. observeElementRect: (instance, callback) =>
  104. observeElementRect(instance, (rect) => {
  105. rectObserverCallbacks++
  106. // A fixed 600px viewport has no usable geometry while detached. Keep the last connected rect.
  107. if (!instance.scrollElement?.isConnected && rect.height === 0) {
  108. ignoredDetachedZeroRects++
  109. return
  110. }
  111. callback(rect)
  112. }),
  113. observeElementOffset: (instance, callback) => {
  114. const deliver = (offset: number, isScrolling: boolean) => {
  115. coreOffsetCallbackCalls++
  116. offsetCallbackSources.push("observer")
  117. callback(offset, isScrolling)
  118. }
  119. if (reconnectMode === "candidate") return observeElementOffsetReconnectAware(instance, deliver)
  120. return observeElementOffset(instance, deliver)
  121. },
  122. })
  123. const frames = async (count = 2) => {
  124. for (let index = 0; index < count; index++) {
  125. await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
  126. }
  127. }
  128. const mountedRows = () => [...(surface?.querySelectorAll<HTMLElement>("[data-row-index]") ?? [])]
  129. const snapshot = (): Snapshot => {
  130. const rows = mountedRows()
  131. const view = viewport?.getBoundingClientRect()
  132. const visibleRows =
  133. viewport?.isConnected && view
  134. ? rows.filter((row) => {
  135. const rect = row.getBoundingClientRect()
  136. return rect.bottom > view.top && rect.top < view.bottom
  137. }).length
  138. : 0
  139. return {
  140. mode: { resource: resourceMode, reconnect: reconnectMode },
  141. operation: {
  142. sequence: ++snapshotSequence,
  143. phase,
  144. time: performance.now(),
  145. frame: browserFrame,
  146. },
  147. resourceState: resource.state,
  148. routeConnected: route?.isConnected ?? false,
  149. viewportConnected: viewport?.isConnected ?? false,
  150. viewportOwnedByRoute: !!route && !!viewport && route.contains(viewport),
  151. sameRoute: route === initialRoute,
  152. sameViewport: viewport === initialViewport,
  153. sameSurface: surface === initialSurface,
  154. sameMountedRows:
  155. initialRows.length > 0 &&
  156. initialRows.length === rows.length &&
  157. initialRows.every((row, index) => row === rows[index]),
  158. nativeOffset: viewport?.scrollTop ?? -1,
  159. coreOffset: virtualizer.scrollOffset ?? -1,
  160. rangeStart: virtualizer.range?.startIndex ?? -1,
  161. rangeEnd: virtualizer.range?.endIndex ?? -1,
  162. indexes: virtualizer.getVirtualItems().map((item) => item.index),
  163. domIndexes: rows.map((row) => Number(row.dataset.rowIndex)),
  164. logicalSurfaceHeight: Number.parseFloat(surface?.style.height ?? "-1"),
  165. renderedSurfaceHeight: surface?.getBoundingClientRect().height ?? -1,
  166. viewportClientHeight: viewport?.clientHeight ?? -1,
  167. viewportScrollHeight: viewport?.scrollHeight ?? -1,
  168. visibleRows,
  169. minimumRowTop:
  170. rows.length && view ? Math.min(...rows.map((row) => row.getBoundingClientRect().top - view.top)) : -1,
  171. domScrollEvents,
  172. lastScrollTrusted,
  173. coreOffsetCallbackCalls,
  174. offsetCallbackSources: [...offsetCallbackSources],
  175. rectObserverCallbacks,
  176. ignoredDetachedZeroRects,
  177. syntheticScrollDispatches: 0,
  178. mutationEvents: mutationEvents.map((event) => ({ ...event })),
  179. }
  180. }
  181. onMount(() => {
  182. if (!route || !viewport || !surface) throw new Error("Timeline fixture did not mount")
  183. const routeRoot = route.parentElement
  184. if (!routeRoot) throw new Error("Timeline route root did not mount")
  185. initialRoute = route
  186. initialViewport = viewport
  187. initialSurface = surface
  188. viewport.addEventListener("scroll", (event) => {
  189. domScrollEvents++
  190. lastScrollTrusted = event.isTrusted
  191. })
  192. const countFrames = () => {
  193. browserFrame++
  194. requestAnimationFrame(countFrames)
  195. }
  196. requestAnimationFrame(countFrames)
  197. new MutationObserver((records) => {
  198. const callbackTime = performance.now()
  199. records.forEach((record) => {
  200. ;([...(record.removedNodes ?? [])] as Node[]).forEach((node) => {
  201. if (node !== route) return
  202. phase = "detached"
  203. mutationEvents.push({
  204. kind: "removed",
  205. callbackTime,
  206. callbackFrame: browserFrame,
  207. routeConnectedInCallback: route.isConnected,
  208. nativeOffsetInCallback: viewport.scrollTop,
  209. })
  210. })
  211. ;([...(record.addedNodes ?? [])] as Node[]).forEach((node) => {
  212. if (node !== route) return
  213. phase = "reinserted"
  214. mutationEvents.push({
  215. kind: "added",
  216. callbackTime,
  217. callbackFrame: browserFrame,
  218. routeConnectedInCallback: route.isConnected,
  219. nativeOffsetInCallback: viewport.scrollTop,
  220. })
  221. })
  222. })
  223. }).observe(routeRoot, { childList: true })
  224. window.timelineSuspense = {
  225. prepare: async () => {
  226. phase = "preparing"
  227. await frames(2)
  228. viewport.scrollTop = viewport.scrollHeight
  229. await frames(3)
  230. await new Promise((resolve) => setTimeout(resolve, 200))
  231. await frames(2)
  232. initialRows = mountedRows()
  233. phase = "prepared"
  234. return snapshot()
  235. },
  236. trigger: () => {
  237. phase = "triggering"
  238. setRefresh(true)
  239. },
  240. resolve: () => {
  241. if (!resolveResource) throw new Error("Resource is not pending")
  242. phase = "resolving"
  243. resolveResource()
  244. },
  245. frames,
  246. snapshot,
  247. }
  248. })
  249. return (
  250. <section ref={route} data-route style={{ width: "900px", margin: "0 auto" }}>
  251. <span aria-hidden="true" style={{ display: "none" }}>
  252. {resourceMode === "guard" && resource.state === "refreshing" ? resource.latest : resource()}
  253. </span>
  254. <div
  255. ref={viewport}
  256. data-viewport
  257. style={{
  258. height: "600px",
  259. overflow: "auto",
  260. "overflow-anchor": "none",
  261. position: "relative",
  262. background: "#202020",
  263. outline: "1px solid #3f3f46",
  264. }}
  265. >
  266. <div
  267. ref={surface}
  268. data-surface
  269. style={{ height: `${virtualizer.getTotalSize()}px`, position: "relative", "overflow-anchor": "none" }}
  270. >
  271. <For each={virtualizer.getVirtualItems()}>
  272. {(item) => (
  273. <div
  274. data-row-index={item.index}
  275. style={{
  276. position: "absolute",
  277. top: "0",
  278. left: "0",
  279. width: "100%",
  280. height: `${item.size}px`,
  281. transform: `translateY(${item.start}px)`,
  282. padding: "10px 14px",
  283. border: "0 solid #333",
  284. "border-bottom-width": "1px",
  285. }}
  286. >
  287. logical row {item.index}
  288. </div>
  289. )}
  290. </For>
  291. </div>
  292. </div>
  293. </section>
  294. )
  295. }
  296. return (
  297. <main>
  298. <Suspense>
  299. <Route />
  300. </Suspense>
  301. </main>
  302. )
  303. }
  304. render(() => <App />, document.getElementById("root")!)