@uniflowed/web 0.0.0-alpha.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cookie.js +144 -0
- package/head.js +132 -0
- package/index.js +96 -0
- package/media.js +637 -0
- package/package.json +31 -0
- package/regions.js +155 -0
- package/time.js +187 -0
- package/vitals.js +721 -0
package/vitals.js
ADDED
|
@@ -0,0 +1,721 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// `@uniflowed/web/vitals`: the five numbers a page is judged on, and where
|
|
4
|
+
// they go.
|
|
5
|
+
//
|
|
6
|
+
// LCP, CLS, INP, TTFB and FCP are the measurements a real page is held to, and
|
|
7
|
+
// none of them can be taken from outside: they are facts about one reader's
|
|
8
|
+
// browser on one reader's connection, and a synthetic run in a data centre is
|
|
9
|
+
// a different page. The browser already computes all five and hands them over
|
|
10
|
+
// through `PerformanceObserver`. This module reads them, decides when each one
|
|
11
|
+
// is final, and hands each to a callback the application supplied.
|
|
12
|
+
//
|
|
13
|
+
// # Nothing leaves the machine
|
|
14
|
+
//
|
|
15
|
+
// `collectVitals` takes a reporter and calls it. It opens no connection, and
|
|
16
|
+
// importing this module does nothing at all — there is no top-level side
|
|
17
|
+
// effect for a bundler to keep or a page to pay for. Sending the numbers
|
|
18
|
+
// somewhere is `vitalsBeacon`, which the application has to construct, and
|
|
19
|
+
// which posts to a path on the page's own origin. There is no uf endpoint, no
|
|
20
|
+
// third party, and no default destination, because a toolchain that phones
|
|
21
|
+
// home by default is a toolchain nobody can audit once.
|
|
22
|
+
//
|
|
23
|
+
// # Reading the browser rather than shipping a copy of a library
|
|
24
|
+
//
|
|
25
|
+
// `web-vitals` is very good and this is not a fork of it. It is a direct read
|
|
26
|
+
// of the same entry types, which is a different thing to maintain: what it
|
|
27
|
+
// costs is the handful of corrections that make a raw entry stream into a
|
|
28
|
+
// number anybody quotes — CLS is a windowed maximum rather than a sum, INP is
|
|
29
|
+
// a high percentile of interactions rather than the slowest one — and those
|
|
30
|
+
// are implemented here rather than approximated. Where uf still differs from
|
|
31
|
+
// `web-vitals` it is written down in `docs/app/guide/vitals`, because a
|
|
32
|
+
// performance number whose definition is unstated is a number two people will
|
|
33
|
+
// read differently.
|
|
34
|
+
//
|
|
35
|
+
// # The one check that decides whether any of this runs
|
|
36
|
+
//
|
|
37
|
+
// `PerformanceObserver.supportedEntryTypes`, per metric, and never
|
|
38
|
+
// `typeof PerformanceObserver`. Node has had a `PerformanceObserver` for years
|
|
39
|
+
// and it supports `mark`, `measure`, `resource` and a set of things about
|
|
40
|
+
// sockets — none of the five entry types here. So the obvious guard passes on
|
|
41
|
+
// a server and then observes nothing, which is the worst of both: code that
|
|
42
|
+
// runs where it cannot work and reports numbers nobody asked for.
|
|
43
|
+
//
|
|
44
|
+
// Asking the constructor which types it serves answers three questions with
|
|
45
|
+
// one expression. A server has no browser entry types. A browser too old for
|
|
46
|
+
// `layout-shift` has no `layout-shift`. And a test host that borrowed Node's
|
|
47
|
+
// constructor is, correctly, both.
|
|
48
|
+
//
|
|
49
|
+
// # A metric the browser cannot measure is absent, not zero
|
|
50
|
+
//
|
|
51
|
+
// This is the rule the rest of the module is arranged around. A browser
|
|
52
|
+
// without `largest-contentful-paint` does not get an LCP of 0; it gets no LCP.
|
|
53
|
+
// Zero is the best possible score, and a collector that reports it for
|
|
54
|
+
// "unknown" produces a dashboard whose worst pages look like its best ones,
|
|
55
|
+
// which is a mistake that survives for months because it looks like good news.
|
|
56
|
+
//
|
|
57
|
+
// The distinction has a second edge worth being precise about: CLS *is*
|
|
58
|
+
// reported as zero when the browser supports `layout-shift` and no layout
|
|
59
|
+
// shifted, because that is a measurement rather than an absence. INP is not
|
|
60
|
+
// reported at all when nobody interacted, because there is no interaction to
|
|
61
|
+
// have been slow.
|
|
62
|
+
//
|
|
63
|
+
// # What belongs in this module
|
|
64
|
+
//
|
|
65
|
+
// A measurement the browser takes of the whole page, whose value is not known
|
|
66
|
+
// until the page has been used, and which is judged against a published
|
|
67
|
+
// threshold. That is what makes these five one subject rather than five.
|
|
68
|
+
//
|
|
69
|
+
// Not here: a timing an application takes of itself. `performance.mark` and
|
|
70
|
+
// `performance.measure` are the browser's API for that, they need no help, and
|
|
71
|
+
// wrapping them would be a second name for something that already has one.
|
|
72
|
+
// Not here either: the elements that cause these numbers. An image that
|
|
73
|
+
// reserves its space and a font that does not swap late are `media.js`'s, and
|
|
74
|
+
// that is the file to change when a number here is bad.
|
|
75
|
+
|
|
76
|
+
import * as React from "@uniflowed/react";
|
|
77
|
+
|
|
78
|
+
/** The metrics this module reports. */
|
|
79
|
+
export type VitalName = "TTFB" | "FCP" | "LCP" | "CLS" | "INP";
|
|
80
|
+
|
|
81
|
+
/** Where a value falls against the thresholds the metric is published with. */
|
|
82
|
+
export type Rating = "good" | "needs-improvement" | "poor";
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* How the page was reached.
|
|
86
|
+
*
|
|
87
|
+
* Carried on every metric because it changes what the metric means: a reload
|
|
88
|
+
* has a warm cache, a back-forward restore has a warm everything, and a
|
|
89
|
+
* prerender was fetched before anybody asked for it. Averaging the four
|
|
90
|
+
* together is how a fast site measures slow.
|
|
91
|
+
*/
|
|
92
|
+
export type NavigationType = "navigate" | "reload" | "back-forward" | "prerender" | "unknown";
|
|
93
|
+
|
|
94
|
+
/** One measurement, final. */
|
|
95
|
+
export type Vital = {
|
|
96
|
+
readonly name: VitalName,
|
|
97
|
+
/** Milliseconds, except `CLS`, which is a unitless layout-shift score. */
|
|
98
|
+
readonly value: number,
|
|
99
|
+
readonly rating: Rating,
|
|
100
|
+
readonly navigationType: NavigationType,
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/** What a collector hands each finished measurement to. */
|
|
104
|
+
export type VitalsReporter = (vital: Vital) => void;
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The body posted to [`VITALS_ENDPOINT`].
|
|
108
|
+
*
|
|
109
|
+
* A receiver gets more than one of these per page load — see `vitalsBeacon` —
|
|
110
|
+
* so the arrays are additive rather than a complete picture, and `url`
|
|
111
|
+
* identifies the page rather than the report.
|
|
112
|
+
*/
|
|
113
|
+
export type VitalsReport = {
|
|
114
|
+
readonly url: string,
|
|
115
|
+
readonly vitals: $ReadOnlyArray<Vital>,
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/** How to collect. */
|
|
119
|
+
export type CollectOptions = {
|
|
120
|
+
readonly report: VitalsReporter,
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The path `vitalsBeacon` posts to unless the project names another.
|
|
125
|
+
*
|
|
126
|
+
* Under `/__uf/` beside the update stream `@uniflowed/hmr` opens, for the same
|
|
127
|
+
* reason: it is a namespace the dev server owns and an application cannot
|
|
128
|
+
* collide with — a directory in `app/` whose name begins with `_` is not a
|
|
129
|
+
* route, so this path is unreachable by anything a project writes.
|
|
130
|
+
*
|
|
131
|
+
* Which is also why it is only half of the answer. It is where a page being
|
|
132
|
+
* developed reports to, and in production the project passes a path of its own
|
|
133
|
+
* to `vitalsBeacon` and serves it from an ordinary route handler. The contract
|
|
134
|
+
* that makes both work is a path and a [`VitalsReport`] body, rather than an
|
|
135
|
+
* integration with anybody.
|
|
136
|
+
*/
|
|
137
|
+
export const VITALS_ENDPOINT: string = "/__uf/vitals";
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The published boundaries, in the metric's own units.
|
|
141
|
+
*
|
|
142
|
+
* At or below `good` is good; above `poor` is poor; between them is the middle
|
|
143
|
+
* band. Written here rather than left to the reporter because a number without
|
|
144
|
+
* its threshold is not yet an answer, and because two reporters that disagreed
|
|
145
|
+
* about where "good" ends would be a bug nobody could see.
|
|
146
|
+
*/
|
|
147
|
+
const THRESHOLDS: { readonly [VitalName]: { readonly good: number, readonly poor: number } } = {
|
|
148
|
+
TTFB: { good: 800, poor: 1800 },
|
|
149
|
+
FCP: { good: 1800, poor: 3000 },
|
|
150
|
+
LCP: { good: 2500, poor: 4000 },
|
|
151
|
+
CLS: { good: 0.1, poor: 0.25 },
|
|
152
|
+
INP: { good: 200, poor: 500 },
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* How long a run of layout shifts stays one run.
|
|
157
|
+
*
|
|
158
|
+
* A gap of a second ends the run and a run may not exceed five seconds. Both
|
|
159
|
+
* numbers are the metric's definition rather than a tuning choice: CLS is the
|
|
160
|
+
* worst such window, not the total, so that a long-lived page is not punished
|
|
161
|
+
* for the sum of a hundred separate small shifts.
|
|
162
|
+
*/
|
|
163
|
+
const SHIFT_GAP: number = 1000;
|
|
164
|
+
const SHIFT_SPAN: number = 5000;
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The shortest interaction worth an entry.
|
|
168
|
+
*
|
|
169
|
+
* The Event Timing API will report every event if asked, which on a page being
|
|
170
|
+
* scrolled is thousands of them. Forty milliseconds is below the threshold any
|
|
171
|
+
* reader perceives and is the same figure `web-vitals` uses, so the two agree
|
|
172
|
+
* about which interactions exist before they disagree about anything else.
|
|
173
|
+
*/
|
|
174
|
+
const EVENT_THRESHOLD: number = 40;
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* How the slowest interaction is discounted.
|
|
178
|
+
*
|
|
179
|
+
* INP is not the slowest interaction: on a page with hundreds of them, the
|
|
180
|
+
* slowest is a coincidence rather than a description. The metric keeps the ten
|
|
181
|
+
* longest and steps one place down the list for every fifty interactions, so a
|
|
182
|
+
* page has to be reliably slow rather than once unlucky. Below fifty
|
|
183
|
+
* interactions this is exactly the maximum, which is why a short session and a
|
|
184
|
+
* long one can be compared at all.
|
|
185
|
+
*/
|
|
186
|
+
const INTERACTIONS_KEPT: number = 10;
|
|
187
|
+
const INTERACTIONS_PER_STEP: number = 50;
|
|
188
|
+
|
|
189
|
+
/** The part of a performance entry this module reads. */
|
|
190
|
+
type TimingEntry = {
|
|
191
|
+
readonly entryType: string,
|
|
192
|
+
readonly name: string,
|
|
193
|
+
readonly startTime: number,
|
|
194
|
+
readonly duration: number,
|
|
195
|
+
/** `layout-shift`: how much moved. */
|
|
196
|
+
readonly value?: number,
|
|
197
|
+
/** `layout-shift`: whether a reader had just done something. */
|
|
198
|
+
readonly hadRecentInput?: boolean,
|
|
199
|
+
/** `event`: which interaction this event belongs to, or 0 for none. */
|
|
200
|
+
readonly interactionId?: number,
|
|
201
|
+
...
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
/** The part of a navigation entry this module reads. */
|
|
205
|
+
type NavigationEntry = {
|
|
206
|
+
readonly type?: string,
|
|
207
|
+
/** Milliseconds from the start of the document to the first response byte. */
|
|
208
|
+
readonly responseStart?: number,
|
|
209
|
+
/** Non-zero only on a prerendered page: when the reader actually arrived. */
|
|
210
|
+
readonly activationStart?: number,
|
|
211
|
+
...
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
/** What a `PerformanceObserver` callback is handed. */
|
|
215
|
+
type EntryList = { readonly getEntries: () => $ReadOnlyArray<TimingEntry>, ... };
|
|
216
|
+
|
|
217
|
+
/** The part of a live observer this module uses. */
|
|
218
|
+
type ObserverHandle = {
|
|
219
|
+
readonly observe: (options: { readonly [string]: mixed }) => mixed,
|
|
220
|
+
readonly disconnect: () => mixed,
|
|
221
|
+
...
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Anything this module attaches a listener to.
|
|
226
|
+
*
|
|
227
|
+
* The window and the document both, because the two events that mean "this
|
|
228
|
+
* page is over" are not dispatched on the same object.
|
|
229
|
+
*/
|
|
230
|
+
type Listens = {
|
|
231
|
+
readonly addEventListener: (type: string, listener: () => mixed, options?: mixed) => mixed,
|
|
232
|
+
readonly removeEventListener: (type: string, listener: () => mixed, options?: mixed) => mixed,
|
|
233
|
+
...
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* The part of a `window` this module reads.
|
|
238
|
+
*
|
|
239
|
+
* The same idea as `@uniflowed/hooks`' `BrowserWindow`, and deliberately a
|
|
240
|
+
* second and much smaller copy: importing that package here would put every
|
|
241
|
+
* hook in it into the graph of any page that measures itself. Naming what is
|
|
242
|
+
* touched is what lets everything below `browserWindow()` be checked.
|
|
243
|
+
*
|
|
244
|
+
* `PerformanceObserver` is typed as the object it is read as rather than as a
|
|
245
|
+
* constructor, because Flow has no way to write "a property that is a class"
|
|
246
|
+
* for something arriving off an untyped global. `observeEntries` is where that
|
|
247
|
+
* is resolved, once.
|
|
248
|
+
*/
|
|
249
|
+
type BrowserWindow = {
|
|
250
|
+
readonly document: {
|
|
251
|
+
readonly visibilityState: string,
|
|
252
|
+
readonly addEventListener: (type: string, listener: () => mixed, options?: mixed) => mixed,
|
|
253
|
+
readonly removeEventListener: (type: string, listener: () => mixed, options?: mixed) => mixed,
|
|
254
|
+
...
|
|
255
|
+
},
|
|
256
|
+
readonly location?: ?{ readonly href: string, ... },
|
|
257
|
+
readonly performance?: ?{
|
|
258
|
+
readonly getEntriesByType?: (type: string) => $ReadOnlyArray<NavigationEntry>,
|
|
259
|
+
/** Chromium only: every interaction, including the ones too fast to observe. */
|
|
260
|
+
readonly interactionCount?: number,
|
|
261
|
+
...
|
|
262
|
+
},
|
|
263
|
+
readonly navigator?: ?{
|
|
264
|
+
readonly sendBeacon?: (url: string, body: string) => boolean,
|
|
265
|
+
...
|
|
266
|
+
},
|
|
267
|
+
readonly fetch?: ?(url: string, init: { readonly [string]: mixed }) => Promise<mixed>,
|
|
268
|
+
readonly PerformanceObserver?: ?{
|
|
269
|
+
readonly supportedEntryTypes?: $ReadOnlyArray<string>,
|
|
270
|
+
...
|
|
271
|
+
},
|
|
272
|
+
readonly addEventListener: (type: string, listener: () => mixed, options?: mixed) => mixed,
|
|
273
|
+
readonly removeEventListener: (type: string, listener: () => mixed, options?: mixed) => mixed,
|
|
274
|
+
...
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
/** A stop function for a collector that never started. */
|
|
278
|
+
function noop(): void {}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* The window this module measures, or `null` where there is no browser.
|
|
282
|
+
*
|
|
283
|
+
* In a browser `globalThis` *is* the window. It is not anywhere a document has
|
|
284
|
+
* been installed onto another host's global, which is every uf test process,
|
|
285
|
+
* so asking the document's own window is what makes both cases work. The
|
|
286
|
+
* single `?? globalThis` is this module's only unchecked step: `window` is
|
|
287
|
+
* `any` in Flow's library definition, and this is where that stops.
|
|
288
|
+
*/
|
|
289
|
+
function browserWindow(): BrowserWindow | null {
|
|
290
|
+
if (typeof globalThis.document === "undefined") {
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
return globalThis.window ?? globalThis;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Whether the browser will actually serve entries of this type. */
|
|
297
|
+
function supports(win: BrowserWindow, type: string): boolean {
|
|
298
|
+
const types = win.PerformanceObserver?.supportedEntryTypes;
|
|
299
|
+
return types != null && types.includes(type);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Start one observer, or return `null` where the browser cannot serve it.
|
|
304
|
+
*
|
|
305
|
+
* `buffered: true` on every one of them is what makes a late start correct:
|
|
306
|
+
* the browser replays the entries it recorded before this call, so a collector
|
|
307
|
+
* mounted by a React root that has already painted still sees its own first
|
|
308
|
+
* paint. Without it, every metric would depend on how early the application
|
|
309
|
+
* happened to run this, which is a measurement of the bundler.
|
|
310
|
+
*
|
|
311
|
+
* The `new` here is the module's one cast. Flow cannot express "an object
|
|
312
|
+
* property that is a constructor" for a value read off an untyped global, and
|
|
313
|
+
* doing it in one function rather than at each call site keeps everything the
|
|
314
|
+
* observers hand back checked.
|
|
315
|
+
*/
|
|
316
|
+
function observeEntries(
|
|
317
|
+
win: BrowserWindow,
|
|
318
|
+
type: string,
|
|
319
|
+
onEntries: (entries: $ReadOnlyArray<TimingEntry>) => void,
|
|
320
|
+
extra?: { readonly [string]: mixed },
|
|
321
|
+
): ObserverHandle | null {
|
|
322
|
+
const Observer = win.PerformanceObserver;
|
|
323
|
+
if (Observer == null || !supports(win, type)) {
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
const observer: ObserverHandle = new (Observer as $FlowFixMe)((list: EntryList) => {
|
|
327
|
+
onEntries(list.getEntries());
|
|
328
|
+
});
|
|
329
|
+
try {
|
|
330
|
+
observer.observe({ ...extra, type, buffered: true });
|
|
331
|
+
} catch {
|
|
332
|
+
// `supportedEntryTypes` said yes and `observe` disagreed, which happens
|
|
333
|
+
// for a type the browser knows the name of and will not serve in this
|
|
334
|
+
// context. Reporting nothing is the same answer as not supporting it, and
|
|
335
|
+
// an exception thrown out of a collector would take the application's
|
|
336
|
+
// startup with it.
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
339
|
+
return observer;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* The document's navigation entry, or `null`.
|
|
344
|
+
*
|
|
345
|
+
* Read through `getEntriesByType` rather than observed, because the entry
|
|
346
|
+
* exists from the moment the document does and an observer would put it behind
|
|
347
|
+
* a callback that has to be ordered against the others. It is also a *live*
|
|
348
|
+
* object: the browser fills its fields in as the navigation proceeds, so
|
|
349
|
+
* holding it and reading a field later is not the same as reading that field
|
|
350
|
+
* now. Both `responseStart` and `activationStart` depend on that.
|
|
351
|
+
*/
|
|
352
|
+
function navigationEntry(win: BrowserWindow): NavigationEntry | null {
|
|
353
|
+
const entries = win.performance?.getEntriesByType?.("navigation");
|
|
354
|
+
return entries == null || entries.length === 0 ? null : entries[0];
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** What the navigation entry calls it, in this module's spelling. */
|
|
358
|
+
function navigationTypeOf(entry: NavigationEntry | null): NavigationType {
|
|
359
|
+
switch (entry?.type) {
|
|
360
|
+
case "navigate":
|
|
361
|
+
return "navigate";
|
|
362
|
+
case "reload":
|
|
363
|
+
return "reload";
|
|
364
|
+
case "back_forward":
|
|
365
|
+
return "back-forward";
|
|
366
|
+
case "prerender":
|
|
367
|
+
return "prerender";
|
|
368
|
+
default:
|
|
369
|
+
return "unknown";
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Where `value` falls against the metric's published thresholds. */
|
|
374
|
+
function rate(name: VitalName, value: number): Rating {
|
|
375
|
+
const threshold = THRESHOLDS[name];
|
|
376
|
+
if (value <= threshold.good) {
|
|
377
|
+
return "good";
|
|
378
|
+
}
|
|
379
|
+
if (value <= threshold.poor) {
|
|
380
|
+
return "needs-improvement";
|
|
381
|
+
}
|
|
382
|
+
return "poor";
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** Longest first. */
|
|
386
|
+
function longestFirst(a: number, b: number): number {
|
|
387
|
+
return b - a;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Measure this page and report each metric once, as it becomes final.
|
|
392
|
+
*
|
|
393
|
+
* Returns a function that stops collecting. Calling it does not report
|
|
394
|
+
* anything: stopping is not the page ending, and a collector that flushed on
|
|
395
|
+
* teardown would report a half-measured page every time a React root remounted.
|
|
396
|
+
*
|
|
397
|
+
* That is also what makes this safe under `StrictMode`, where an effect is set
|
|
398
|
+
* up, torn down and set up again. Nothing here reports from the call itself:
|
|
399
|
+
* observer callbacks are delivered in a later task, the page-visibility
|
|
400
|
+
* listeners fire later still, and the one value that *could* be read at once —
|
|
401
|
+
* TTFB, off the navigation entry — is deferred by a microtask for exactly this
|
|
402
|
+
* reason. So the first collector is stopped before any of them runs, reports
|
|
403
|
+
* nothing at all, and the second collector, replaying the same buffered
|
|
404
|
+
* entries, reports each metric exactly once. `uf dev` mounts every application
|
|
405
|
+
* under Strict Mode since ubugeeei-prod/uf#516, so this is the ordinary case
|
|
406
|
+
* rather than a corner of it.
|
|
407
|
+
*
|
|
408
|
+
* On a server this observes nothing and returns immediately, so a component
|
|
409
|
+
* that renders in both places can call it unconditionally.
|
|
410
|
+
*/
|
|
411
|
+
export function collectVitals(options: CollectOptions): () => void {
|
|
412
|
+
const win = browserWindow();
|
|
413
|
+
if (win == null) {
|
|
414
|
+
return noop;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const report = options.report;
|
|
418
|
+
const teardown: Array<() => void> = [];
|
|
419
|
+
const reported: Set<VitalName> = new Set();
|
|
420
|
+
const navigation = navigationEntry(win);
|
|
421
|
+
const navigationType = navigationTypeOf(navigation);
|
|
422
|
+
let stopped = false;
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* A prerendered page starts its clock before anybody asked for it, so every
|
|
426
|
+
* timestamp on it is that far ahead of what the reader experienced. Applied
|
|
427
|
+
* when the metric is reported rather than when the entry arrives, because
|
|
428
|
+
* `activationStart` is zero until the page is activated and the entry may
|
|
429
|
+
* well have arrived before that.
|
|
430
|
+
*/
|
|
431
|
+
const sinceArrival = (time: number): number =>
|
|
432
|
+
Math.max(time - (navigation?.activationStart ?? 0), 0);
|
|
433
|
+
|
|
434
|
+
const emit = (name: VitalName, value: number): void => {
|
|
435
|
+
if (stopped || reported.has(name)) {
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
reported.add(name);
|
|
439
|
+
report({ name, value, rating: rate(name, value), navigationType });
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
// Attempted twice: once shortly after this call, and once when the page is
|
|
443
|
+
// put away. The navigation entry exists from the start and its timings can
|
|
444
|
+
// still be zero when it is first read, so a collector that only looked at the
|
|
445
|
+
// beginning would report nothing on a browser that fills the entry in later —
|
|
446
|
+
// and one that only looked at the end would report nothing for a page nobody
|
|
447
|
+
// ever leaves.
|
|
448
|
+
const tryTtfb = (): void => {
|
|
449
|
+
const responseStart = navigation?.responseStart ?? 0;
|
|
450
|
+
if (responseStart > 0) {
|
|
451
|
+
emit("TTFB", sinceArrival(responseStart));
|
|
452
|
+
}
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
const paint = observeEntries(win, "paint", (entries) => {
|
|
456
|
+
for (const entry of entries) {
|
|
457
|
+
if (entry.name === "first-contentful-paint") {
|
|
458
|
+
emit("FCP", sinceArrival(entry.startTime));
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
// The raw timestamp rather than the corrected one, for the same reason
|
|
464
|
+
// `sinceArrival` is applied late.
|
|
465
|
+
let lcpAt = -1;
|
|
466
|
+
const lcp = observeEntries(win, "largest-contentful-paint", (entries) => {
|
|
467
|
+
for (const entry of entries) {
|
|
468
|
+
lcpAt = entry.startTime;
|
|
469
|
+
}
|
|
470
|
+
});
|
|
471
|
+
const finishLcp = (): void => {
|
|
472
|
+
if (lcpAt >= 0) {
|
|
473
|
+
emit("LCP", sinceArrival(lcpAt));
|
|
474
|
+
}
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
let shifted = 0;
|
|
478
|
+
let worstRun = 0;
|
|
479
|
+
let runStart = 0;
|
|
480
|
+
let runLast = 0;
|
|
481
|
+
const cls = observeEntries(win, "layout-shift", (entries) => {
|
|
482
|
+
for (const entry of entries) {
|
|
483
|
+
// A shift the reader caused by clicking something is not a shift the
|
|
484
|
+
// reader suffered, and the browser is the only thing that knows which
|
|
485
|
+
// is which.
|
|
486
|
+
if (entry.hadRecentInput === true) {
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
const value = entry.value ?? 0;
|
|
490
|
+
const continues =
|
|
491
|
+
shifted !== 0 &&
|
|
492
|
+
entry.startTime - runLast < SHIFT_GAP &&
|
|
493
|
+
entry.startTime - runStart < SHIFT_SPAN;
|
|
494
|
+
if (continues) {
|
|
495
|
+
shifted += value;
|
|
496
|
+
} else {
|
|
497
|
+
shifted = value;
|
|
498
|
+
runStart = entry.startTime;
|
|
499
|
+
}
|
|
500
|
+
runLast = entry.startTime;
|
|
501
|
+
if (shifted > worstRun) {
|
|
502
|
+
worstRun = shifted;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
// Keyed by interaction rather than by event: one tap is a `pointerdown`, a
|
|
508
|
+
// `pointerup` and a `click`, and the interaction's latency is the longest of
|
|
509
|
+
// them rather than their sum.
|
|
510
|
+
const interactions: Map<number, number> = new Map();
|
|
511
|
+
const inp = observeEntries(
|
|
512
|
+
win,
|
|
513
|
+
"event",
|
|
514
|
+
(entries) => {
|
|
515
|
+
for (const entry of entries) {
|
|
516
|
+
const id = entry.interactionId ?? 0;
|
|
517
|
+
if (id === 0) {
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
if (entry.duration > (interactions.get(id) ?? 0)) {
|
|
521
|
+
interactions.set(id, entry.duration);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
},
|
|
525
|
+
{ durationThreshold: EVENT_THRESHOLD },
|
|
526
|
+
);
|
|
527
|
+
const finishInp = (): void => {
|
|
528
|
+
if (interactions.size === 0) {
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
const longest = [...interactions.values()].sort(longestFirst).slice(0, INTERACTIONS_KEPT);
|
|
532
|
+
// `interactionCount` includes the interactions that were too fast to be
|
|
533
|
+
// observed at all, which is what makes this a percentile of a page rather
|
|
534
|
+
// than of a sample. Where the browser does not have it, the observed
|
|
535
|
+
// count is the best available and biases the answer towards the slowest
|
|
536
|
+
// interaction — which is the honest direction to be wrong in.
|
|
537
|
+
const count = win.performance?.interactionCount ?? interactions.size;
|
|
538
|
+
const step = Math.floor(count / INTERACTIONS_PER_STEP);
|
|
539
|
+
emit("INP", longest[Math.min(longest.length - 1, step)]);
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
const finish = (): void => {
|
|
543
|
+
tryTtfb();
|
|
544
|
+
finishLcp();
|
|
545
|
+
// Zero rather than nothing, and only when the browser served the entry
|
|
546
|
+
// type: a page whose layout never moved has a CLS, and it is zero.
|
|
547
|
+
if (cls != null) {
|
|
548
|
+
emit("CLS", worstRun);
|
|
549
|
+
}
|
|
550
|
+
finishInp();
|
|
551
|
+
};
|
|
552
|
+
|
|
553
|
+
const onVisibilityChange = (): void => {
|
|
554
|
+
if (win.document.visibilityState === "hidden") {
|
|
555
|
+
finish();
|
|
556
|
+
}
|
|
557
|
+
};
|
|
558
|
+
|
|
559
|
+
const listen = (target: Listens, type: string, listener: () => mixed, options?: mixed): void => {
|
|
560
|
+
target.addEventListener(type, listener, options);
|
|
561
|
+
teardown.push(() => {
|
|
562
|
+
target.removeEventListener(type, listener, options);
|
|
563
|
+
});
|
|
564
|
+
};
|
|
565
|
+
|
|
566
|
+
// Both, because they are not the same event in every browser: a tab switched
|
|
567
|
+
// away fires `visibilitychange` and never unloads, and a page replaced by a
|
|
568
|
+
// navigation has historically been the one that only fires `pagehide`.
|
|
569
|
+
listen(win.document, "visibilitychange", onVisibilityChange);
|
|
570
|
+
listen(win, "pagehide", finish);
|
|
571
|
+
|
|
572
|
+
// The browser stops nominating largest-contentful-paint candidates once the
|
|
573
|
+
// reader has interacted, so the value is final at that moment rather than at
|
|
574
|
+
// the end of the page. Reporting it then is what makes LCP visible to
|
|
575
|
+
// somebody watching their own page instead of only to whoever reads the logs.
|
|
576
|
+
listen(win, "keydown", finishLcp, { once: true, capture: true });
|
|
577
|
+
listen(win, "click", finishLcp, { once: true, capture: true });
|
|
578
|
+
|
|
579
|
+
for (const observer of [paint, lcp, cls, inp]) {
|
|
580
|
+
if (observer != null) {
|
|
581
|
+
teardown.push(() => {
|
|
582
|
+
observer.disconnect();
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// The first attempt, one microtask away rather than here, and that deferral
|
|
588
|
+
// is what makes the Strict Mode paragraph in this function's header true
|
|
589
|
+
// rather than nearly true.
|
|
590
|
+
//
|
|
591
|
+
// Every other value this collector produces arrives through an observer
|
|
592
|
+
// callback or a page-visibility event, and both are delivered in a later
|
|
593
|
+
// task — so a collector that is set up and torn down inside one React commit
|
|
594
|
+
// reports none of them. This call was the exception: it read the navigation
|
|
595
|
+
// entry synchronously, before anything could stop the collector, so the
|
|
596
|
+
// discarded first collector of a Strict Mode double-mount reported TTFB and
|
|
597
|
+
// the surviving second one reported it again. One page load, two TTFBs, and
|
|
598
|
+
// with #602's `/__uf/vitals` two web-vitals lines in the terminal — which is
|
|
599
|
+
// the report a reader would trust least and check first. See
|
|
600
|
+
// ubugeeei-prod/uf#516.
|
|
601
|
+
//
|
|
602
|
+
// A microtask rather than a timer: it is the shortest delay that is still
|
|
603
|
+
// after the synchronous work React does in a commit, and it needs no handle
|
|
604
|
+
// to cancel — `emit` already refuses to report from a stopped collector, so
|
|
605
|
+
// a microtask that runs after the teardown does nothing.
|
|
606
|
+
queueMicrotask(tryTtfb);
|
|
607
|
+
|
|
608
|
+
return () => {
|
|
609
|
+
if (stopped) {
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
stopped = true;
|
|
613
|
+
for (const off of teardown) {
|
|
614
|
+
off();
|
|
615
|
+
}
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Measure this page for as long as the component is mounted.
|
|
621
|
+
*
|
|
622
|
+
* Belongs in a root layout, which is late — the page has usually painted by
|
|
623
|
+
* the time React commits. That is correct rather than tolerated: every
|
|
624
|
+
* observer asks for buffered entries, so the browser replays what it recorded
|
|
625
|
+
* before this ran, and the first paint of a server-rendered page is measured
|
|
626
|
+
* by a component that did not exist when it happened.
|
|
627
|
+
*
|
|
628
|
+
* `report` is read through a ref so that an inline arrow does not tear the
|
|
629
|
+
* collector down and build a new one on every render. Restarting is not
|
|
630
|
+
* harmless: the replayed entries would be reported a second time.
|
|
631
|
+
*/
|
|
632
|
+
export function useVitals(report: VitalsReporter): void {
|
|
633
|
+
const latest = React.useRef<VitalsReporter>(report);
|
|
634
|
+
|
|
635
|
+
React.useEffect(() => {
|
|
636
|
+
latest.current = report;
|
|
637
|
+
}, [report]);
|
|
638
|
+
|
|
639
|
+
React.useEffect(() => {
|
|
640
|
+
return collectVitals({
|
|
641
|
+
report: (vital) => {
|
|
642
|
+
latest.current(vital);
|
|
643
|
+
},
|
|
644
|
+
});
|
|
645
|
+
}, []);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* A reporter that posts to `endpoint` on the page's own origin.
|
|
650
|
+
*
|
|
651
|
+
* Defaults to [`VITALS_ENDPOINT`]. Nothing is sent until a metric is final, no
|
|
652
|
+
* connection is opened before that, and the destination is a path rather than
|
|
653
|
+
* a URL, so this cannot be pointed at somebody else's server by accident.
|
|
654
|
+
*
|
|
655
|
+
* # Why more than one request
|
|
656
|
+
*
|
|
657
|
+
* Metrics become final at different moments — TTFB and FCP early, LCP, CLS and
|
|
658
|
+
* INP when the page is put away — and the sends are coalesced across a
|
|
659
|
+
* microtask rather than held until the end. So a receiver normally sees two
|
|
660
|
+
* reports and must treat them as additive.
|
|
661
|
+
*
|
|
662
|
+
* Holding everything until the page is hidden would have been one request, and
|
|
663
|
+
* it would have made the whole thing depend on which listener the browser
|
|
664
|
+
* called first: the collector's, which produces the last three metrics, or
|
|
665
|
+
* this one's, which sends them. That ordering is not something either side can
|
|
666
|
+
* state, so it is not something the design relies on. It also costs nothing to
|
|
667
|
+
* give up — a receiver that prints a number as soon as it is known is more
|
|
668
|
+
* useful than one that waits for a tab to close.
|
|
669
|
+
*/
|
|
670
|
+
export function vitalsBeacon(endpoint?: string): VitalsReporter {
|
|
671
|
+
const win = browserWindow();
|
|
672
|
+
if (win == null) {
|
|
673
|
+
return noop;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
const target = endpoint ?? VITALS_ENDPOINT;
|
|
677
|
+
const pending: Array<Vital> = [];
|
|
678
|
+
let scheduled = false;
|
|
679
|
+
|
|
680
|
+
const flush = (): void => {
|
|
681
|
+
scheduled = false;
|
|
682
|
+
if (pending.length === 0) {
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
const body = JSON.stringify({ url: win.location?.href ?? "", vitals: pending });
|
|
686
|
+
pending.length = 0;
|
|
687
|
+
|
|
688
|
+
const navigator = win.navigator;
|
|
689
|
+
const sendBeacon = navigator?.sendBeacon;
|
|
690
|
+
// `sendBeacon` is the only transport a browser promises to finish after
|
|
691
|
+
// the page is gone, so it is tried first and its return value is read: it
|
|
692
|
+
// answers `false` when the payload will not fit in the queue, and a
|
|
693
|
+
// dropped report that looked sent is worse than one that was never sent.
|
|
694
|
+
if (navigator != null && sendBeacon != null && sendBeacon.call(navigator, target, body)) {
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
const post = win.fetch;
|
|
698
|
+
if (post != null) {
|
|
699
|
+
// A page being put away can lose the network mid-request, and a rejected
|
|
700
|
+
// promise nobody is holding becomes an unhandled rejection — which
|
|
701
|
+
// arrives in whatever error reporter the application installed, from a
|
|
702
|
+
// line about telemetry. Losing the report is the right outcome here;
|
|
703
|
+
// reporting the loss as an application error is not.
|
|
704
|
+
post(target, {
|
|
705
|
+
method: "POST",
|
|
706
|
+
body,
|
|
707
|
+
keepalive: true,
|
|
708
|
+
headers: { "content-type": "application/json" },
|
|
709
|
+
}).catch(noop);
|
|
710
|
+
}
|
|
711
|
+
};
|
|
712
|
+
|
|
713
|
+
return (vital: Vital): void => {
|
|
714
|
+
pending.push(vital);
|
|
715
|
+
if (scheduled) {
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
scheduled = true;
|
|
719
|
+
queueMicrotask(flush);
|
|
720
|
+
};
|
|
721
|
+
}
|