@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/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
+ }