| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251 |
- import type { Page } from "@playwright/test"
- type CachedRepaintTrace = {
- timeOriginEpochMs: number
- startedAtPerformanceMs: number
- samples: {
- observedAtMs: number
- root: number | undefined
- scrollTop: number
- scrollHeight: number
- bottomErrorPx: number | undefined
- last: boolean
- rows: { key: string | undefined; node: number; top: number; bottom: number }[]
- mounted: number
- center: string | undefined
- destination: string[]
- source: string[]
- }[]
- mutations: { observedAtMs: number; changed: { type: string; node: number }[] }[]
- shifts: { occurredAtMs: number; value: number }[]
- windowMs: number
- running: boolean
- stop: () => void
- }
- export async function installCachedRepaintProbe(
- page: Page,
- input: { targetHref: string; destination: string[]; source: string[]; last: string; windowMs: number },
- ) {
- await page.evaluate(({ targetHref, destination, source, last, windowMs }) => {
- const destinationIDs = new Set(destination)
- const sourceIDs = new Set(source)
- const nodeIDs = new WeakMap<Node, number>()
- let nextNodeID = 1
- const id = (node: Node) => {
- const current = nodeIDs.get(node)
- if (current) return current
- nodeIDs.set(node, nextNodeID)
- return nextNodeID++
- }
- const state: CachedRepaintTrace = {
- timeOriginEpochMs: performance.timeOrigin,
- startedAtPerformanceMs: 0,
- samples: [],
- mutations: [],
- shifts: [],
- windowMs,
- running: false,
- stop: () => {},
- }
- const recordShifts = (entries: PerformanceEntry[]) => {
- if (!state.running) return
- state.shifts.push(
- ...entries
- .map((entry) => {
- if (
- entry.startTime < state.startedAtPerformanceMs ||
- entry.startTime > state.startedAtPerformanceMs + state.windowMs
- )
- return
- return {
- occurredAtMs: entry.startTime - state.startedAtPerformanceMs,
- value: (entry as PerformanceEntry & { value: number }).value,
- }
- })
- .filter((entry): entry is { occurredAtMs: number; value: number } => entry !== undefined),
- )
- }
- const shiftObserver = new PerformanceObserver((entries) => recordShifts(entries.getEntries()))
- shiftObserver.observe({ type: "layout-shift" })
- const recordMutations = (entries: MutationRecord[]) => {
- if (!state.running) return
- const observedAtMs = performance.now() - state.startedAtPerformanceMs
- if (observedAtMs > state.windowMs) return
- const changed = entries.flatMap((entry) => [
- ...[...entry.addedNodes].map((node) => ({ type: "add", node: id(node) })),
- ...[...entry.removedNodes].map((node) => ({ type: "remove", node: id(node) })),
- ])
- if (changed.length) state.mutations.push({ observedAtMs, changed })
- }
- const mutationObserver = new MutationObserver(recordMutations)
- mutationObserver.observe(document.documentElement, { childList: true, subtree: true })
- state.stop = () => {
- recordShifts(shiftObserver.takeRecords())
- recordMutations(mutationObserver.takeRecords())
- state.running = false
- shiftObserver.disconnect()
- mutationObserver.disconnect()
- }
- const sample = () => {
- if (!state.running) return
- setTimeout(() => {
- if (!state.running) return
- const observedAtMs = performance.now() - state.startedAtPerformanceMs
- if (observedAtMs > state.windowMs) return
- const root = [...document.querySelectorAll<HTMLElement>(".scroll-view__viewport")].find((element) =>
- element.querySelector("[data-timeline-row]"),
- )
- if (root) {
- const view = root.getBoundingClientRect()
- const rows = [...root.querySelectorAll<HTMLElement>("[data-timeline-key]")]
- .map((element) => ({
- key: element.dataset.timelineKey,
- node: id(element),
- rect: element.getBoundingClientRect(),
- }))
- .filter((item) => item.rect.bottom > view.top && item.rect.top < view.bottom)
- .map((item) => ({
- key: item.key,
- node: item.node,
- top: item.rect.top - view.top,
- bottom: item.rect.bottom - view.top,
- }))
- const messages = [...root.querySelectorAll<HTMLElement>("[data-message-id]")]
- .filter((element) => {
- const rect = element.getBoundingClientRect()
- return rect.bottom > view.top && rect.top < view.bottom
- })
- .map((element) => element.dataset.messageId!)
- const spacer = root.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')?.getBoundingClientRect()
- state.samples.push({
- observedAtMs,
- root: id(root),
- scrollTop: root.scrollTop,
- scrollHeight: root.scrollHeight,
- bottomErrorPx: spacer ? spacer.bottom - view.bottom : undefined,
- last: messages.includes(last),
- rows,
- mounted: root.querySelectorAll("[data-timeline-key]").length,
- center: document
- .elementFromPoint(view.left + view.width / 2, view.top + view.height / 2)
- ?.textContent?.slice(0, 80),
- destination: messages.filter((messageID) => destinationIDs.has(messageID)),
- source: messages.filter((messageID) => sourceIDs.has(messageID)),
- })
- } else {
- state.samples.push({
- observedAtMs,
- root: undefined,
- scrollTop: 0,
- scrollHeight: 0,
- bottomErrorPx: undefined,
- last: false,
- rows: [],
- mounted: 0,
- center: document.elementFromPoint(innerWidth / 2, innerHeight / 2)?.textContent?.slice(0, 80),
- destination: [],
- source: [],
- })
- }
- requestAnimationFrame(sample)
- }, 0)
- }
- document.addEventListener(
- "click",
- (event) => {
- const link = event.target instanceof Element ? event.target.closest("a") : undefined
- if (link?.getAttribute("href") !== targetHref) return
- state.startedAtPerformanceMs = performance.now()
- state.running = true
- requestAnimationFrame(sample)
- },
- { capture: true, once: true },
- )
- ;(window as Window & { __cachedFlash?: CachedRepaintTrace }).__cachedFlash = state
- }, input)
- }
- export function layoutShiftSample(entry: Pick<PerformanceEntry, "startTime"> & { value: number }, started: number) {
- if (entry.startTime < started) return
- return { occurredAtMs: entry.startTime - started, value: entry.value }
- }
- export async function waitForCachedRepaintWindow(page: Page, durationMs: number) {
- await page.waitForFunction((durationMs) => {
- const state = (window as Window & { __cachedFlash?: CachedRepaintTrace }).__cachedFlash
- return !!state?.running && performance.now() - state.startedAtPerformanceMs >= durationMs
- }, durationMs)
- }
- export async function collectCachedRepaintTrace(page: Page) {
- return page.evaluate(() => {
- const state = (window as Window & { __cachedFlash?: CachedRepaintTrace }).__cachedFlash!
- state.stop()
- return state
- })
- }
- export function summarizeCachedRepaintTrace(trace: CachedRepaintTrace) {
- const roots = trace.samples.map((sample) => sample.root)
- const bottomErrors = trace.samples.flatMap((sample) =>
- sample.bottomErrorPx === undefined ? [] : [Math.abs(sample.bottomErrorPx)],
- )
- const category = (sample: CachedRepaintTrace["samples"][number]) => {
- if (sample.source.length) return "source"
- if (sample.root === undefined || sample.rows.length === 0) return "blank"
- if (!sample.destination.length) return "unknown"
- if (sample.last && Math.abs(sample.bottomErrorPx ?? Infinity) <= 1) return "correct"
- return "wrongDestination"
- }
- return {
- samples: trace.samples.length,
- durationMs: trace.samples.at(-1)?.observedAtMs ?? 0,
- firstSampleObservedMs: trace.samples[0]?.observedAtMs,
- firstSampleCorrect: trace.samples[0] ? category(trace.samples[0]) === "correct" : false,
- blankSamples: trace.samples.filter((sample) => category(sample) === "blank").length,
- sourceSamples: trace.samples.filter((sample) => category(sample) === "source").length,
- wrongDestinationSamples: trace.samples.filter((sample) => category(sample) === "wrongDestination").length,
- unknownSamples: trace.samples.filter((sample) => category(sample) === "unknown").length,
- rootChanges: roots.slice(1).filter((root, index) => root !== roots[index]).length,
- mountedMin: trace.samples.length ? Math.min(...trace.samples.map((sample) => sample.mounted)) : 0,
- mountedMax: Math.max(...trace.samples.map((sample) => sample.mounted)),
- maxBottomErrorPx: Math.max(0, ...bottomErrors),
- mutationBatches: trace.mutations.length,
- addedNodes: trace.mutations.reduce(
- (sum, batch) => sum + batch.changed.filter((change) => change.type === "add").length,
- 0,
- ),
- removedNodes: trace.mutations.reduce(
- (sum, batch) => sum + batch.changed.filter((change) => change.type === "remove").length,
- 0,
- ),
- layoutShiftValueSum: trace.shifts.reduce((sum, shift) => sum + shift.value, 0),
- maxLayoutShiftValue: Math.max(0, ...trace.shifts.map((shift) => shift.value)),
- }
- }
- export function compressCachedRepaintTrace(trace: CachedRepaintTrace) {
- const samples: {
- observedAtMs: number[]
- state: Omit<CachedRepaintTrace["samples"][number], "observedAtMs">
- }[] = []
- for (const sample of trace.samples) {
- const { observedAtMs, ...state } = sample
- const previous = samples.at(-1)
- if (previous && JSON.stringify(previous.state) === JSON.stringify(state)) {
- previous.observedAtMs.push(observedAtMs)
- continue
- }
- samples.push({ observedAtMs: [observedAtMs], state })
- }
- return {
- timeOriginEpochMs: trace.timeOriginEpochMs,
- startedAtPerformanceMs: trace.startedAtPerformanceMs,
- windowMs: trace.windowMs,
- summary: summarizeCachedRepaintTrace(trace),
- samples,
- mutations: trace.mutations,
- shifts: trace.shifts,
- }
- }
|