session-tab-repaint-probe.ts 10.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251
  1. import type { Page } from "@playwright/test"
  2. type CachedRepaintTrace = {
  3. timeOriginEpochMs: number
  4. startedAtPerformanceMs: number
  5. samples: {
  6. observedAtMs: number
  7. root: number | undefined
  8. scrollTop: number
  9. scrollHeight: number
  10. bottomErrorPx: number | undefined
  11. last: boolean
  12. rows: { key: string | undefined; node: number; top: number; bottom: number }[]
  13. mounted: number
  14. center: string | undefined
  15. destination: string[]
  16. source: string[]
  17. }[]
  18. mutations: { observedAtMs: number; changed: { type: string; node: number }[] }[]
  19. shifts: { occurredAtMs: number; value: number }[]
  20. windowMs: number
  21. running: boolean
  22. stop: () => void
  23. }
  24. export async function installCachedRepaintProbe(
  25. page: Page,
  26. input: { targetHref: string; destination: string[]; source: string[]; last: string; windowMs: number },
  27. ) {
  28. await page.evaluate(({ targetHref, destination, source, last, windowMs }) => {
  29. const destinationIDs = new Set(destination)
  30. const sourceIDs = new Set(source)
  31. const nodeIDs = new WeakMap<Node, number>()
  32. let nextNodeID = 1
  33. const id = (node: Node) => {
  34. const current = nodeIDs.get(node)
  35. if (current) return current
  36. nodeIDs.set(node, nextNodeID)
  37. return nextNodeID++
  38. }
  39. const state: CachedRepaintTrace = {
  40. timeOriginEpochMs: performance.timeOrigin,
  41. startedAtPerformanceMs: 0,
  42. samples: [],
  43. mutations: [],
  44. shifts: [],
  45. windowMs,
  46. running: false,
  47. stop: () => {},
  48. }
  49. const recordShifts = (entries: PerformanceEntry[]) => {
  50. if (!state.running) return
  51. state.shifts.push(
  52. ...entries
  53. .map((entry) => {
  54. if (
  55. entry.startTime < state.startedAtPerformanceMs ||
  56. entry.startTime > state.startedAtPerformanceMs + state.windowMs
  57. )
  58. return
  59. return {
  60. occurredAtMs: entry.startTime - state.startedAtPerformanceMs,
  61. value: (entry as PerformanceEntry & { value: number }).value,
  62. }
  63. })
  64. .filter((entry): entry is { occurredAtMs: number; value: number } => entry !== undefined),
  65. )
  66. }
  67. const shiftObserver = new PerformanceObserver((entries) => recordShifts(entries.getEntries()))
  68. shiftObserver.observe({ type: "layout-shift" })
  69. const recordMutations = (entries: MutationRecord[]) => {
  70. if (!state.running) return
  71. const observedAtMs = performance.now() - state.startedAtPerformanceMs
  72. if (observedAtMs > state.windowMs) return
  73. const changed = entries.flatMap((entry) => [
  74. ...[...entry.addedNodes].map((node) => ({ type: "add", node: id(node) })),
  75. ...[...entry.removedNodes].map((node) => ({ type: "remove", node: id(node) })),
  76. ])
  77. if (changed.length) state.mutations.push({ observedAtMs, changed })
  78. }
  79. const mutationObserver = new MutationObserver(recordMutations)
  80. mutationObserver.observe(document.documentElement, { childList: true, subtree: true })
  81. state.stop = () => {
  82. recordShifts(shiftObserver.takeRecords())
  83. recordMutations(mutationObserver.takeRecords())
  84. state.running = false
  85. shiftObserver.disconnect()
  86. mutationObserver.disconnect()
  87. }
  88. const sample = () => {
  89. if (!state.running) return
  90. setTimeout(() => {
  91. if (!state.running) return
  92. const observedAtMs = performance.now() - state.startedAtPerformanceMs
  93. if (observedAtMs > state.windowMs) return
  94. const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
  95. element.querySelector("[data-timeline-row]"),
  96. )
  97. if (root) {
  98. const view = root.getBoundingClientRect()
  99. const rows = [...root.querySelectorAll<HTMLElement>("[data-timeline-key]")]
  100. .map((element) => ({
  101. key: element.dataset.timelineKey,
  102. node: id(element),
  103. rect: element.getBoundingClientRect(),
  104. }))
  105. .filter((item) => item.rect.bottom > view.top && item.rect.top < view.bottom)
  106. .map((item) => ({
  107. key: item.key,
  108. node: item.node,
  109. top: item.rect.top - view.top,
  110. bottom: item.rect.bottom - view.top,
  111. }))
  112. const messages = [...root.querySelectorAll<HTMLElement>("[data-message-id]")]
  113. .filter((element) => {
  114. const rect = element.getBoundingClientRect()
  115. return rect.bottom > view.top && rect.top < view.bottom
  116. })
  117. .map((element) => element.dataset.messageId!)
  118. const spacer = root.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')?.getBoundingClientRect()
  119. state.samples.push({
  120. observedAtMs,
  121. root: id(root),
  122. scrollTop: root.scrollTop,
  123. scrollHeight: root.scrollHeight,
  124. bottomErrorPx: spacer ? spacer.bottom - view.bottom : undefined,
  125. last: messages.includes(last),
  126. rows,
  127. mounted: root.querySelectorAll("[data-timeline-key]").length,
  128. center: document
  129. .elementFromPoint(view.left + view.width / 2, view.top + view.height / 2)
  130. ?.textContent?.slice(0, 80),
  131. destination: messages.filter((messageID) => destinationIDs.has(messageID)),
  132. source: messages.filter((messageID) => sourceIDs.has(messageID)),
  133. })
  134. } else {
  135. state.samples.push({
  136. observedAtMs,
  137. root: undefined,
  138. scrollTop: 0,
  139. scrollHeight: 0,
  140. bottomErrorPx: undefined,
  141. last: false,
  142. rows: [],
  143. mounted: 0,
  144. center: document.elementFromPoint(innerWidth / 2, innerHeight / 2)?.textContent?.slice(0, 80),
  145. destination: [],
  146. source: [],
  147. })
  148. }
  149. requestAnimationFrame(sample)
  150. }, 0)
  151. }
  152. document.addEventListener(
  153. "click",
  154. (event) => {
  155. const link = event.target instanceof Element ? event.target.closest("a") : undefined
  156. if (link?.getAttribute("href") !== targetHref) return
  157. state.startedAtPerformanceMs = performance.now()
  158. state.running = true
  159. requestAnimationFrame(sample)
  160. },
  161. { capture: true, once: true },
  162. )
  163. ;(window as Window & { __cachedFlash?: CachedRepaintTrace }).__cachedFlash = state
  164. }, input)
  165. }
  166. export function layoutShiftSample(entry: Pick<PerformanceEntry, "startTime"> & { value: number }, started: number) {
  167. if (entry.startTime < started) return
  168. return { occurredAtMs: entry.startTime - started, value: entry.value }
  169. }
  170. export async function waitForCachedRepaintWindow(page: Page, durationMs: number) {
  171. await page.waitForFunction((durationMs) => {
  172. const state = (window as Window & { __cachedFlash?: CachedRepaintTrace }).__cachedFlash
  173. return !!state?.running && performance.now() - state.startedAtPerformanceMs >= durationMs
  174. }, durationMs)
  175. }
  176. export async function collectCachedRepaintTrace(page: Page) {
  177. return page.evaluate(() => {
  178. const state = (window as Window & { __cachedFlash?: CachedRepaintTrace }).__cachedFlash!
  179. state.stop()
  180. return state
  181. })
  182. }
  183. export function summarizeCachedRepaintTrace(trace: CachedRepaintTrace) {
  184. const roots = trace.samples.map((sample) => sample.root)
  185. const bottomErrors = trace.samples.flatMap((sample) =>
  186. sample.bottomErrorPx === undefined ? [] : [Math.abs(sample.bottomErrorPx)],
  187. )
  188. const category = (sample: CachedRepaintTrace["samples"][number]) => {
  189. if (sample.source.length) return "source"
  190. if (sample.root === undefined || sample.rows.length === 0) return "blank"
  191. if (!sample.destination.length) return "unknown"
  192. if (sample.last && Math.abs(sample.bottomErrorPx ?? Infinity) <= 1) return "correct"
  193. return "wrongDestination"
  194. }
  195. return {
  196. samples: trace.samples.length,
  197. durationMs: trace.samples.at(-1)?.observedAtMs ?? 0,
  198. firstSampleObservedMs: trace.samples[0]?.observedAtMs,
  199. firstSampleCorrect: trace.samples[0] ? category(trace.samples[0]) === "correct" : false,
  200. blankSamples: trace.samples.filter((sample) => category(sample) === "blank").length,
  201. sourceSamples: trace.samples.filter((sample) => category(sample) === "source").length,
  202. wrongDestinationSamples: trace.samples.filter((sample) => category(sample) === "wrongDestination").length,
  203. unknownSamples: trace.samples.filter((sample) => category(sample) === "unknown").length,
  204. rootChanges: roots.slice(1).filter((root, index) => root !== roots[index]).length,
  205. mountedMin: trace.samples.length ? Math.min(...trace.samples.map((sample) => sample.mounted)) : 0,
  206. mountedMax: Math.max(...trace.samples.map((sample) => sample.mounted)),
  207. maxBottomErrorPx: Math.max(0, ...bottomErrors),
  208. mutationBatches: trace.mutations.length,
  209. addedNodes: trace.mutations.reduce(
  210. (sum, batch) => sum + batch.changed.filter((change) => change.type === "add").length,
  211. 0,
  212. ),
  213. removedNodes: trace.mutations.reduce(
  214. (sum, batch) => sum + batch.changed.filter((change) => change.type === "remove").length,
  215. 0,
  216. ),
  217. layoutShiftValueSum: trace.shifts.reduce((sum, shift) => sum + shift.value, 0),
  218. maxLayoutShiftValue: Math.max(0, ...trace.shifts.map((shift) => shift.value)),
  219. }
  220. }
  221. export function compressCachedRepaintTrace(trace: CachedRepaintTrace) {
  222. const samples: {
  223. observedAtMs: number[]
  224. state: Omit<CachedRepaintTrace["samples"][number], "observedAtMs">
  225. }[] = []
  226. for (const sample of trace.samples) {
  227. const { observedAtMs, ...state } = sample
  228. const previous = samples.at(-1)
  229. if (previous && JSON.stringify(previous.state) === JSON.stringify(state)) {
  230. previous.observedAtMs.push(observedAtMs)
  231. continue
  232. }
  233. samples.push({ observedAtMs: [observedAtMs], state })
  234. }
  235. return {
  236. timeOriginEpochMs: trace.timeOriginEpochMs,
  237. startedAtPerformanceMs: trace.startedAtPerformanceMs,
  238. windowMs: trace.windowMs,
  239. summary: summarizeCachedRepaintTrace(trace),
  240. samples,
  241. mutations: trace.mutations,
  242. shifts: trace.shifts,
  243. }
  244. }