From ed926213573acd245a80de27084c4eaa534e0af0 Mon Sep 17 00:00:00 2001 From: Ahfu C Kit III <196157628+ahfuckit@users.noreply.github.com> Date: Fri, 23 Jan 2026 07:40:37 -0700 Subject: [PATCH 1/5] Add files via upload Signed-off-by: Ahfu C Kit III <196157628+ahfuckit@users.noreply.github.com> --- web-vitals/base/metric.ts | 11 + web-vitals/base/observer.ts | 66 +++++ web-vitals/base/processor.ts | 6 + web-vitals/cls/layout-shift-processor.ts | 65 +++++ web-vitals/cls/metric.ts | 40 ++++ web-vitals/cls/observer.ts | 34 +++ web-vitals/dom-nodes.ts | 21 ++ web-vitals/element-timing/metric.ts | 20 ++ web-vitals/element-timing/observer.ts | 69 ++++++ web-vitals/get-selector.ts | 32 +++ web-vitals/hpc-events.ts | 84 +++++++ web-vitals/hpc-events1.ts | 84 +++++++ web-vitals/hpc.ts | 293 +++++++++++++++++++++++ web-vitals/hydro-stats.ts | 198 +++++++++++++++ web-vitals/hydro-stats1.ts | 198 +++++++++++++++ web-vitals/inp/interaction-count.ts | 63 +++++ web-vitals/inp/interaction-list.ts | 77 ++++++ web-vitals/inp/interaction-processor.ts | 124 ++++++++++ web-vitals/inp/metric.ts | 187 +++++++++++++++ web-vitals/inp/observer.ts | 80 +++++++ web-vitals/long-animation-frames.ts | 28 +++ web-vitals/utils/when-idle-or-hidden.ts | 24 ++ web-vitals/web-vitals.ts | 69 ++++++ 23 files changed, 1873 insertions(+) create mode 100644 web-vitals/base/metric.ts create mode 100644 web-vitals/base/observer.ts create mode 100644 web-vitals/base/processor.ts create mode 100644 web-vitals/cls/layout-shift-processor.ts create mode 100644 web-vitals/cls/metric.ts create mode 100644 web-vitals/cls/observer.ts create mode 100644 web-vitals/dom-nodes.ts create mode 100644 web-vitals/element-timing/metric.ts create mode 100644 web-vitals/element-timing/observer.ts create mode 100644 web-vitals/get-selector.ts create mode 100644 web-vitals/hpc-events.ts create mode 100644 web-vitals/hpc-events1.ts create mode 100644 web-vitals/hpc.ts create mode 100644 web-vitals/hydro-stats.ts create mode 100644 web-vitals/hydro-stats1.ts create mode 100644 web-vitals/inp/interaction-count.ts create mode 100644 web-vitals/inp/interaction-list.ts create mode 100644 web-vitals/inp/interaction-processor.ts create mode 100644 web-vitals/inp/metric.ts create mode 100644 web-vitals/inp/observer.ts create mode 100644 web-vitals/long-animation-frames.ts create mode 100644 web-vitals/utils/when-idle-or-hidden.ts create mode 100644 web-vitals/web-vitals.ts diff --git a/web-vitals/base/metric.ts b/web-vitals/base/metric.ts new file mode 100644 index 0000000..2b4162e --- /dev/null +++ b/web-vitals/base/metric.ts @@ -0,0 +1,11 @@ +export abstract class BaseMetric { + value: number + entries: EntryType[] + + constructor(value: number, entries: EntryType[]) { + this.value = value + this.entries = entries + } + + abstract get attribution(): AttributionType +} diff --git a/web-vitals/base/observer.ts b/web-vitals/base/observer.ts new file mode 100644 index 0000000..aaa9596 --- /dev/null +++ b/web-vitals/base/observer.ts @@ -0,0 +1,66 @@ +import {ssrSafeDocument} from '@github-ui/ssr-utils' +import type {BaseProcessor} from './processor' +import type {BaseMetric} from './metric' + +type ObserverCallback = (metric: MetricType, opts: {url?: string}) => void + +/* + * The CLSObserver is responsible for listening to Performance events and routing them to the entryProcessor. + * It also manages resetting CLS and reporting it when navigating or hiding a page. + */ +export abstract class BaseObserver, EntryType> { + cb: ObserverCallback + entryProcessor: BaseProcessor + observer?: PerformanceObserver + url?: string + + constructor(cb: ObserverCallback) { + this.cb = cb + this.entryProcessor = this.initializeProcessor() + this.setupListeners() + } + + abstract initializeProcessor(): BaseProcessor + abstract get supported(): boolean + abstract get softNavEventToListen(): string + + setupListeners() { + if (!this.supported) return + + const onHiddenOrPageHide = (event: Event) => { + if (event.type === 'pagehide' || document.visibilityState === 'hidden') { + this.report() + } + } + + // Similar to web-vitals, we report the current CLS when hard navigating or + // when the page is hidden + ssrSafeDocument?.addEventListener('visibilitychange', onHiddenOrPageHide, true) + ssrSafeDocument?.addEventListener('pagehide', onHiddenOrPageHide, true) + + ssrSafeDocument?.addEventListener(this.softNavEventToListen, () => { + this.report() + this.reset() + }) + } + + abstract observe(initialLoad: boolean): void + + report() { + if (!this.entryProcessor.metric || this.entryProcessor.metric.value < 0) return + + this.cb(this.entryProcessor.metric, {url: this.url}) + } + + teardown() { + this.observer?.takeRecords() + this.observer?.disconnect() + } + + reset() { + this.teardown() + this.entryProcessor.teardown() + this.entryProcessor = this.initializeProcessor() + this.observe(false) + } +} diff --git a/web-vitals/base/processor.ts b/web-vitals/base/processor.ts new file mode 100644 index 0000000..7160e42 --- /dev/null +++ b/web-vitals/base/processor.ts @@ -0,0 +1,6 @@ +export abstract class BaseProcessor { + abstract processEntries(entries: EntryType[]): void + abstract get metric(): MetricType | null + + teardown() {} +} diff --git a/web-vitals/cls/layout-shift-processor.ts b/web-vitals/cls/layout-shift-processor.ts new file mode 100644 index 0000000..bd734f5 --- /dev/null +++ b/web-vitals/cls/layout-shift-processor.ts @@ -0,0 +1,65 @@ +import {BaseProcessor} from '../base/processor' +import {getSelector} from '../get-selector' +import {CLSMetric, getLargestLayoutShiftSource} from './metric' + +// From https://github.com/GoogleChrome/web-vitals/blob/1b872cf5f2159e8ace0e98d55d8eb54fb09adfbe/src/lib/LayoutShiftManager.ts#L17 +// with a few modifications to fit our needs. +export class LayoutShiftProcessor extends BaseProcessor { + sessionValue = 0 + sessionEntries: LayoutShift[] = [] + layoutShiftTargetMap: Map = new Map() + + get metric() { + // Pages without entries report CLS = 0 + if (this.sessionEntries.length === 0) { + return new CLSMetric(0, [], new Map()) + } + + return new CLSMetric(this.sessionValue, this.sessionEntries, this.layoutShiftTargetMap) + } + + processEntries(entries: LayoutShift[]) { + for (const entry of entries) { + this.processEntry(entry) + } + } + + processEntry(entry: LayoutShift) { + // Only count layout shifts without recent user input. + if (entry.hadRecentInput) return + + const firstSessionEntry = this.sessionEntries[0] + const lastSessionEntry = this.sessionEntries.at(-1) + + // If the entry occurred less than 1 second after the previous entry + // and less than 5 seconds after the first entry in the session, + // include the entry in the current session. Otherwise, start a new + // session. + if ( + this.sessionValue && + firstSessionEntry && + lastSessionEntry && + entry.startTime - lastSessionEntry.startTime < 1000 && + entry.startTime - firstSessionEntry.startTime < 5000 + ) { + this.sessionValue += entry.value + this.sessionEntries.push(entry) + } else { + this.sessionValue = entry.value + this.sessionEntries = [entry] + } + + this.setLargestShiftSource(entry) + } + + setLargestShiftSource(entry: LayoutShift) { + if (entry?.sources?.length) { + const largestSource = getLargestLayoutShiftSource(entry.sources) + const node = largestSource?.node + if (node) { + const customTarget = getSelector(node) + this.layoutShiftTargetMap.set(largestSource, customTarget) + } + } + } +} diff --git a/web-vitals/cls/metric.ts b/web-vitals/cls/metric.ts new file mode 100644 index 0000000..564e494 --- /dev/null +++ b/web-vitals/cls/metric.ts @@ -0,0 +1,40 @@ +import {BaseMetric} from '../base/metric' + +export interface CLSAttribution { + largestShiftTarget?: string +} + +const getLargestLayoutShiftEntry = (entries: LayoutShift[]) => { + return entries.reduce((a, b) => (a.value > b.value ? a : b)) +} + +export const getLargestLayoutShiftSource = (sources: LayoutShiftAttribution[]) => { + return sources.find(s => s.node?.nodeType === 1) || sources[0] +} + +/* + * The CLS metric. This class is compatible with web-vitals' CLSMetric interface that we expect to report to DataDog and Hydro. + */ +export class CLSMetric extends BaseMetric { + name = 'CLS' as const + targetMap: Map + + constructor(value: number, entries: LayoutShift[], targetMap: Map) { + super(value, entries) + this.targetMap = targetMap + } + + get attribution(): CLSAttribution { + if (!this.entries.length) return {} + + const largestEntry = getLargestLayoutShiftEntry(this.entries) + if (!largestEntry?.sources?.length) return {} + + const largestSource = getLargestLayoutShiftSource(largestEntry.sources) + if (!largestSource) return {} + + return { + largestShiftTarget: this.targetMap.get(largestSource), + } + } +} diff --git a/web-vitals/cls/observer.ts b/web-vitals/cls/observer.ts new file mode 100644 index 0000000..d2b01de --- /dev/null +++ b/web-vitals/cls/observer.ts @@ -0,0 +1,34 @@ +import {ssrSafeWindow} from '@github-ui/ssr-utils' +import type {CLSMetric} from './metric' +import {LayoutShiftProcessor} from './layout-shift-processor' +import {BaseObserver} from '../base/observer' +import {SOFT_NAV_STATE} from '@github-ui/soft-nav/states' + +const supportsCLS = ssrSafeWindow && 'LayoutShift' in ssrSafeWindow + +/* + * The CLSObserver is responsible for listening to Performance events and routing them to the entryProcessor. + * It also manages resetting CLS and reporting it when navigating or hiding a page. + */ +export class CLSObserver extends BaseObserver { + get softNavEventToListen() { + return SOFT_NAV_STATE.START + } + + initializeProcessor() { + return new LayoutShiftProcessor() + } + + override get supported(): boolean { + return !!supportsCLS + } + + observe(initialLoad = true) { + this.url = ssrSafeWindow?.location.href + this.observer = new PerformanceObserver(list => { + this.entryProcessor.processEntries(list.getEntries() as LayoutShift[]) + }) + + this.observer.observe({type: 'layout-shift', buffered: initialLoad}) + } +} diff --git a/web-vitals/dom-nodes.ts b/web-vitals/dom-nodes.ts new file mode 100644 index 0000000..a117608 --- /dev/null +++ b/web-vitals/dom-nodes.ts @@ -0,0 +1,21 @@ +import {isFeatureEnabled} from '@github-ui/feature-flags' +import {SOFT_NAV_STATE} from '@github-ui/soft-nav/states' +import {ssrSafeDocument} from '@github-ui/ssr-utils' + +let previousDomNodeCount: number = 0 + +ssrSafeDocument?.addEventListener(SOFT_NAV_STATE.START, () => { + if (!isFeatureEnabled('dom_node_counts')) return + previousDomNodeCount = countNodes() // nodes may have changes with user interactions / deferred renders +}) + +function countNodes() { + return ssrSafeDocument?.getElementsByTagName('*').length || 0 +} + +export function getDomNodes() { + return { + previous: previousDomNodeCount, + current: countNodes(), + } +} diff --git a/web-vitals/element-timing/metric.ts b/web-vitals/element-timing/metric.ts new file mode 100644 index 0000000..4292dff --- /dev/null +++ b/web-vitals/element-timing/metric.ts @@ -0,0 +1,20 @@ +import {getSelector} from '../get-selector' + +export class ElementTimingMetric { + name = 'ElementTiming' as const + value: number + identifier: string + attribution: { + target?: string + } + + declare app: string + + constructor(value: number, element: Element, identifier: string) { + this.value = value + this.identifier = identifier + this.attribution = { + target: getSelector(element), + } + } +} diff --git a/web-vitals/element-timing/observer.ts b/web-vitals/element-timing/observer.ts new file mode 100644 index 0000000..ca0263b --- /dev/null +++ b/web-vitals/element-timing/observer.ts @@ -0,0 +1,69 @@ +import {SOFT_NAV_STATE} from '@github-ui/soft-nav/states' +import {ssrSafeDocument, ssrSafeWindow} from '@github-ui/ssr-utils' +import {ElementTimingMetric} from './metric' + +const supportsElementTiming = ssrSafeWindow && 'PerformanceElementTiming' in ssrSafeWindow + +type ElementTimingTCallback = (elementTiming: ElementTimingMetric, opts: {url?: string}) => void + +interface PerformanceElementTiming extends PerformanceEntry { + renderTime: number + observer?: PerformanceObserver + element: Element + identifier: string +} +/* + * The ElementTimingObserver is responsible for listening to PerformanceElementTiming events and reporting them. + */ +export class ElementTimingObserver { + cb: ElementTimingTCallback + observer?: PerformanceObserver + url?: string + + constructor(cb: ElementTimingTCallback) { + this.cb = cb + this.setupListeners() + } + + setupListeners() { + if (!supportsElementTiming) return + + // SOFT_NAV_STATE.RENDER is dispatched when the soft navigation finished rendering. + // That means that the previous page is fully hidden so we can stop listening for its events. + ssrSafeDocument?.addEventListener(SOFT_NAV_STATE.RENDER, () => { + this.reset() + }) + } + + observe(initialLoad = true) { + if (!supportsElementTiming) return + + this.observer = new PerformanceObserver(list => { + const entries = list.getEntries() as PerformanceElementTiming[] + for (const {renderTime, element, identifier} of entries) { + this.report(new ElementTimingMetric(renderTime, element, identifier)) + } + }) + + this.observer.observe({ + type: 'element', + // buffered events are important on first page load since we may have missed + // a few until the observer was set up. + buffered: initialLoad, + }) + } + + report(metric: ElementTimingMetric) { + this.cb(metric, {url: this.url}) + } + + teardown() { + this.observer?.takeRecords() + this.observer?.disconnect() + } + + reset() { + this.teardown() + this.observe(false) + } +} diff --git a/web-vitals/get-selector.ts b/web-vitals/get-selector.ts new file mode 100644 index 0000000..2c48dea --- /dev/null +++ b/web-vitals/get-selector.ts @@ -0,0 +1,32 @@ +/* + * From https://github.com/GoogleChrome/web-vitals/blob/7b44bea0d5ba6629c5fd34c3a09cc683077871d0/src/lib/getSelector.ts + * I want to make sure we get element names the same way as web-vitals does. + */ + +const getName = (node: Node) => { + const name = node.nodeName + return node.nodeType === 1 ? name.toLowerCase() : name.toUpperCase().replace(/^#/, '') +} + +export const getSelector = (node: Node | null | undefined, maxLen?: number) => { + let sel = '' + + try { + while (node && node.nodeType !== 9) { + const el: Element = node as Element + const part = el.id + ? `#${el.id}` + : getName(el) + + (el.classList && el.classList.value && el.classList.value.trim() && el.classList.value.trim().length + ? `.${el.classList.value.trim().replace(/\s+/g, '.')}` + : '') + if (sel.length + part.length > (maxLen || 100) - 1) return sel || part + sel = sel ? `${part}>${sel}` : part + if (el.id) break + node = el.parentNode + } + } catch { + // Do nothing... + } + return sel +} diff --git a/web-vitals/hpc-events.ts b/web-vitals/hpc-events.ts new file mode 100644 index 0000000..a67473a --- /dev/null +++ b/web-vitals/hpc-events.ts @@ -0,0 +1,84 @@ +import type {SoftNavMechanism} from '@github-ui/soft-nav/events' +import {getSelector} from './get-selector' + +export interface HPCEventTarget extends EventTarget { + addEventListener( + type: 'hpc:timing', + listener: (event: HPCTimingEvent) => void, + options?: boolean | AddEventListenerOptions, + ): void + addEventListener( + type: 'hpc:dom-insertion', + listener: (event: HPCDomInsertionEvent) => void, + options?: boolean | AddEventListenerOptions, + ): void + addEventListener(type: string, listener: (event: Event) => void, options?: boolean | AddEventListenerOptions): void + + removeEventListener( + type: 'hpc:timing', + listener: (event: HPCTimingEvent) => void, + options?: boolean | AddEventListenerOptions, + ): void + removeEventListener( + type: 'hpc:dom-insertion', + listener: (event: HPCDomInsertionEvent) => void, + options?: boolean | AddEventListenerOptions, + ): void + removeEventListener(type: string, listener: (event: Event) => void, options?: boolean | AddEventListenerOptions): void +} + +export class HPCTimingEvent extends Event { + name = 'HPC' as const + value: number + attribution: { + element?: string + } + + soft: boolean + ssr: boolean + lazy: boolean + alternate: boolean + mechanism: SoftNavMechanism | 'hard' + found: boolean + gqlFetched: boolean + jsFetched: boolean + app: string + + constructor( + soft: boolean, + ssr: boolean, + lazy: boolean, + alternate: boolean, + mechanism: SoftNavMechanism | 'hard', + found: boolean, + gqlFetched: boolean, + jsFetched: boolean, + app: string, + start: number, + element: Element | null, + ) { + super('hpc:timing') + this.soft = soft + this.ssr = ssr + this.lazy = lazy + this.alternate = alternate + this.mechanism = mechanism + this.found = found + this.gqlFetched = gqlFetched + this.jsFetched = jsFetched + this.app = app + + this.value = performance.now() - start + this.attribution = { + element: getSelector(element), + } + } +} + +export class HPCDomInsertionEvent extends Event { + element: Element | null + constructor(element: Element | null) { + super('hpc:dom-insertion') + this.element = element + } +} diff --git a/web-vitals/hpc-events1.ts b/web-vitals/hpc-events1.ts new file mode 100644 index 0000000..a67473a --- /dev/null +++ b/web-vitals/hpc-events1.ts @@ -0,0 +1,84 @@ +import type {SoftNavMechanism} from '@github-ui/soft-nav/events' +import {getSelector} from './get-selector' + +export interface HPCEventTarget extends EventTarget { + addEventListener( + type: 'hpc:timing', + listener: (event: HPCTimingEvent) => void, + options?: boolean | AddEventListenerOptions, + ): void + addEventListener( + type: 'hpc:dom-insertion', + listener: (event: HPCDomInsertionEvent) => void, + options?: boolean | AddEventListenerOptions, + ): void + addEventListener(type: string, listener: (event: Event) => void, options?: boolean | AddEventListenerOptions): void + + removeEventListener( + type: 'hpc:timing', + listener: (event: HPCTimingEvent) => void, + options?: boolean | AddEventListenerOptions, + ): void + removeEventListener( + type: 'hpc:dom-insertion', + listener: (event: HPCDomInsertionEvent) => void, + options?: boolean | AddEventListenerOptions, + ): void + removeEventListener(type: string, listener: (event: Event) => void, options?: boolean | AddEventListenerOptions): void +} + +export class HPCTimingEvent extends Event { + name = 'HPC' as const + value: number + attribution: { + element?: string + } + + soft: boolean + ssr: boolean + lazy: boolean + alternate: boolean + mechanism: SoftNavMechanism | 'hard' + found: boolean + gqlFetched: boolean + jsFetched: boolean + app: string + + constructor( + soft: boolean, + ssr: boolean, + lazy: boolean, + alternate: boolean, + mechanism: SoftNavMechanism | 'hard', + found: boolean, + gqlFetched: boolean, + jsFetched: boolean, + app: string, + start: number, + element: Element | null, + ) { + super('hpc:timing') + this.soft = soft + this.ssr = ssr + this.lazy = lazy + this.alternate = alternate + this.mechanism = mechanism + this.found = found + this.gqlFetched = gqlFetched + this.jsFetched = jsFetched + this.app = app + + this.value = performance.now() - start + this.attribution = { + element: getSelector(element), + } + } +} + +export class HPCDomInsertionEvent extends Event { + element: Element | null + constructor(element: Element | null) { + super('hpc:dom-insertion') + this.element = element + } +} diff --git a/web-vitals/hpc.ts b/web-vitals/hpc.ts new file mode 100644 index 0000000..4af0361 --- /dev/null +++ b/web-vitals/hpc.ts @@ -0,0 +1,293 @@ +import {wasServerRendered} from '@github-ui/ssr-utils' +import {onLCP} from 'web-vitals/attribution' +import {hasFetchedGQL, hasFetchedJS, isReactAlternate, isReactLazyPayload} from './web-vitals' +import type {SoftNavMechanism} from '@github-ui/soft-nav/events' +import {HPCDomInsertionEvent, HPCTimingEvent, type HPCEventTarget} from './hpc-events' +import {hasSoftNavFailure} from '@github-ui/soft-nav/utils' +import {SOFT_NAV_STATE} from '@github-ui/soft-nav/states' +import {getCurrentReactAppName} from '@github-ui/stats-metadata' + +const INSERTION_TIMEOUT = 10000 +const ELEMENTS_TO_IGNORE = ['meta', 'script', 'link'] + +function getAppName() { + return getCurrentReactAppName() || 'rails' +} + +function isVisible(element: HTMLElement) { + // Safari doesn't support `checkVisibility` yet. + if (typeof element.checkVisibility === 'function') return element.checkVisibility() + + return Boolean(element.offsetParent || element.offsetWidth || element.offsetHeight) +} + +type CallbackFunction = (metric: HPCTimingEvent) => void + +interface HPCObserverAttributes { + soft: boolean + mechanism: SoftNavMechanism | 'hard' + latestHPCElement: Element | null + callback: CallbackFunction +} + +export class HPCObserver { + abortController = new AbortController() + tabHidden = false + insertionFound = false + hpcElement: Element | null = null + + soft: boolean + mechanism: SoftNavMechanism | 'hard' + latestHPCElement: Element | null + hpcStart: DOMHighResTimeStamp + hpcTarget: HPCEventTarget = new EventTarget() as HPCEventTarget + animationFrame?: number + dataHPCanimationFrame?: number + emulatedHPCTimer?: ReturnType + listenerOpts: AddEventListenerOptions + hpcDOMInsertionObserver: MutationObserver | null = null + callback: CallbackFunction + + constructor({soft, mechanism, latestHPCElement, callback}: HPCObserverAttributes) { + this.soft = soft + this.mechanism = mechanism + + if (hasSoftNavFailure()) { + this.mechanism = 'turbo.error' + } + + this.latestHPCElement = latestHPCElement + this.hpcStart = soft ? performance.now() : 0 + this.listenerOpts = {capture: true, passive: true, once: true, signal: this.abortController.signal} + this.callback = callback + } + + connect() { + if (!this.soft) { + // In a hard-load, if the script is evaluated after the `data-hpc` element is rendered, + // we default the HPC value to LCP. + const hpcElement = document.querySelector('[data-hpc]') + if (hpcElement) { + this.hpcElement = hpcElement + this.setLCPasHPC(this.soft, true, this.callback) + return + } + + // if the element is not in the page yet, listen for mutations. + setTimeout(() => { + // if no mutations happen after INSERTION_TIMEOUT, default to LCP again + if (!this.insertionFound) this.setLCPasHPC(this.soft, false, this.callback) + }, INSERTION_TIMEOUT) + } + + this.#setupListeners() + this.hpcDOMInsertionObserver = this.#buildMutationObserver() + this.hpcDOMInsertionObserver.observe(document, {childList: true, subtree: true}) + } + + disconnect() { + this.#cleanupListeners() + this.hpcDOMInsertionObserver?.disconnect() + } + + // Observer to listen to ALL mutations to the DOM. We need to check all added nodes + // for the `data-hpc` attribue. If none are found, we keep listening until all mutations are done. + #buildMutationObserver() { + return new MutationObserver(mutations => { + let hasDataHPC = false + let visibleElement = false + let hpcElement: Element | null = null + let insertionElement: Element | null = null + + const validMutations = mutations.filter( + mutation => mutation.type === 'childList' && mutation.addedNodes.length > 0, + ) + + // if the mutation didn't add any nodes, we don't track its HPC + if (validMutations.length === 0) return + + const addedNodes = validMutations + .flatMap(mutation => Array.from(mutation.addedNodes)) + .filter(node => node instanceof Element && !ELEMENTS_TO_IGNORE.includes(node.tagName.toLowerCase())) + + if (addedNodes.length === 0) return + + for (const node of addedNodes) { + const el = node as Element + hpcElement = el.hasAttribute('data-hpc') ? el : el.querySelector('[data-hpc]') + if (hpcElement) { + this.hpcElement = hpcElement + if (this.animationFrame) cancelAnimationFrame(this.animationFrame) + hasDataHPC = true + break + } + } + + if (hasDataHPC && hpcElement) { + this.#reportHPC(hpcElement) + return + } + + for (const node of addedNodes) { + const el = node as HTMLElement + // we only care about visible elements + if (isVisible(el)) { + insertionElement = el + if (this.animationFrame) cancelAnimationFrame(this.animationFrame) + visibleElement = true + break + } + } + + if (visibleElement) { + const insertionEvent = new HPCDomInsertionEvent(insertionElement) + this.animationFrame = requestAnimationFrame(() => { + this.hpcTarget.dispatchEvent(insertionEvent) + }) + } + }) + } + + #reportHPC(element: Element) { + window.performance.measure('HPC', 'navigationStart') + // data-hpc found, we can stop listening to mutations. + this.hpcDOMInsertionObserver?.disconnect() + // only cancel the animation frame if the controller aborts. + const timingEvent = new HPCTimingEvent( + this.soft, + wasServerRendered(), + isReactLazyPayload(), + isReactAlternate(), + this.mechanism, + true, + hasFetchedGQL(), + hasFetchedJS(), + getAppName(), + this.hpcStart, + element, + ) + + this.dataHPCanimationFrame = requestAnimationFrame(() => { + this.hpcTarget.dispatchEvent(timingEvent) + }) + } + + #cleanupListeners() { + document.removeEventListener('touchstart', this.stop, this.listenerOpts) + document.removeEventListener('mousedown', this.stop, this.listenerOpts) + document.removeEventListener('keydown', this.stop, this.listenerOpts) + document.removeEventListener('pointerdown', this.stop, this.listenerOpts) + document.removeEventListener('visibilitychange', this.onVisibilityChange) + document.removeEventListener(SOFT_NAV_STATE.RENDER, this.onSoftNavRender) + + this.hpcTarget.removeEventListener('hpc:dom-insertion', this.onDOMInsertion) + this.hpcTarget.removeEventListener('hpc:timing', this.onHPCTiming) + + this.abortController.signal.removeEventListener('abort', this.onAbort) + } + + #setupListeners() { + // Stop listening for HPC events if the user has interacted, as interactions + // can cause DOM mutations, which we want to avoid capturing for HPC. + // eslint-disable-next-line github/require-passive-events + document.addEventListener('touchstart', this.stop, this.listenerOpts) + document.addEventListener('mousedown', this.stop, this.listenerOpts) + document.addEventListener('keydown', this.stop, this.listenerOpts) + document.addEventListener('pointerdown', this.stop, this.listenerOpts) + + // Process HPC events + this.hpcTarget.addEventListener('hpc:dom-insertion', this.onDOMInsertion, { + signal: this.abortController.signal, + }) + this.hpcTarget.addEventListener('hpc:timing', this.onHPCTiming, {signal: this.abortController.signal}) + document.addEventListener(SOFT_NAV_STATE.RENDER, this.onSoftNavRender) + + // If the user changes tab, we don't want to send the recorded metrics since it may send garbage data. + document.addEventListener('visibilitychange', this.onVisibilityChange, { + signal: this.abortController.signal, + }) + + // If the stop event is triggered, we want to stop listening to DOM mutations. + this.abortController.signal.addEventListener('abort', this.onAbort) + } + + stop = () => { + this.abortController.abort() + } + + onDOMInsertion = (e: HPCDomInsertionEvent) => { + this.insertionFound = true + clearTimeout(this.emulatedHPCTimer) + // Whenever we see a DOM insertion, we keep track of when it happened. + const event = new HPCTimingEvent( + this.soft, + wasServerRendered(), + isReactLazyPayload(), + isReactAlternate(), + this.mechanism, + false, + hasFetchedGQL(), + hasFetchedJS(), + getAppName(), + this.hpcStart, + e.element, + ) + + // If no mutations happen after the timeout, we assume that the DOM is fully loaded, so we send the + // last seen mutation values. + this.emulatedHPCTimer = setTimeout(() => this.hpcTarget.dispatchEvent(event), INSERTION_TIMEOUT) + } + + onHPCTiming = (e: HPCTimingEvent) => { + if (!this.tabHidden && e.value < 60_000) this.callback(e) + + this.abortController.abort() + } + + onVisibilityChange = () => { + this.tabHidden = true + this.abortController.abort() + } + + onSoftNavRender = () => { + const currentHPCElement = document.querySelector('[data-hpc]') + this.hpcElement = currentHPCElement + + // In case the soft navigation doesn't change the root data-hpc element, the MutationObserver + // won't catch it, so we use the soft navigation timing as HPC. + if (!currentHPCElement || currentHPCElement !== this.latestHPCElement) return + + this.#reportHPC(currentHPCElement) + } + + onAbort = () => { + if (this.dataHPCanimationFrame) cancelAnimationFrame(this.dataHPCanimationFrame) + if (this.animationFrame) cancelAnimationFrame(this.animationFrame) + clearTimeout(this.emulatedHPCTimer) + this.disconnect() + } + + setLCPasHPC(soft: boolean, found: boolean, cb: CallbackFunction) { + const mechanism = this.mechanism === 'turbo.error' ? this.mechanism : 'hard' + + onLCP(({value, attribution}) => { + window.performance.measure('HPC', {start: 'navigationStart', end: value}) + cb({ + name: 'HPC', + value, + soft, + found, + gqlFetched: hasFetchedGQL(), + jsFetched: hasFetchedJS(), + ssr: wasServerRendered(), + lazy: isReactLazyPayload(), + alternate: isReactAlternate(), + mechanism, + app: getAppName(), + attribution: { + element: attribution?.target, + }, + } as HPCTimingEvent) + }) + } +} diff --git a/web-vitals/hydro-stats.ts b/web-vitals/hydro-stats.ts new file mode 100644 index 0000000..cf9b429 --- /dev/null +++ b/web-vitals/hydro-stats.ts @@ -0,0 +1,198 @@ +import {sendEvent, stringifyObjectValues} from '@github-ui/hydro-analytics' +import {getEnabledFeatures, isFeatureEnabled} from '@github-ui/feature-flags' +import {loaded} from '@github-ui/document-ready' +import type {WebVitalMetric, MetricOrHPC} from './web-vitals' +import type {HPCTimingEvent} from './hpc-events' +import {getCPUBucket} from '@github-ui/cpu-bucket' +import type {INPAttribution} from './inp/metric' + +interface WebVitalInformation { + name: string + value: number + element?: string + events?: string + interactionType?: string + eventType?: string + inputDelay?: number + processingDuration?: number + presentationDelay?: number +} + +interface HPCInformation extends WebVitalInformation { + mechanism: HPCTimingEvent['mechanism'] + soft: boolean +} + +interface HydroStat { + react?: boolean + reactApp?: string | null + reactPartials?: string[] + featureFlags?: string[] + ssr?: boolean + hpc?: HPCInformation + ttfb?: WebVitalInformation + fcp?: WebVitalInformation + lcp?: WebVitalInformation + fid?: WebVitalInformation + inp?: WebVitalInformation + cls?: WebVitalInformation + elementtiming?: WebVitalInformation + longTasks?: PerformanceEntryList + longAnimationFrames?: PerformanceEntryList + controller?: string + action?: string + routePattern?: string + cpu?: string + domNodes?: number + previousDomNodes?: number +} + +let queued: HydroStat | undefined + +/** + * Batched report of vital to hydro + */ +export function sendToHydro({ + metric, + ssr, + domNodes, + previousDomNodes, + longTasks, + longAnimationFrames, +}: { + metric?: MetricOrHPC + ssr: boolean + domNodes?: number + previousDomNodes?: number + longTasks?: PerformanceEntryList + longAnimationFrames?: PerformanceEntryList +}) { + let hydroStat: HydroStat | undefined + if (isFeatureEnabled('report_hydro_web_vitals')) return + + if (!hydroStat) { + const reactApp = document.querySelector('react-app') + hydroStat = queueStat() + hydroStat.react = !!reactApp + hydroStat.reactApp = reactApp?.getAttribute('app-name') + // Convert to Set and back to Array to remove duplicates. + hydroStat.reactPartials = [ + ...new Set( + Array.from(document.querySelectorAll('react-partial')).map( + partial => partial.getAttribute('partial-name') || '', + ), + ), + ] + hydroStat.featureFlags = getFeatureFlags() + hydroStat.ssr = ssr + hydroStat.controller = document.querySelector('meta[name="route-controller"]')?.content + hydroStat.action = document.querySelector('meta[name="route-action"]')?.content + hydroStat.routePattern = document.querySelector('meta[name="route-pattern"]')?.content + hydroStat.cpu = getCPUBucket() + + if (domNodes) hydroStat.domNodes = domNodes + if (previousDomNodes) hydroStat.previousDomNodes = previousDomNodes + } + + if (metric) { + return sendWebVital(hydroStat, metric) + } + + hydroStat.longTasks = longTasks + hydroStat.longAnimationFrames = longAnimationFrames +} + +interface ReactApp extends Element { + enabledFeatures: string[] +} + +function getFeatureFlags() { + const globalFlags = getEnabledFeatures() + const reactAppFlags = document.querySelector('react-app')?.enabledFeatures || [] + // need to many to check for speculation_rules otherwise it will get minified away + const speculationRulesFlag = isFeatureEnabled('speculation_rules') ? ['speculation_rules'] : [] + + return Array.from(new Set([...globalFlags, ...reactAppFlags, ...speculationRulesFlag])) +} + +function sendWebVital(hydroStat: HydroStat, metric: MetricOrHPC) { + if (metric.value < 60_000) { + if (metric.name === 'HPC') { + hydroStat[metric.name.toLocaleLowerCase() as Lowercase] = buildHPCInformation(metric) + } else { + hydroStat[metric.name.toLocaleLowerCase() as Lowercase] = buildWebVitalInformation(metric) + } + } +} + +function buildHPCInformation(metric: HPCTimingEvent): HPCInformation { + return { + name: metric.name, + value: metric.value, + element: metric.attribution?.element, + soft: !!metric.soft, + mechanism: metric.mechanism, + } +} + +function buildWebVitalInformation(metric: WebVitalMetric): WebVitalInformation { + const vitalInformation: WebVitalInformation = { + name: metric.name, + value: metric.value, + } + + switch (metric.name) { + case 'LCP': + case 'ElementTiming': + vitalInformation.element = metric.attribution?.target + break + case 'INP': + vitalInformation.element = metric.attribution?.interactionTarget + // Only include custom fields if they exist (from our custom INPMetric class) + if (metric.attribution && 'interactionType' in metric.attribution) { + const customAttribution = metric.attribution as INPAttribution + vitalInformation.interactionType = customAttribution.interactionType + vitalInformation.eventType = customAttribution.eventType + vitalInformation.inputDelay = customAttribution.inputDelay + vitalInformation.processingDuration = customAttribution.processingDuration + vitalInformation.presentationDelay = customAttribution.presentationDelay + } + if (metric.entries?.length) vitalInformation.events = metric.entries.map(entry => entry.name).join(',') + break + case 'CLS': + vitalInformation.element = metric.attribution?.largestShiftTarget + break + } + + return vitalInformation +} + +/** + * Create a new stat object and schedule it to be sent to hydro + */ +function queueStat(): HydroStat { + if (!queued) { + queued = {} + scheduleSend() + } + return queued +} + +/** + * Schedule a send to hydro + */ +async function scheduleSend() { + await loaded + // eslint-disable-next-line compat/compat + window.requestIdleCallback(send) +} + +/** + * Send the queued event to hydro + */ +function send() { + if (!queued) return + + sendEvent('web-vital', stringifyObjectValues(queued)) + queued = undefined +} diff --git a/web-vitals/hydro-stats1.ts b/web-vitals/hydro-stats1.ts new file mode 100644 index 0000000..cf9b429 --- /dev/null +++ b/web-vitals/hydro-stats1.ts @@ -0,0 +1,198 @@ +import {sendEvent, stringifyObjectValues} from '@github-ui/hydro-analytics' +import {getEnabledFeatures, isFeatureEnabled} from '@github-ui/feature-flags' +import {loaded} from '@github-ui/document-ready' +import type {WebVitalMetric, MetricOrHPC} from './web-vitals' +import type {HPCTimingEvent} from './hpc-events' +import {getCPUBucket} from '@github-ui/cpu-bucket' +import type {INPAttribution} from './inp/metric' + +interface WebVitalInformation { + name: string + value: number + element?: string + events?: string + interactionType?: string + eventType?: string + inputDelay?: number + processingDuration?: number + presentationDelay?: number +} + +interface HPCInformation extends WebVitalInformation { + mechanism: HPCTimingEvent['mechanism'] + soft: boolean +} + +interface HydroStat { + react?: boolean + reactApp?: string | null + reactPartials?: string[] + featureFlags?: string[] + ssr?: boolean + hpc?: HPCInformation + ttfb?: WebVitalInformation + fcp?: WebVitalInformation + lcp?: WebVitalInformation + fid?: WebVitalInformation + inp?: WebVitalInformation + cls?: WebVitalInformation + elementtiming?: WebVitalInformation + longTasks?: PerformanceEntryList + longAnimationFrames?: PerformanceEntryList + controller?: string + action?: string + routePattern?: string + cpu?: string + domNodes?: number + previousDomNodes?: number +} + +let queued: HydroStat | undefined + +/** + * Batched report of vital to hydro + */ +export function sendToHydro({ + metric, + ssr, + domNodes, + previousDomNodes, + longTasks, + longAnimationFrames, +}: { + metric?: MetricOrHPC + ssr: boolean + domNodes?: number + previousDomNodes?: number + longTasks?: PerformanceEntryList + longAnimationFrames?: PerformanceEntryList +}) { + let hydroStat: HydroStat | undefined + if (isFeatureEnabled('report_hydro_web_vitals')) return + + if (!hydroStat) { + const reactApp = document.querySelector('react-app') + hydroStat = queueStat() + hydroStat.react = !!reactApp + hydroStat.reactApp = reactApp?.getAttribute('app-name') + // Convert to Set and back to Array to remove duplicates. + hydroStat.reactPartials = [ + ...new Set( + Array.from(document.querySelectorAll('react-partial')).map( + partial => partial.getAttribute('partial-name') || '', + ), + ), + ] + hydroStat.featureFlags = getFeatureFlags() + hydroStat.ssr = ssr + hydroStat.controller = document.querySelector('meta[name="route-controller"]')?.content + hydroStat.action = document.querySelector('meta[name="route-action"]')?.content + hydroStat.routePattern = document.querySelector('meta[name="route-pattern"]')?.content + hydroStat.cpu = getCPUBucket() + + if (domNodes) hydroStat.domNodes = domNodes + if (previousDomNodes) hydroStat.previousDomNodes = previousDomNodes + } + + if (metric) { + return sendWebVital(hydroStat, metric) + } + + hydroStat.longTasks = longTasks + hydroStat.longAnimationFrames = longAnimationFrames +} + +interface ReactApp extends Element { + enabledFeatures: string[] +} + +function getFeatureFlags() { + const globalFlags = getEnabledFeatures() + const reactAppFlags = document.querySelector('react-app')?.enabledFeatures || [] + // need to many to check for speculation_rules otherwise it will get minified away + const speculationRulesFlag = isFeatureEnabled('speculation_rules') ? ['speculation_rules'] : [] + + return Array.from(new Set([...globalFlags, ...reactAppFlags, ...speculationRulesFlag])) +} + +function sendWebVital(hydroStat: HydroStat, metric: MetricOrHPC) { + if (metric.value < 60_000) { + if (metric.name === 'HPC') { + hydroStat[metric.name.toLocaleLowerCase() as Lowercase] = buildHPCInformation(metric) + } else { + hydroStat[metric.name.toLocaleLowerCase() as Lowercase] = buildWebVitalInformation(metric) + } + } +} + +function buildHPCInformation(metric: HPCTimingEvent): HPCInformation { + return { + name: metric.name, + value: metric.value, + element: metric.attribution?.element, + soft: !!metric.soft, + mechanism: metric.mechanism, + } +} + +function buildWebVitalInformation(metric: WebVitalMetric): WebVitalInformation { + const vitalInformation: WebVitalInformation = { + name: metric.name, + value: metric.value, + } + + switch (metric.name) { + case 'LCP': + case 'ElementTiming': + vitalInformation.element = metric.attribution?.target + break + case 'INP': + vitalInformation.element = metric.attribution?.interactionTarget + // Only include custom fields if they exist (from our custom INPMetric class) + if (metric.attribution && 'interactionType' in metric.attribution) { + const customAttribution = metric.attribution as INPAttribution + vitalInformation.interactionType = customAttribution.interactionType + vitalInformation.eventType = customAttribution.eventType + vitalInformation.inputDelay = customAttribution.inputDelay + vitalInformation.processingDuration = customAttribution.processingDuration + vitalInformation.presentationDelay = customAttribution.presentationDelay + } + if (metric.entries?.length) vitalInformation.events = metric.entries.map(entry => entry.name).join(',') + break + case 'CLS': + vitalInformation.element = metric.attribution?.largestShiftTarget + break + } + + return vitalInformation +} + +/** + * Create a new stat object and schedule it to be sent to hydro + */ +function queueStat(): HydroStat { + if (!queued) { + queued = {} + scheduleSend() + } + return queued +} + +/** + * Schedule a send to hydro + */ +async function scheduleSend() { + await loaded + // eslint-disable-next-line compat/compat + window.requestIdleCallback(send) +} + +/** + * Send the queued event to hydro + */ +function send() { + if (!queued) return + + sendEvent('web-vital', stringifyObjectValues(queued)) + queued = undefined +} diff --git a/web-vitals/inp/interaction-count.ts b/web-vitals/inp/interaction-count.ts new file mode 100644 index 0000000..f3b4117 --- /dev/null +++ b/web-vitals/inp/interaction-count.ts @@ -0,0 +1,63 @@ +// Based on https://github.com/GoogleChrome/web-vitals/blob/7b44bea0d5ba6629c5fd34c3a09cc683077871d0/src/lib/polyfills/interactionCountPolyfill.ts +declare global { + interface Performance { + interactionCount: number + } +} + +/* + * The InteractionCountObserver tracks the number of interactions that have occurred on the page. This + * is used to estimate INP's p98 value. + */ +export class InteractionCountObserver { + interactionCountEstimate = 0 + minKnownInteractionId = Infinity + maxKnownInteractionId = 0 + observer?: PerformanceObserver + + get interactionCount() { + return this.observer ? this.interactionCountEstimate : performance.interactionCount || 0 + } + + teardown() { + if (this.observer) { + // take the records so a new observer will start empty + this.observer.takeRecords() + this.observer.disconnect() + this.observer = undefined + } + } + + // from https://github.com/GoogleChrome/web-vitals/blob/7b44bea0d5ba6629c5fd34c3a09cc683077871d0/src/lib/polyfills/interactionCountPolyfill.ts#L29-L40 + updateEstimate = (entries: PerformanceEventTiming[]) => { + for (const e of entries) { + if (e.interactionId) { + this.minKnownInteractionId = Math.min(this.minKnownInteractionId, e.interactionId) + this.maxKnownInteractionId = Math.max(this.maxKnownInteractionId, e.interactionId) + + this.interactionCountEstimate = this.maxKnownInteractionId + ? (this.maxKnownInteractionId - this.minKnownInteractionId) / 7 + 1 + : 0 + } + } + } + + observe() { + if ('interactionCount' in performance || this.observer) return + + this.observer = new PerformanceObserver(async list => { + // Delay by a microtask to workaround a bug in Safari where the + // callback is invoked immediately, rather than in a separate task. + // See: https://github.com/GoogleChrome/web-vitals/issues/277 + await Promise.resolve() + + this.updateEstimate(list.getEntries() as PerformanceEventTiming[]) + }) + + this.observer.observe({ + type: 'event', + buffered: true, + durationThreshold: 0, + }) + } +} diff --git a/web-vitals/inp/interaction-list.ts b/web-vitals/inp/interaction-list.ts new file mode 100644 index 0000000..f8e8086 --- /dev/null +++ b/web-vitals/inp/interaction-list.ts @@ -0,0 +1,77 @@ +export interface Interaction { + id: string + latency: number + entries: PerformanceEventTiming[] +} + +/* + * The InteractionList is a list of interactions that are sorted by latency DESCENDING. + * The list has a maximum size and will remove the shortest interaction if the list is full. + */ +export class InteractionList { + interactions: Interaction[] = [] + interactionsMap: Map = new Map() + maxSize: number + + constructor(size: number) { + this.maxSize = size + } + + get shortestInteraction() { + return this.interactions[this.interactions.length - 1] + } + + get(id: string) { + return this.interactionsMap.get(id) + } + + update(interaction: Interaction, entry: PerformanceEventTiming) { + const newLatency = Math.max(interaction.latency, entry.duration) + interaction.entries.push(entry) + + if (newLatency !== interaction.latency) { + interaction.latency = Math.max(interaction.latency, entry.duration) + // the new value may change sorting order so we need to sort the list + this.sort() + } + } + + add(interaction: Interaction) { + const shortestInteraction = this.shortestInteraction + + // Only add interaction if list is not full or if the interaction is longer than the shortest one + if ( + this.interactions.length <= this.maxSize || + !shortestInteraction || + interaction.latency > shortestInteraction.latency + ) { + this.interactionsMap.set(interaction.id, interaction) + this.interactions.push(interaction) + this.sort() + + // Remove the shortest interaction if list reached the limit + if (this.interactions.length > this.maxSize) { + this.interactions.pop() + } + } + } + + sort() { + this.interactions.sort((a, b) => b.latency - a.latency) + } + + findEntry(entry: PerformanceEventTiming) { + return this.interactions.some((interaction: Interaction) => { + return interaction.entries.some(prevEntry => { + return entry.duration === prevEntry.duration && entry.startTime === prevEntry.startTime + }) + }) + } + + // from https://github.com/GoogleChrome/web-vitals/blob/7b44bea0d5ba6629c5fd34c3a09cc683077871d0/src/onINP.ts#L112-L123 + estimateP98(numOfPageInteractions: number) { + const candidateInteractionIndex = Math.min(this.interactions.length - 1, Math.floor(numOfPageInteractions / 50)) + + return this.interactions[candidateInteractionIndex] + } +} diff --git a/web-vitals/inp/interaction-processor.ts b/web-vitals/inp/interaction-processor.ts new file mode 100644 index 0000000..9112dd0 --- /dev/null +++ b/web-vitals/inp/interaction-processor.ts @@ -0,0 +1,124 @@ +import {BaseProcessor} from '../base/processor' +import {InteractionCountObserver} from './interaction-count' +import {InteractionList, type Interaction} from './interaction-list' +import {INPMetric} from './metric' + +export interface CallbackRegistration { + event: Event + cb: CallbackFn +} +export type CallbackFn = (data: Interaction) => void + +const MAX_INTERACTION_ENTRIES = 10 + +// Event to PerformanceEventTiming type mapping +const EVENT_TO_PERF_MAP: Record = { + // Mouse events โ†’ Pointer events + mousedown: 'pointerdown', + mouseup: 'pointerup', + mousemove: 'pointermove', + mouseenter: 'pointerenter', + mouseleave: 'pointerleave', + mouseover: 'pointerover', + mouseout: 'pointerout', + // Touch events โ†’ Pointer events + touchstart: 'pointerdown', + touchend: 'pointerup', + touchmove: 'pointermove', + touchcancel: 'pointercancel', +} + +function normalizeEventType(eventType: string): string { + return EVENT_TO_PERF_MAP[eventType] ?? eventType +} + +function eventMatches(entry: PerformanceEventTiming, event: Event) { + // performance entries don't have a reference to the original event, + // so we match them based on their type and target. Since the target + // may not be present in the entry anymore, we match timestamps to within 1ms + const normalizedEventType = normalizeEventType(event.type) + return ( + (entry.name === normalizedEventType || entry.name === event.type) && + (entry.target === event.target || Math.abs(entry.startTime - event.timeStamp) < 1) + ) +} +/* + * The InteractionProcessor is responsible for processing PerformanceEventTiming entries and keeping track of the current INP. + */ +export class InteractionProcessor extends BaseProcessor { + interactions: InteractionList = new InteractionList(MAX_INTERACTION_ENTRIES) + interactionCountObserver: InteractionCountObserver + registeredCallbacks: Set = new Set() + + constructor() { + super() + this.interactionCountObserver = new InteractionCountObserver() + this.interactionCountObserver.observe() + } + + get metric() { + const interaction = this.interactions.estimateP98(this.interactionCountObserver.interactionCount) + + // Pages without interactions report INP = 0 + if (!interaction) { + return null + } + + return new INPMetric(interaction.latency, interaction.entries) + } + + override teardown() { + this.registeredCallbacks.clear() + this.interactionCountObserver.teardown() + } + + processEntries(entries: PerformanceEventTiming[]) { + const callbackMap = new Map() + + for (const entry of entries) { + // This is a `event` type entry + if (entry.interactionId) { + for (const callback of this.registeredCallbacks) { + if (eventMatches(entry, callback.event)) { + callbackMap.set(String(entry.interactionId), callback.cb) + // avoid checking this callback again since we already found a match + this.registeredCallbacks.delete(callback) + } + } + + this.processEntry(entry) + continue + } + + // see https://github.com/GoogleChrome/web-vitals/blob/7b44bea0d5ba6629c5fd34c3a09cc683077871d0/src/onINP.ts#L169-L189 + if (entry.entryType === 'first-input') { + if (!this.interactions.findEntry(entry)) { + this.processEntry(entry) + } + } + } + + for (const [interactionId, fn] of callbackMap) { + const interaction = this.interactions.get(interactionId) + if (interaction) fn(interaction) + } + } + + processEntry(entry: PerformanceEventTiming) { + const existingInteraction = this.interactions.get(String(entry.interactionId)) + + // multiple events may be fired for the same interaction, so we'll only keep + // the longest duration. + if (existingInteraction) { + return this.interactions.update(existingInteraction, entry) + } + + const interaction: Interaction = { + id: String(entry.interactionId), + latency: entry.duration, + entries: [entry], + } + + this.interactions.add(interaction) + } +} diff --git a/web-vitals/inp/metric.ts b/web-vitals/inp/metric.ts new file mode 100644 index 0000000..e387c2c --- /dev/null +++ b/web-vitals/inp/metric.ts @@ -0,0 +1,187 @@ +import {BaseMetric} from '../base/metric' +import {getSelector} from '../get-selector' + +export type InteractionType = 'text_input' | 'action_click' | 'disclosure' | 'selection' | 'submit' | 'unknown' + +export interface INPAttribution { + interactionTarget?: string + interactionType?: InteractionType + eventType?: string + // Phase timing fields + inputDelay?: number + processingDuration?: number + presentationDelay?: number +} + +const INPUT_EVENTS = new Set(['input', 'keydown', 'keyup', 'keypress']) +const POINTER_EVENTS = new Set(['click', 'pointerdown', 'pointerup', 'mousedown', 'mouseup']) + +interface EntryFlags { + isDisclosure: boolean + isSubmitButton: boolean + isTextInput: boolean + isSelection: boolean + isPointerEventEventType: boolean + isInputEventType: boolean +} + +// WeakMap to store precomputed flags per entry +const entryFlagsMap = new WeakMap() + +function isPointerEvent(eventType: string) { + return POINTER_EVENTS.has(eventType) +} + +/** + * Check if element is a selection element using native DOM checks for performance. + * Avoids CSS selector matching. + */ +function isSelectionElement(el: Element): boolean { + if (el.tagName === 'SELECT') return true + const role = el.getAttribute('role') + return role === 'listbox' || role === 'combobox' +} + +/** + * Lazily compute and cache DOM-derived flags for an entry. + * Ensures each DOM traversal happens only once per entry. + * Performance optimizations: + * - Uses native closest() for DOM traversal for better performance + * - Lazy computation of isSelection only when needed + * - Native DOM property checks for better performance + */ +function getEntryFlags(entry: PerformanceEventTiming, target: Element | null): EntryFlags { + const cached = entryFlagsMap.get(entry) + if (cached) return cached + + const isPointerEventEventType = isPointerEvent(entry.name) + const isInputEventType = INPUT_EVENTS.has(entry.name) + + // Early return for unknown events on non-select, non-input, non-pointer + // Only compute isSelection if needed (after checking event type) + if (!isPointerEventEventType && !isInputEventType) { + // Lazy computation: only check selection elements when we might need it + const isSelection = !!(target && isSelectionElement(target)) + if (!isSelection) { + const flags: EntryFlags = { + isDisclosure: false, + isSubmitButton: false, + isTextInput: false, + isSelection: false, + isPointerEventEventType, + isInputEventType, + } + entryFlagsMap.set(entry, flags) + return flags + } + } + + let disclosure = false + let submitButton = false + let textInput = false + // Compute isSelection only when needed (for pointer/input events or when early return didn't happen) + let isSelection = false + + if (target) { + // Check if target is a selection element (only when we need it) + isSelection = isSelectionElement(target) + + // Disclosure and submit detection + if (isPointerEventEventType) { + // Use native closest() for disclosure and submit detection + // This is more efficient than manual traversal as it's implemented in browser native code + const match = target.closest('details, [aria-expanded], button[type="submit"], input[type="submit"]') + + if (match) { + // Precedence: disclosure takes priority over submit + if (match.tagName === 'DETAILS' || match.hasAttribute('aria-expanded')) { + disclosure = true + } else if ( + (match.tagName === 'BUTTON' && (match as HTMLButtonElement).type === 'submit') || + (match.tagName === 'INPUT' && (match as HTMLInputElement).type === 'submit') + ) { + submitButton = true + } + } + } + + // Only compute textInput if event is input type + // Use direct property checks instead of complex :not() selectors for performance + if (isInputEventType) { + if (target.tagName === 'TEXTAREA') { + textInput = true + } else if (target.tagName === 'INPUT') { + const inputType = (target as HTMLInputElement).type + // Input elements are text inputs unless they're button or submit types + textInput = inputType !== 'button' && inputType !== 'submit' + } else if (target instanceof HTMLElement && target.isContentEditable) { + textInput = true + } + } + } + + const flags: EntryFlags = { + isDisclosure: disclosure, + isSubmitButton: submitButton, + isTextInput: textInput, + isSelection, + isPointerEventEventType, + isInputEventType, + } + + entryFlagsMap.set(entry, flags) + return flags +} + +/** + * Determine interaction type using precomputed flags. + */ +function detectInteractionType(entry: PerformanceEventTiming, target: Element | null): InteractionType { + const eventType = entry.name + const {isDisclosure, isSubmitButton, isTextInput, isSelection, isPointerEventEventType} = getEntryFlags(entry, target) + + // Precedence rules + if (isDisclosure) return 'disclosure' + if (isSubmitButton) return 'submit' + if (eventType === 'submit') return 'submit' + if (isTextInput) return 'text_input' + if (isSelection) return 'selection' + if (isPointerEventEventType) return 'action_click' + + return 'unknown' +} + +/* + * The INP metric. Compatible with web-vitals' INPMetric interface + * and suitable for reporting to DataDog and Hydro. + */ +export class INPMetric extends BaseMetric { + name = 'INP' as const + + get attribution(): INPAttribution { + let entry: PerformanceEventTiming | undefined + + // Select the longest interaction (INP definition) + for (const e of this.entries) { + if (!entry || e.duration > entry.duration) { + entry = e + } + } + + const target = (entry?.target as Element | null) ?? null + + // Compute phase durations from the PerformanceEventTiming entry + const inputDelay = entry ? entry.processingStart - entry.startTime : undefined + const processingDuration = entry ? entry.processingEnd - entry.processingStart : undefined + const presentationDelay = entry ? entry.duration - (entry.processingEnd - entry.startTime) : undefined + + return { + interactionTarget: entry && target ? getSelector(target) : '', + interactionType: entry ? detectInteractionType(entry, target) : undefined, + eventType: entry?.name, + inputDelay, + processingDuration, + presentationDelay, + } + } +} diff --git a/web-vitals/inp/observer.ts b/web-vitals/inp/observer.ts new file mode 100644 index 0000000..49ebf4e --- /dev/null +++ b/web-vitals/inp/observer.ts @@ -0,0 +1,80 @@ +import {ssrSafeDocument, ssrSafeWindow} from '@github-ui/ssr-utils' +import type {INPMetric} from './metric' +import {InteractionProcessor, type CallbackRegistration} from './interaction-processor' +import {BaseObserver} from '../base/observer' +import {SOFT_NAV_STATE} from '@github-ui/soft-nav/states' +import {whenIdleOrHidden} from '../utils/when-idle-or-hidden' + +const supportsINP = + // eslint-disable-next-line compat/compat + ssrSafeWindow && 'PerformanceEventTiming' in ssrSafeWindow && 'interactionId' in PerformanceEventTiming.prototype + +/* + * The INPObserver is responsible for listening to Performance events and routing them to the InteractionProcessor. + * It also manages resetting INP and reporting it when navigating or hiding a page. + */ +export class INPObserver extends BaseObserver { + get softNavEventToListen() { + return SOFT_NAV_STATE.START + } + + initializeProcessor() { + return new InteractionProcessor() + } + + override get supported(): boolean { + return !!supportsINP + } + + observe(initialLoad = true) { + if (!supportsINP) return + + this.url = ssrSafeWindow?.location.href + this.observer = new PerformanceObserver(list => { + whenIdleOrHidden(() => { + this.entryProcessor.processEntries(list.getEntries() as PerformanceEventTiming[]) + }) + }) + + if (initialLoad) { + return this.observeEvents(initialLoad) + } + + // SOFT_NAV_STATE.RENDER is dispatched when the soft navigation finished rendering. + // That means that the previous page is fully hidden so we can start listening for new events. + ssrSafeDocument?.addEventListener(SOFT_NAV_STATE.RENDER, () => { + this.observeEvents(initialLoad) + }) + } + + observeEvents(initialLoad: boolean) { + if (!this.observer) return + + this.observer.observe({type: 'first-input', buffered: initialLoad}) + this.observer.observe({ + type: 'event', + durationThreshold: 40, + // buffered events are important on first page load since we may have missed + // a few until the observer was set up. + buffered: initialLoad, + }) + } + + registerCallback(callback: CallbackRegistration) { + this.interactionProcessor.registeredCallbacks.add(callback) + } + + override report() { + const entries = this.observer?.takeRecords() + + if (entries && entries.length) { + this.entryProcessor.processEntries(entries as PerformanceEventTiming[]) + } + + super.report() + } + + get interactionProcessor(): InteractionProcessor { + return this.entryProcessor as InteractionProcessor + } +} diff --git a/web-vitals/long-animation-frames.ts b/web-vitals/long-animation-frames.ts new file mode 100644 index 0000000..1d5febf --- /dev/null +++ b/web-vitals/long-animation-frames.ts @@ -0,0 +1,28 @@ +import {sendStats} from '@github-ui/stats' +import {sendToHydro} from './hydro-stats' +import {wasServerRendered} from '@github-ui/ssr-utils' + +export const observeLongAnimationFrames = () => { + if ( + typeof PerformanceObserver !== 'undefined' && + (PerformanceObserver.supportedEntryTypes || []).includes('long-animation-frame') + ) { + const observer = new PerformanceObserver(function (list) { + const longAnimationFrameEntries = list.getEntries() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const longAnimationFrames = longAnimationFrameEntries.map(({name, duration, blockingDuration}: any) => ({ + name, + duration, + blockingDuration, + url: window.location.href, + })) + + if (longAnimationFrames.length > 0) { + sendToHydro({longAnimationFrames: longAnimationFrameEntries, ssr: wasServerRendered()}) + } + + sendStats({longAnimationFrames}) + }) + observer.observe({type: 'long-animation-frame', buffered: true}) + } +} diff --git a/web-vitals/utils/when-idle-or-hidden.ts b/web-vitals/utils/when-idle-or-hidden.ts new file mode 100644 index 0000000..d799173 --- /dev/null +++ b/web-vitals/utils/when-idle-or-hidden.ts @@ -0,0 +1,24 @@ +// from https://github.com/GoogleChrome/web-vitals/blob/255855743e6c7a28a81a568ae229bd454559701d/src/lib/whenIdleOrHidden.ts#L23 + +export const whenIdleOrHidden = (cb: () => void) => { + // If the document is hidden, run the callback immediately, otherwise + // race an idle callback with the next `visibilitychange` event. + if (document.visibilityState === 'hidden') return cb() + + // run callback only once + let called = false + + const callback = () => { + if (called) return + called = true + cb() + } + + addEventListener('visibilitychange', callback, {once: true, capture: true}) + // eslint-disable-next-line compat/compat + requestIdleCallback(() => { + callback() + // cleanup listener + removeEventListener('visibilitychange', callback, {capture: true}) + }) +} diff --git a/web-vitals/web-vitals.ts b/web-vitals/web-vitals.ts new file mode 100644 index 0000000..e126901 --- /dev/null +++ b/web-vitals/web-vitals.ts @@ -0,0 +1,69 @@ +import type { + CLSMetricWithAttribution, + FCPMetricWithAttribution, + INPMetricWithAttribution, + LCPMetricWithAttribution, + TTFBMetricWithAttribution, +} from 'web-vitals/attribution' +import type {HPCTimingEvent} from './hpc-events' +import type {INPMetric} from './inp/metric' +import type {ElementTimingMetric} from './element-timing/metric' +import type {CLSMetric} from './cls/metric' +import type {INPObserver} from './inp/observer' +import {isFeatureEnabled} from '@github-ui/feature-flags' + +// eslint-disable-next-line no-barrel-files/no-barrel-files +export {initMemorySampling} from './memory-sampling' + +export type WebVitalMetric = + | CLSMetricWithAttribution + | FCPMetricWithAttribution + | INPMetricWithAttribution + | LCPMetricWithAttribution + | TTFBMetricWithAttribution + | INPMetric + | CLSMetric + | ElementTimingMetric + +export type SoftWebVitalMetric = + | CLSMetricWithAttribution + | FCPMetricWithAttribution + | INPMetricWithAttribution + | LCPMetricWithAttribution + | TTFBMetricWithAttribution + +export type MetricOrHPC = WebVitalMetric | HPCTimingEvent + +export function isReactLazyPayload() { + return Boolean(document.querySelector('react-app[data-lazy="true"]')) +} + +export function isReactAlternate() { + return Boolean(document.querySelector('react-app[data-alternate="true"]')) +} + +export function isHeaderRedesign() { + return Boolean(document.querySelector('header.AppHeader')) +} + +export function hasFetchedGQL(): boolean { + return performance.getEntriesByType('resource').some(e => e.initiatorType === 'fetch' && e.name.includes('_graphql?')) +} + +export function hasFetchedJS(): boolean { + return performance.getEntriesByType('resource').some(e => e.initiatorType === 'script') +} + +let inpObserver: INPObserver | null = null + +export function getGlobalINPObserver(): INPObserver | null { + return inpObserver +} + +export function setGlobalINPObserver(observer: INPObserver) { + inpObserver = observer +} + +export function isSpeculationRulesEnabled(): boolean { + return isFeatureEnabled('speculation_rules') +} From d76a2d8b072f5aa44e0b705796bc9e87ed60464c Mon Sep 17 00:00:00 2001 From: Ahfu C Kit III <196157628+ahfuckit@users.noreply.github.com> Date: Mon, 26 Jan 2026 12:55:30 -0700 Subject: [PATCH 2/5] Create __chromium_devtools_metrics_reporter.js Signed-off-by: Ahfu C Kit III <196157628+ahfuckit@users.noreply.github.com> --- .../__chromium_devtools_metrics_reporter.js | 956 ++++++++++++++++++ 1 file changed, 956 insertions(+) create mode 100644 devtools/__chromium_devtools_metrics_reporter.js diff --git a/devtools/__chromium_devtools_metrics_reporter.js b/devtools/__chromium_devtools_metrics_reporter.js new file mode 100644 index 0000000..029f3bb --- /dev/null +++ b/devtools/__chromium_devtools_metrics_reporter.js @@ -0,0 +1,956 @@ +!function() { + "use strict"; + let t = -1; + const e = () => t + , n = e => { + addEventListener("pageshow", n => { + n.persisted && (t = n.timeStamp, + e(n)) + } + , !0) + } + , r = (t, e, n, r) => { + let i, o; + return s => { + e.value >= 0 && (s || r) && (o = e.value - (i ?? 0), + (o || void 0 === i) && (i = e.value, + e.delta = o, + e.rating = ( (t, e) => t > e[1] ? "poor" : t > e[0] ? "needs-improvement" : "good")(e.value, n), + t(e))) + } + } + , i = t => { + requestAnimationFrame( () => requestAnimationFrame( () => t())) + } + , o = () => { + const t = performance.getEntriesByType("navigation")[0]; + if (t && t.responseStart > 0 && t.responseStart < performance.now()) + return t + } + , s = () => { + const t = o(); + return t?.activationStart ?? 0 + } + , a = (t, n=-1) => { + const r = o(); + let i = "navigate"; + return e() >= 0 ? i = "back-forward-cache" : r && (document.prerendering || s() > 0 ? i = "prerender" : document.wasDiscarded ? i = "restore" : r.type && (i = r.type.replace(/_/g, "-"))), + { + name: t, + value: n, + rating: "good", + delta: 0, + entries: [], + id: `v5-${Date.now()}-${Math.floor(8999999999999 * Math.random()) + 1e12}`, + navigationType: i + } + } + , c = new WeakMap; + function l(t, e) { + return c.get(t) || c.set(t, new e), + c.get(t) + } + class u { + _onAfterProcessingUnexpectedShift; + _sessionValue = 0; + _sessionEntries = []; + _processEntry(t) { + if (t.hadRecentInput) + return; + const e = this._sessionEntries[0] + , n = this._sessionEntries.at(-1); + this._sessionValue && e && n && t.startTime - n.startTime < 1e3 && t.startTime - e.startTime < 5e3 ? (this._sessionValue += t.value, + this._sessionEntries.push(t)) : (this._sessionValue = t.value, + this._sessionEntries = [t]), + this._onAfterProcessingUnexpectedShift?.(t) + } + } + const d = (t, e, n={}) => { + try { + if (PerformanceObserver.supportedEntryTypes.includes(t)) { + const r = new PerformanceObserver(t => { + Promise.resolve().then( () => { + e(t.getEntries()) + } + ) + } + ); + return r.observe({ + type: t, + buffered: !0, + ...n + }), + r + } + } catch {} + } + , m = t => { + let e = !1; + return () => { + e || (t(), + e = !0) + } + } + ; + let p = -1; + const g = new Set + , f = () => "hidden" !== document.visibilityState || document.prerendering ? 1 / 0 : 0 + , h = t => { + if ("hidden" === document.visibilityState) { + if ("visibilitychange" === t.type) + for (const t of g) + t(); + isFinite(p) || (p = "visibilitychange" === t.type ? t.timeStamp : 0, + removeEventListener("prerenderingchange", h, !0)) + } + } + , y = () => { + if (p < 0) { + const t = s() + , e = document.prerendering ? void 0 : globalThis.performance.getEntriesByType("visibility-state").filter(e => "hidden" === e.name && e.startTime > t)[0]?.startTime; + p = e ?? f(), + addEventListener("visibilitychange", h, !0), + addEventListener("prerenderingchange", h, !0), + n( () => { + setTimeout( () => { + p = f() + } + ) + } + ) + } + return { + get firstHiddenTime() { + return p + }, + onHidden(t) { + g.add(t) + } + } + } + , v = t => { + document.prerendering ? addEventListener("prerenderingchange", () => t(), !0) : t() + } + , T = [1800, 3e3] + , _ = (t, e={}) => { + v( () => { + const o = y(); + let c, l = a("FCP"); + const u = d("paint", t => { + for (const e of t) + "first-contentful-paint" === e.name && (u.disconnect(), + e.startTime < o.firstHiddenTime && (l.value = Math.max(e.startTime - s(), 0), + l.entries.push(e), + c(!0))) + } + ); + u && (c = r(t, l, T, e.reportAllChanges), + n(n => { + l = a("FCP"), + c = r(t, l, T, e.reportAllChanges), + i( () => { + l.value = performance.now() - n.timeStamp, + c(!0) + } + ) + } + )) + } + ) + } + , E = [.1, .25] + , b = (t, e={}) => { + const o = y(); + _(m( () => { + let s, c = a("CLS", 0); + const m = l(e, u) + , p = t => { + for (const e of t) + m._processEntry(e); + m._sessionValue > c.value && (c.value = m._sessionValue, + c.entries = m._sessionEntries, + s()) + } + , g = d("layout-shift", p); + g && (s = r(t, c, E, e.reportAllChanges), + o.onHidden( () => { + p(g.takeRecords()), + s(!0) + } + ), + n( () => { + m._sessionValue = 0, + c = a("CLS", 0), + s = r(t, c, E, e.reportAllChanges), + i( () => s()) + } + ), + setTimeout(s)) + } + )) + } + ; + let S = 0 + , L = 1 / 0 + , I = 0; + const P = t => { + for (const e of t) + e.interactionId && (L = Math.min(L, e.interactionId), + I = Math.max(I, e.interactionId), + S = I ? (I - L) / 7 + 1 : 0) + } + ; + let D; + const C = () => D ? S : performance.interactionCount ?? 0; + let M = 0; + class w { + _longestInteractionList = []; + _longestInteractionMap = new Map; + _onBeforeProcessingEntry; + _onAfterProcessingINPCandidate; + _resetInteractions() { + M = C(), + this._longestInteractionList.length = 0, + this._longestInteractionMap.clear() + } + _estimateP98LongestInteraction() { + const t = Math.min(this._longestInteractionList.length - 1, Math.floor((C() - M) / 50)); + return this._longestInteractionList[t] + } + _processEntry(t) { + if (this._onBeforeProcessingEntry?.(t), + !t.interactionId && "first-input" !== t.entryType) + return; + const e = this._longestInteractionList.at(-1); + let n = this._longestInteractionMap.get(t.interactionId); + if (n || this._longestInteractionList.length < 10 || t.duration > e._latency) { + if (n ? t.duration > n._latency ? (n.entries = [t], + n._latency = t.duration) : t.duration === n._latency && t.startTime === n.entries[0].startTime && n.entries.push(t) : (n = { + id: t.interactionId, + entries: [t], + _latency: t.duration + }, + this._longestInteractionMap.set(n.id, n), + this._longestInteractionList.push(n)), + this._longestInteractionList.sort( (t, e) => e._latency - t._latency), + this._longestInteractionList.length > 10) { + const t = this._longestInteractionList.splice(10); + for (const e of t) + this._longestInteractionMap.delete(e.id) + } + this._onAfterProcessingINPCandidate?.(n) + } + } + } + const A = t => { + const e = globalThis.requestIdleCallback || setTimeout; + "hidden" === document.visibilityState ? t() : (t = m(t), + addEventListener("visibilitychange", t, { + once: !0, + capture: !0 + }), + e( () => { + t(), + removeEventListener("visibilitychange", t, { + capture: !0 + }) + } + )) + } + , x = [200, 500] + , F = (t, e={}) => { + if (!globalThis.PerformanceEventTiming || !("interactionId"in PerformanceEventTiming.prototype)) + return; + const i = y(); + v( () => { + "interactionCount"in performance || D || (D = d("event", P, { + type: "event", + buffered: !0, + durationThreshold: 0 + })); + let o, s = a("INP"); + const c = l(e, w) + , u = t => { + A( () => { + for (const e of t) + c._processEntry(e); + const e = c._estimateP98LongestInteraction(); + e && e._latency !== s.value && (s.value = e._latency, + s.entries = e.entries, + o()) + } + ) + } + , m = d("event", u, { + durationThreshold: e.durationThreshold ?? 40 + }); + o = r(t, s, x, e.reportAllChanges), + m && (m.observe({ + type: "first-input", + buffered: !0 + }), + i.onHidden( () => { + u(m.takeRecords()), + o(!0) + } + ), + n( () => { + c._resetInteractions(), + s = a("INP"), + o = r(t, s, x, e.reportAllChanges) + } + )) + } + ) + } + ; + class B { + _onBeforeProcessingEntry; + _processEntry(t) { + this._onBeforeProcessingEntry?.(t) + } + } + const N = [2500, 4e3] + , k = (t, e={}) => { + v( () => { + const o = y(); + let c, u = a("LCP"); + const p = l(e, B) + , g = t => { + e.reportAllChanges || (t = t.slice(-1)); + for (const e of t) + p._processEntry(e), + e.startTime < o.firstHiddenTime && (u.value = Math.max(e.startTime - s(), 0), + u.entries = [e], + c()) + } + , f = d("largest-contentful-paint", g); + if (f) { + c = r(t, u, N, e.reportAllChanges); + const o = m( () => { + g(f.takeRecords()), + f.disconnect(), + c(!0) + } + ) + , s = t => { + t.isTrusted && (A(o), + removeEventListener(t.type, s, { + capture: !0 + })) + } + ; + for (const t of ["keydown", "click", "visibilitychange"]) + addEventListener(t, s, { + capture: !0 + }); + n(n => { + u = a("LCP"), + c = r(t, u, N, e.reportAllChanges), + i( () => { + u.value = performance.now() - n.timeStamp, + c(!0) + } + ) + } + ) + } + } + ) + } + , O = [800, 1800] + , R = t => { + document.prerendering ? v( () => R(t)) : "complete" !== document.readyState ? addEventListener("load", () => R(t), !0) : setTimeout(t) + } + , j = t => { + if ("loading" === document.readyState) + return "loading"; + { + const e = o(); + if (e) { + if (t < e.domInteractive) + return "loading"; + if (0 === e.domContentLoadedEventStart || t < e.domContentLoadedEventStart) + return "dom-interactive"; + if (0 === e.domComplete || t < e.domComplete) + return "dom-content-loaded" + } + } + return "complete" + } + , W = t => { + const e = t.nodeName; + return 1 === t.nodeType ? e.toLowerCase() : e.toUpperCase().replace(/^#/, "") + } + , V = t => { + let e = ""; + try { + for (; 9 !== t?.nodeType; ) { + const n = t + , r = n.id ? "#" + n.id : [W(n), ...Array.from(n.classList).sort()].join("."); + if (e.length + r.length > 99) + return e || r; + if (e = e ? r + ">" + e : r, + n.id) + break; + t = n.parentNode + } + } catch {} + return e + } + , q = t => t.find(t => 1 === t.node?.nodeType) || t[0]; + var H = Object.freeze({ + __proto__: null, + CLSThresholds: E, + FCPThresholds: T, + INPThresholds: x, + LCPThresholds: N, + TTFBThresholds: O, + onCLS: (t, e={}) => { + const n = l(e = Object.assign({}, e), u) + , r = new WeakMap; + n._onAfterProcessingUnexpectedShift = t => { + if (t?.sources?.length) { + const n = q(t.sources) + , i = n?.node; + if (i) { + const t = e.generateTarget?.(i) ?? V(i); + r.set(n, t) + } + } + } + , + b(e => { + const n = (t => { + let e = {}; + if (t.entries.length) { + const n = t.entries.reduce( (t, e) => t.value > e.value ? t : e); + if (n?.sources?.length) { + const t = q(n.sources); + t && (e = { + largestShiftTarget: r.get(t), + largestShiftTime: n.startTime, + largestShiftValue: n.value, + largestShiftSource: t, + largestShiftEntry: n, + loadState: j(n.startTime) + }) + } + } + return Object.assign(t, { + attribution: e + }) + } + )(e); + t(n) + } + , e) + } + , + onFCP: (t, n={}) => { + _(n => { + const r = (t => { + let n = { + timeToFirstByte: 0, + firstByteToFCP: t.value, + loadState: j(e()) + }; + if (t.entries.length) { + const e = o() + , r = t.entries.at(-1); + if (e) { + const i = e.activationStart || 0 + , o = Math.max(0, e.responseStart - i); + n = { + timeToFirstByte: o, + firstByteToFCP: t.value - o, + loadState: j(t.entries[0].startTime), + navigationEntry: e, + fcpEntry: r + } + } + } + return Object.assign(t, { + attribution: n + }) + } + )(n); + t(r) + } + , n) + } + , + onINP: (t, e={}) => { + const n = l(e = Object.assign({}, e), w); + let r = [] + , i = [] + , o = 0; + const s = new WeakMap + , a = new WeakMap; + let c = !1; + const u = () => { + c || (A(m), + c = !0) + } + , m = () => { + const t = n._longestInteractionList.map(t => s.get(t.entries[0])) + , e = i.length - 50; + i = i.filter( (n, r) => r >= e || t.includes(n)); + const a = new Set; + for (const t of i) { + const e = p(t.startTime, t.processingEnd); + for (const t of e) + a.add(t) + } + const l = r.length - 1 - 50; + r = r.filter( (t, e) => t.startTime > o && e > l || a.has(t)), + c = !1 + } + ; + n._onBeforeProcessingEntry = t => { + !async function(t) { + if (!e.onEachInteraction) + return; + if (await Promise.resolve(), + !t.interactionId) + return; + const n = g({ + entries: [t], + name: "INP", + rating: "good", + value: t.duration, + delta: t.duration, + navigationType: "navigate", + id: "N/A" + }); + e.onEachInteraction(n) + }(t), + (t => { + const e = t.startTime + t.duration; + let n; + o = Math.max(o, t.processingEnd); + for (let r = i.length - 1; r >= 0; r--) { + const o = i[r]; + if (Math.abs(e - o.renderTime) <= 8) { + n = o, + n.startTime = Math.min(t.startTime, n.startTime), + n.processingStart = Math.min(t.processingStart, n.processingStart), + n.processingEnd = Math.max(t.processingEnd, n.processingEnd), + n.entries.push(t); + break + } + } + n || (n = { + startTime: t.startTime, + processingStart: t.processingStart, + processingEnd: t.processingEnd, + renderTime: e, + entries: [t] + }, + i.push(n)), + (t.interactionId || "first-input" === t.entryType) && s.set(t, n), + u() + } + )(t) + } + , + n._onAfterProcessingINPCandidate = t => { + if (!a.get(t)) { + const n = t.entries[0].target; + if (n) { + const r = e.generateTarget?.(n) ?? V(n); + a.set(t, r) + } + } + } + ; + const p = (t, e) => { + const n = []; + for (const i of r) + if (!(i.startTime + i.duration < t)) { + if (i.startTime > e) + break; + n.push(i) + } + return n + } + , g = t => { + const e = t.entries[0] + , r = s.get(e) + , i = e.processingStart + , o = Math.max(e.startTime + e.duration, i) + , c = Math.min(r.processingEnd, o) + , l = r.entries.sort( (t, e) => t.processingStart - e.processingStart) + , u = p(e.startTime, c) + , d = n._longestInteractionMap.get(e.interactionId) + , m = { + interactionTarget: a.get(d), + interactionType: e.name.startsWith("key") ? "keyboard" : "pointer", + interactionTime: e.startTime, + nextPaintTime: o, + processedEventEntries: l, + longAnimationFrameEntries: u, + inputDelay: i - e.startTime, + processingDuration: c - i, + presentationDelay: o - c, + loadState: j(e.startTime), + longestScript: void 0, + totalScriptDuration: void 0, + totalStyleAndLayoutDuration: void 0, + totalPaintDuration: void 0, + totalUnattributedDuration: void 0 + }; + return (t => { + if (!t.longAnimationFrameEntries?.length) + return; + const e = t.interactionTime + , n = t.inputDelay + , r = t.processingDuration; + let i, o, s = 0, a = 0, c = 0, l = 0; + for (const c of t.longAnimationFrameEntries) { + a = a + c.startTime + c.duration - c.styleAndLayoutStart; + for (const t of c.scripts) { + const c = t.startTime + t.duration; + if (c < e) + continue; + const u = c - Math.max(e, t.startTime) + , d = t.duration ? u / t.duration * t.forcedStyleAndLayoutDuration : 0; + s += u - d, + a += d, + u > l && (o = t.startTime < e + n ? "input-delay" : t.startTime >= e + n + r ? "presentation-delay" : "processing-duration", + i = t, + l = u) + } + } + const u = t.longAnimationFrameEntries.at(-1) + , d = u ? u.startTime + u.duration : 0; + d >= e + n + r && (c = t.nextPaintTime - d), + i && o && (t.longestScript = { + entry: i, + subpart: o, + intersectingDuration: l + }), + t.totalScriptDuration = s, + t.totalStyleAndLayoutDuration = a, + t.totalPaintDuration = c, + t.totalUnattributedDuration = t.nextPaintTime - e - s - a - c + } + )(m), + Object.assign(t, { + attribution: m + }) + } + ; + d("long-animation-frame", t => { + r = r.concat(t), + u() + } + ), + F(e => { + const n = g(e); + t(n) + } + , e) + } + , + onLCP: (t, e={}) => { + const n = l(e = Object.assign({}, e), B) + , r = new WeakMap; + n._onBeforeProcessingEntry = t => { + const n = t.element; + if (n) { + const i = e.generateTarget?.(n) ?? V(n); + r.set(t, i) + } + } + , + k(e => { + const n = (t => { + let e = { + timeToFirstByte: 0, + resourceLoadDelay: 0, + resourceLoadDuration: 0, + elementRenderDelay: t.value + }; + if (t.entries.length) { + const n = o(); + if (n) { + const i = n.activationStart || 0 + , o = t.entries.at(-1) + , s = o.url && performance.getEntriesByType("resource").filter(t => t.name === o.url)[0] + , a = Math.max(0, n.responseStart - i) + , c = Math.max(a, s ? (s.requestStart || s.startTime) - i : 0) + , l = Math.min(t.value, Math.max(c, s ? s.responseEnd - i : 0)); + e = { + target: r.get(o), + timeToFirstByte: a, + resourceLoadDelay: c - a, + resourceLoadDuration: l - c, + elementRenderDelay: t.value - l, + navigationEntry: n, + lcpEntry: o + }, + o.url && (e.url = o.url), + s && (e.lcpResourceEntry = s) + } + } + return Object.assign(t, { + attribution: e + }) + } + )(e); + t(n) + } + , e) + } + , + onTTFB: (t, e={}) => { + ( (t, e={}) => { + let i = a("TTFB") + , c = r(t, i, O, e.reportAllChanges); + R( () => { + const l = o(); + l && (i.value = Math.max(l.responseStart - s(), 0), + i.entries = [l], + c(!0), + n( () => { + i = a("TTFB", 0), + c = r(t, i, O, e.reportAllChanges), + c(!0) + } + )) + } + ) + } + )(e => { + const n = (t => { + let e = { + waitingDuration: 0, + cacheDuration: 0, + dnsDuration: 0, + connectionDuration: 0, + requestDuration: 0 + }; + if (t.entries.length) { + const n = t.entries[0] + , r = n.activationStart || 0 + , i = Math.max((n.workerStart || n.fetchStart) - r, 0) + , o = Math.max(n.domainLookupStart - r, 0) + , s = Math.max(n.connectStart - r, 0) + , a = Math.max(n.connectEnd - r, 0); + e = { + waitingDuration: i, + cacheDuration: o - i, + dnsDuration: s - o, + connectionDuration: a - s, + requestDuration: t.value - a, + navigationEntry: n + } + } + return Object.assign(t, { + attribution: e + }) + } + )(e); + t(n) + } + , e) + } + }); + var U = Object.freeze({ + __proto__: null, + onEachLayoutShift: function(t) { + new PerformanceObserver(e => { + const n = e.getEntries().filter(t => "hadRecentInput"in t); + for (const e of n) { + if (e.hadRecentInput) + continue; + const n = e.sources.map(t => t.node).filter(t => t instanceof Node); + t({ + attribution: { + affectedNodes: n + }, + entry: e, + value: e.value + }) + } + } + ).observe({ + type: "layout-shift", + buffered: !0 + }) + } + }); + function $(t) { + return `layout-shift-${t.value}-${t.startTime}` + } + const {onLCP: z, onCLS: G, onINP: J} = H + , {onEachLayoutShift: K} = U + , Q = [] + , X = [] + , Y = [] + , Z = Window.prototype.addEventListener; + Window.prototype.addEventListener = function(...t) { + return Q.push(t), + Z.call(this, ...t) + } + ; + const tt = Document.prototype.addEventListener; + Document.prototype.addEventListener = function(...t) { + return X.push(t), + tt.call(this, ...t) + } + ; + class et extends PerformanceObserver { + constructor(...t) { + super(...t), + Y.push(this) + } + } + globalThis.PerformanceObserver = et; + let nt = !1; + function rt(t) { + const e = JSON.stringify(t); + window.__chromium_devtools_metrics_reporter(e) + } + window.__chromium_devtools_kill_live_metrics = () => { + if (!nt) { + for (const t of Y) + t.disconnect(); + for (const t of Q) + window.removeEventListener(...t); + for (const t of X) + document.removeEventListener(...t); + nt = !0 + } + } + ; + const it = []; + function ot(t) { + const e = it.length; + return it.push(new WeakRef(t)), + e + } + function st() { + if (document.prerendering) + return !0; + const t = self.performance.getEntriesByType?.("navigation")[0]?.activationStart; + return void 0 !== t && t > 0 + } + window.getNodeForIndex = t => it[t].deref(); + let at = null; + rt({ + name: "reset" + }), + new PerformanceObserver(t => { + for (const e of t.getEntries()) + null !== at || st() || (at = "hidden" === e.name) + } + ).observe({ + type: "visibility-state", + buffered: !0 + }), + n( () => { + at = !1, + rt({ + name: "reset" + }) + } + ), + z(t => { + const e = { + name: "LCP", + value: t.value, + startedHidden: Boolean(at), + phases: { + timeToFirstByte: t.attribution.timeToFirstByte, + resourceLoadDelay: t.attribution.resourceLoadDelay, + resourceLoadTime: t.attribution.resourceLoadDuration, + elementRenderDelay: t.attribution.elementRenderDelay + } + } + , n = t.attribution.lcpEntry?.element; + n && (e.nodeIndex = ot(n)), + rt(e) + } + , { + reportAllChanges: !0 + }), + G(t => { + rt({ + name: "CLS", + value: t.value, + clusterShiftIds: t.entries.map($) + }) + } + , { + reportAllChanges: !0 + }), + J(t => { + rt({ + name: "INP", + value: t.value, + phases: { + inputDelay: t.attribution.inputDelay, + processingDuration: t.attribution.processingDuration, + presentationDelay: t.attribution.presentationDelay + }, + startTime: t.entries[0].startTime, + entryGroupId: t.entries[0].interactionId, + interactionType: t.attribution.interactionType + }) + } + , { + reportAllChanges: !0, + durationThreshold: 0, + onEachInteraction: function(t) { + const e = { + name: "InteractionEntry", + duration: t.value, + phases: { + inputDelay: t.attribution.inputDelay, + processingDuration: t.attribution.processingDuration, + presentationDelay: t.attribution.presentationDelay + }, + startTime: t.entries[0].startTime, + entryGroupId: t.entries[0].interactionId, + nextPaintTime: t.attribution.nextPaintTime, + interactionType: t.attribution.interactionType, + eventName: t.entries[0].name, + longAnimationFrameEntries: (n = t.attribution.longAnimationFrameEntries.slice(-5).map(t => t.toJSON()), + n.map(t => { + const e = []; + for (const n of t.scripts) { + if (e.length < 10) { + e.push(n); + continue + } + const t = e.findIndex(t => t.duration < n.duration); + -1 !== t && (e[t] = n) + } + return e.sort( (t, e) => t.startTime - e.startTime), + t.scripts = e, + t + } + )) + }; + var n; + const r = t.attribution.interactionTarget; + r && (e.nodeIndex = Number(r)), + rt(e) + }, + generateTarget(t) { + if (t) + return String(ot(t)) + } + }), + K(t => { + rt({ + name: "LayoutShift", + score: t.value, + uniqueLayoutShiftId: $(t.entry), + affectedNodeIndices: t.attribution.affectedNodes.map(ot) + }) + } + ) +}(); From e5d1d7840e93cfefdb9c065782aa83dc6ebd77d2 Mon Sep 17 00:00:00 2001 From: Ahfu C Kit III <196157628+ahfuckit@users.noreply.github.com> Date: Mon, 26 Jan 2026 16:43:16 -0700 Subject: [PATCH 3/5] Create w3fixer.js Signed-off-by: Ahfu C Kit III <196157628+ahfuckit@users.noreply.github.com> --- w3fixer.js | 429 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 429 insertions(+) create mode 100644 w3fixer.js diff --git a/w3fixer.js b/w3fixer.js new file mode 100644 index 0000000..bb744f0 --- /dev/null +++ b/w3fixer.js @@ -0,0 +1,429 @@ +/****************************************************************************** + * JS Extension for the W3C Spec Style Sheet * + * * + * This code handles: * + * - some fixup to improve the table of contents * + * - the obsolete warning on outdated specs * + ******************************************************************************/ +(function() { + "use strict"; + try { + var details = document.querySelector("div.head details"); + details.addEventListener("toggle", function toggle() { + window.localStorage.setItem("tr-metadata", details.open); + }, false); + details.open = !localStorage.getItem("tr-metadata") || localStorage.getItem("tr-metadata") === 'true'; + } catch (e) {}; // ignore errors for this interaction + + const tocToggleId = 'toc-toggle'; + const tocJumpId = 'toc-jump'; + const tocCollapseId = 'toc-collapse'; + const tocExpandId = 'toc-expand'; + const tocThemeToggle = 'toc-theme-toggle'; + + var ESCAPEKEY = 27; + + // Internationalization support for sidebar/jump text + var lang = document.documentElement.lang || 'en'; + var i18n = { + en: { + collapseSidebar: 'Collapse Sidebar', + expandSidebar: 'Pop Out Sidebar', + jumpToToc: 'Jump to Table of Contents', + }, + cs: { + collapseSidebar: 'Skrรฝt postrannรญ panel', + expandSidebar: 'Zobrazit postrannรญ panel', + jumpToToc: 'Pล™ejรญt na obsah', + }, + de: { + collapseSidebar: 'Seitenleiste einklappen', + expandSidebar: 'Seitenleiste ausklappen', + jumpToToc: 'Zum Inhaltsverzeichnis springen', + }, + es: { + collapseSidebar: 'Colapsar barra lateral', + expandSidebar: 'Mostrar barra lateral', + jumpToToc: 'Ir al รญndice', + }, + ja: { + collapseSidebar: 'ใ‚ตใ‚คใƒ‰ใƒใƒผใ‚’ๆŠ˜ใ‚ŠใŸใŸใ‚€', + expandSidebar: 'ใ‚ตใ‚คใƒ‰ใƒใƒผใ‚’่กจ็คบ', + jumpToToc: '็›ฎๆฌกใธใ‚ธใƒฃใƒณใƒ—', + }, + ko: { + collapseSidebar: '์‚ฌ์ด๋“œ๋ฐ” ์ ‘๊ธฐ', + expandSidebar: '์‚ฌ์ด๋“œ๋ฐ” ํŽผ์น˜๊ธฐ', + jumpToToc: '๋ชฉ์ฐจ๋กœ ์ด๋™', + }, + nl: { + collapseSidebar: 'Zijbalk samenvouwen', + expandSidebar: 'Zijbalk uitklappen', + jumpToToc: 'Naar inhoudsopgave', + }, + zh: { + collapseSidebar: 'ๆ”ถ่ตทไพง่พนๆ ', + expandSidebar: 'ๅฑ•ๅผ€ไพง่พนๆ ', + jumpToToc: '่ทณ่ฝฌๅˆฐ็›ฎๅฝ•', + } + }; + var t = i18n[lang] || i18n['en']; + var collapseSidebarText = ' ' + + `${t.collapseSidebar}`; + var expandSidebarText = ' ' + + `${t.expandSidebar}`; + var tocJumpText = ' ' + + `${t.jumpToToc}`; + + var sidebarMedia = window.matchMedia('screen and (min-width: 78em)'); + var autoToggle = function(e){ toggleSidebar(e.matches) }; + if(sidebarMedia.addListener) { + sidebarMedia.addListener(autoToggle); + } + + function toggleSidebar(on, skipScroll) { + if (on == undefined) { + on = !document.body.classList.contains('toc-sidebar'); + } + + if (!skipScroll) { + /* Don't scroll to compensate for the ToC if we're above it already. */ + var headY = 0; + var head = document.querySelector('.head'); + if (head) { + // terrible approx of "top of ToC" + headY += head.offsetTop + head.offsetHeight; + } + skipScroll = window.scrollY < headY; + } + + var toggle = document.getElementById(tocToggleId); + var tocNav = document.getElementById('toc'); + if (on) { + var tocHeight = tocNav.offsetHeight; + document.body.classList.add('toc-sidebar'); + document.body.classList.remove('toc-inline'); + toggle.innerHTML = collapseSidebarText; + toggle.setAttribute('aria-labelledby', `${tocCollapseId}-text`); + if (!skipScroll) { + window.scrollBy(0, 0 - tocHeight); + } + tocNav.focus(); + sidebarMedia.addListener(autoToggle); // auto-collapse when out of room + } + else { + document.body.classList.add('toc-inline'); + document.body.classList.remove('toc-sidebar'); + toggle.innerHTML = expandSidebarText; + toggle.setAttribute('aria-labelledby', `${tocExpandId}-text`); + if (!skipScroll) { + window.scrollBy(0, tocNav.offsetHeight); + } + if (toggle.matches(':hover')) { + /* Unfocus button when not using keyboard navigation, + because I don't know where else to send the focus. */ + toggle.blur(); + } + } + } + + function createSidebarToggle() { + /* Create the sidebar toggle in JS; it shouldn't exist when JS is off. */ + var toggle = document.createElement('a'); + /* This should probably be a button, but appearance isn't standards-track.*/ + toggle.id = tocToggleId; + toggle.class = 'toc-toggle'; + toggle.href = '#toc'; + toggle.innerHTML = collapseSidebarText; + toggle.setAttribute('aria-labelledby', `${tocCollapseId}-text`); + + sidebarMedia.addListener(autoToggle); + var toggler = function(e) { + e.preventDefault(); + sidebarMedia.removeListener(autoToggle); // persist explicit off states + toggleSidebar(); + return false; + } + toggle.addEventListener('click', toggler, false); + + + /* Get