Public: anyone can read this; the crew can change it.
webvitals(): LCP, CLS and TBT from recorded performance entries, graded against specification.website thresholds #2985
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/2982-webvitals-lcp-cls-and-tbt-from-recorded → main 1 commit fork
2 files
changed +224
−0
src/release/webvitals.ts added +144
| @@ -0,0 +1,144 @@ | ||
| 1 | /** | |
| 2 | * The website specification's Core Web Vitals, graded from what a page | |
| 3 | * recorded (#2982, part of #2915 check 9). `webvitals()` takes the | |
| 4 | * `largest-contentful-paint`, `layout-shift` and `longtask` performance | |
| 5 | * entries as plain data and returns LCP, CLS and TBT, each graded against the | |
| 6 | * specification.website thresholds and citing its section. Pure: no browser, | |
| 7 | * no clock, no I/O. Entries that are not finite, non-negative numbers are | |
| 8 | * dropped at the boundary, never trusted. | |
| 9 | * | |
| 10 | * Thresholds (good / poor; between is needs-improvement), as web.dev states | |
| 11 | * them and specification.website cites: | |
| 12 | * - LCP: good <= 2500 ms, poor > 4000 ms. | |
| 13 | * - CLS: good <= 0.1, poor > 0.25 (unitless). | |
| 14 | * - INP: good <= 200 ms, poor > 500 ms. A lab run has no interactions, so | |
| 15 | * Total Blocking Time stands in for it, graded with Lighthouse's TBT | |
| 16 | * scale: good <= 200 ms, poor > 600 ms. | |
| 17 | */ | |
| 18 | ||
| 19 | export type Grade = "good" | "needs-improvement" | "poor"; | |
| 20 | ||
| 21 | export interface Vital { | |
| 22 | /** The measured value, or null when the page recorded no entry for it. */ | |
| 23 | value: number | null; | |
| 24 | grade: Grade | null; | |
| 25 | /** Upper bound of "good", inclusive. */ | |
| 26 | good: number; | |
| 27 | /** Upper bound of "needs-improvement", inclusive; above it is "poor". */ | |
| 28 | poor: number; | |
| 29 | unit: "ms" | ""; | |
| 30 | /** The specification.website section this vital cites. */ | |
| 31 | section: string; | |
| 32 | } | |
| 33 | ||
| 34 | export interface WebVitals { | |
| 35 | lcpMs: Vital; | |
| 36 | cls: Vital; | |
| 37 | tbtMs: Vital; | |
| 38 | } | |
| 39 | ||
| 40 | /** The fields of a recorded entry this module reads; anything else is ignored. */ | |
| 41 | export interface Entries { | |
| 42 | lcp?: readonly unknown[]; | |
| 43 | layoutShift?: readonly unknown[]; | |
| 44 | longtask?: readonly unknown[]; | |
| 45 | } | |
| 46 | ||
| 47 | const SPEC = "https://specification.website/"; | |
| 48 | const s = (name: string) => `${SPEC}#${name}`; | |
| 49 | ||
| 50 | export const LCP = { good: 2500, poor: 4000, unit: "ms", section: s("largest-contentful-paint") } as const; | |
| 51 | export const CLS = { good: 0.1, poor: 0.25, unit: "", section: s("cumulative-layout-shift") } as const; | |
| 52 | /** TBT is the lab proxy for INP (good <= 200 ms); poor follows Lighthouse's TBT scale. */ | |
| 53 | export const TBT = { good: 200, poor: 600, unit: "ms", section: s("interaction-to-next-paint") } as const; | |
| 54 | ||
| 55 | /** A long task blocks the main thread for whatever it runs past 50 ms. */ | |
| 56 | export const BLOCKING_MS = 50; | |
| 57 | /** CLS session windows: a gap of 1 s or more starts a new window, and no window spans more than 5 s. */ | |
| 58 | export const SESSION_GAP_MS = 1000; | |
| 59 | export const SESSION_CAP_MS = 5000; | |
| 60 | ||
| 61 | const num = (v: unknown): number | null => (typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : null); | |
| 62 | const field = (e: unknown, k: string): unknown => (e && typeof e === "object" ? (e as Record<string, unknown>)[k] : undefined); | |
| 63 | const list = (v: unknown): readonly unknown[] => (Array.isArray(v) ? v : []); | |
| 64 | ||
| 65 | export function grade(value: number, t: { good: number; poor: number }): Grade { | |
| 66 | return value <= t.good ? "good" : value <= t.poor ? "needs-improvement" : "poor"; | |
| 67 | } | |
| 68 | ||
| 69 | function vital(value: number | null, t: { good: number; poor: number; unit: "ms" | ""; section: string }): Vital { | |
| 70 | return { value, grade: value === null ? null : grade(value, t), good: t.good, poor: t.poor, unit: t.unit, section: t.section }; | |
| 71 | } | |
| 72 | ||
| 73 | /** | |
| 74 | * LCP is the render time of the largest candidate the page reported. The | |
| 75 | * browser only reports a candidate larger than the last, so the largest | |
| 76 | * `size` is the final one; without sizes the latest `startTime` stands. | |
| 77 | * `startTime` is renderTime, or loadTime for cross-origin images without | |
| 78 | * Timing-Allow-Origin. | |
| 79 | */ | |
| 80 | export function lcpMs(entries: readonly unknown[]): number | null { | |
| 81 | let best: { t: number; size: number } | null = null; | |
| 82 | for (const e of entries) { | |
| 83 | const t = num(field(e, "startTime")); | |
| 84 | if (t === null) continue; | |
| 85 | const size = num(field(e, "size")) ?? 0; | |
| 86 | if (!best || size > best.size || (size === best.size && t > best.t)) best = { t, size }; | |
| 87 | } | |
| 88 | return best && Math.round(best.t); | |
| 89 | } | |
| 90 | ||
| 91 | /** | |
| 92 | * CLS is the largest session window's sum of shift values. Shifts within | |
| 93 | * 500 ms of input (`hadRecentInput`) are expected and do not count. A window | |
| 94 | * closes when the next shift comes 1 s or more after the previous one, or | |
| 95 | * 5 s or more after the window's first. | |
| 96 | */ | |
| 97 | export function cls(entries: readonly unknown[]): number { | |
| 98 | const shifts: { t: number; v: number }[] = []; | |
| 99 | for (const e of entries) { | |
| 100 | if (field(e, "hadRecentInput") === true) continue; | |
| 101 | const t = num(field(e, "startTime")); | |
| 102 | const v = num(field(e, "value")); | |
| 103 | if (t !== null && v !== null) shifts.push({ t, v }); | |
| 104 | } | |
| 105 | shifts.sort((a, b) => a.t - b.t); | |
| 106 | let max = 0; | |
| 107 | let sum = 0; | |
| 108 | let first = -Infinity; | |
| 109 | let prev = -Infinity; | |
| 110 | for (const { t, v } of shifts) { | |
| 111 | if (t - prev >= SESSION_GAP_MS || t - first >= SESSION_CAP_MS) { | |
| 112 | sum = 0; | |
| 113 | first = t; | |
| 114 | } | |
| 115 | sum += v; | |
| 116 | prev = t; | |
| 117 | if (sum > max) max = sum; | |
| 118 | } | |
| 119 | // Four places hide float noise (0.07 + 0.03) without moving a grade. | |
| 120 | return Math.round(max * 1e4) / 1e4; | |
| 121 | } | |
| 122 | ||
| 123 | /** TBT sums, over every long task, the part of its duration past 50 ms. */ | |
| 124 | export function tbtMs(entries: readonly unknown[]): number { | |
| 125 | let total = 0; | |
| 126 | for (const e of entries) { | |
| 127 | const d = num(field(e, "duration")); | |
| 128 | if (d !== null && d > BLOCKING_MS) total += d - BLOCKING_MS; | |
| 129 | } | |
| 130 | return Math.round(total); | |
| 131 | } | |
| 132 | ||
| 133 | /** | |
| 134 | * Grades what a page recorded. A page that recorded no LCP candidate gets a | |
| 135 | * null LCP; no shifts is a CLS of 0 and no long tasks a TBT of 0, both | |
| 136 | * measured, not missing. | |
| 137 | */ | |
| 138 | export function webvitals(input: Entries): WebVitals { | |
| 139 | return { | |
| 140 | lcpMs: vital(lcpMs(list(input?.lcp)), LCP), | |
| 141 | cls: vital(cls(list(input?.layoutShift)), CLS), | |
| 142 | tbtMs: vital(tbtMs(list(input?.longtask)), TBT), | |
| 143 | }; | |
| 144 | } |
test/webvitals.test.ts added +80
| @@ -0,0 +1,80 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { cls, grade, lcpMs, tbtMs, webvitals, CLS, LCP, TBT } from "../src/release/webvitals.ts"; | |
| 3 | ||
| 4 | const shift = (startTime: number, value: number, hadRecentInput = false) => ({ entryType: "layout-shift", startTime, value, hadRecentInput }); | |
| 5 | ||
| 6 | test("LCP is the largest candidate's start time, not the latest entry", () => { | |
| 7 | const entries = [ | |
| 8 | { entryType: "largest-contentful-paint", startTime: 800, size: 1200 }, | |
| 9 | { entryType: "largest-contentful-paint", startTime: 1900.4, size: 50000 }, | |
| 10 | { entryType: "largest-contentful-paint", startTime: 2100, size: 900 }, | |
| 11 | ]; | |
| 12 | expect(lcpMs(entries)).toBe(1900); | |
| 13 | // Without sizes the latest candidate stands. | |
| 14 | expect(lcpMs([{ startTime: 300 }, { startTime: 700 }])).toBe(700); | |
| 15 | expect(lcpMs([])).toBeNull(); | |
| 16 | }); | |
| 17 | ||
| 18 | test("LCP grades at 2.5 s and 4 s, both bounds inclusive", () => { | |
| 19 | expect([2500, 2501, 4000, 4001].map((v) => grade(v, LCP))).toEqual(["good", "needs-improvement", "needs-improvement", "poor"]); | |
| 20 | const v = webvitals({ lcp: [{ startTime: 3200, size: 10 }] }).lcpMs; | |
| 21 | expect(v).toEqual({ value: 3200, grade: "needs-improvement", good: 2500, poor: 4000, unit: "ms", section: LCP.section }); | |
| 22 | expect(v.section).toStartWith("https://specification.website/#"); | |
| 23 | }); | |
| 24 | ||
| 25 | test("CLS ignores shifts that had recent input", () => { | |
| 26 | expect(cls([shift(100, 0.05), shift(200, 0.5, true), shift(300, 0.02)])).toBe(0.07); | |
| 27 | }); | |
| 28 | ||
| 29 | test("CLS is the largest session window; a 1 s gap starts a new window", () => { | |
| 30 | // Window A: 0 ms, 900 ms (gap 900 < 1000) -> 0.04 + 0.04 = 0.08. | |
| 31 | // Window B: 1900 ms (gap exactly 1000, so new), 2500 ms -> 0.03 + 0.03 = 0.06. | |
| 32 | expect(cls([shift(0, 0.04), shift(900, 0.04), shift(1900, 0.03), shift(2500, 0.03)])).toBe(0.08); | |
| 33 | // Order of recording does not matter. | |
| 34 | expect(cls([shift(2500, 0.03), shift(900, 0.04), shift(1900, 0.03), shift(0, 0.04)])).toBe(0.08); | |
| 35 | }); | |
| 36 | ||
| 37 | test("CLS caps a session window at 5 s", () => { | |
| 38 | // Shifts every 900 ms never leave a 1 s gap; the cap splits them at 5 s: | |
| 39 | // 0..4500 ms (6 shifts, 0.3) and 5400..6300 ms (2 shifts, 0.1). | |
| 40 | const shifts = [0, 900, 1800, 2700, 3600, 4500, 5400, 6300].map((t) => shift(t, 0.05)); | |
| 41 | expect(cls(shifts)).toBe(0.3); | |
| 42 | // Uncapped this would have been 0.4. | |
| 43 | expect(cls(shifts.slice(0, 6))).toBe(0.3); | |
| 44 | }); | |
| 45 | ||
| 46 | test("CLS grades at 0.1 and 0.25, and hides float noise at the bound", () => { | |
| 47 | // 0.07 + 0.03 is 0.09999999999999999 in floats; it reports and grades as 0.1. | |
| 48 | const v = webvitals({ layoutShift: [shift(0, 0.07), shift(10, 0.03)] }).cls; | |
| 49 | expect(v.value).toBe(0.1); | |
| 50 | expect(v.grade).toBe("good"); | |
| 51 | expect([0.11, 0.25, 0.26].map((x) => grade(x, CLS))).toEqual(["needs-improvement", "needs-improvement", "poor"]); | |
| 52 | expect(webvitals({}).cls).toMatchObject({ value: 0, grade: "good" }); | |
| 53 | }); | |
| 54 | ||
| 55 | test("TBT sums each long task's time past 50 ms", () => { | |
| 56 | // 120 -> 70, 50 -> 0 (not over), 51 -> 1, 300 -> 250: 321 ms. | |
| 57 | const tasks = [120, 50, 51, 300].map((duration, i) => ({ entryType: "longtask", startTime: i * 1000, duration })); | |
| 58 | expect(tbtMs(tasks)).toBe(321); | |
| 59 | expect(tbtMs([])).toBe(0); | |
| 60 | }); | |
| 61 | ||
| 62 | test("TBT stands in for INP: good at 200 ms, poor past 600 ms", () => { | |
| 63 | expect([200, 201, 600, 601].map((v) => grade(v, TBT))).toEqual(["good", "needs-improvement", "needs-improvement", "poor"]); | |
| 64 | const v = webvitals({ longtask: [{ duration: 250 }] }).tbtMs; | |
| 65 | expect(v).toMatchObject({ value: 200, grade: "good", unit: "ms" }); | |
| 66 | expect(v.section).toContain("interaction-to-next-paint"); | |
| 67 | }); | |
| 68 | ||
| 69 | test("junk at the boundary is dropped, not trusted", () => { | |
| 70 | const junk = [null, 7, "x", { startTime: -5, size: 999 }, { startTime: NaN }, { startTime: "900" }, { startTime: Infinity }]; | |
| 71 | const v = webvitals({ | |
| 72 | lcp: [...junk, { startTime: 1200, size: 1 }], | |
| 73 | layoutShift: [...junk, { startTime: 0, value: -1 }, { startTime: 0, value: "0.9" }, shift(5, 0.02)], | |
| 74 | longtask: [...junk, { duration: -100 }, { duration: "900" }, { duration: 60 }], | |
| 75 | }); | |
| 76 | expect([v.lcpMs.value, v.cls.value, v.tbtMs.value]).toEqual([1200, 0.02, 10]); | |
| 77 | // Not arrays at all. | |
| 78 | const none = webvitals({ lcp: "x", layoutShift: 3, longtask: {} } as never); | |
| 79 | expect([none.lcpMs.value, none.lcpMs.grade, none.cls.value, none.tbtMs.value]).toEqual([null, null, 0, 0]); | |
| 80 | }); |
new above
offline