@atlaskit/editor-plugin-interactivity 0.1.0 → 0.2.0

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.
Files changed (51) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +34 -6
  3. package/afm-cc/tsconfig.json +12 -0
  4. package/afm-products/tsconfig.json +12 -0
  5. package/dist/cjs/analytics/fire-interactivity-event.js +39 -0
  6. package/dist/cjs/analytics/interactivity-snapshot.js +1 -0
  7. package/dist/cjs/collector/bucket-boundaries.js +137 -0
  8. package/dist/cjs/collector/interaction-group.js +167 -0
  9. package/dist/cjs/collector/interaction-observer.js +114 -0
  10. package/dist/cjs/collector/interaction-tracker.js +144 -0
  11. package/dist/cjs/collector/interactivity-collector.js +289 -0
  12. package/dist/cjs/collector/interactivity-session.js +52 -0
  13. package/dist/cjs/collector/lifecycle-observer.js +66 -0
  14. package/dist/cjs/collector/snapshot-scheduler.js +70 -0
  15. package/dist/cjs/interactivityPlugin.js +81 -6
  16. package/dist/es2019/analytics/fire-interactivity-event.js +33 -0
  17. package/dist/es2019/analytics/interactivity-snapshot.js +0 -0
  18. package/dist/es2019/collector/bucket-boundaries.js +117 -0
  19. package/dist/es2019/collector/interaction-group.js +110 -0
  20. package/dist/es2019/collector/interaction-observer.js +90 -0
  21. package/dist/es2019/collector/interaction-tracker.js +116 -0
  22. package/dist/es2019/collector/interactivity-collector.js +228 -0
  23. package/dist/es2019/collector/interactivity-session.js +42 -0
  24. package/dist/es2019/collector/lifecycle-observer.js +46 -0
  25. package/dist/es2019/collector/snapshot-scheduler.js +48 -0
  26. package/dist/es2019/interactivityPlugin.js +75 -6
  27. package/dist/esm/analytics/fire-interactivity-event.js +33 -0
  28. package/dist/esm/analytics/interactivity-snapshot.js +0 -0
  29. package/dist/esm/collector/bucket-boundaries.js +130 -0
  30. package/dist/esm/collector/interaction-group.js +161 -0
  31. package/dist/esm/collector/interaction-observer.js +108 -0
  32. package/dist/esm/collector/interaction-tracker.js +137 -0
  33. package/dist/esm/collector/interactivity-collector.js +282 -0
  34. package/dist/esm/collector/interactivity-session.js +45 -0
  35. package/dist/esm/collector/lifecycle-observer.js +59 -0
  36. package/dist/esm/collector/snapshot-scheduler.js +63 -0
  37. package/dist/esm/interactivityPlugin.js +81 -6
  38. package/dist/types/analytics/fire-interactivity-event.d.ts +9 -0
  39. package/dist/types/analytics/interactivity-snapshot.d.ts +61 -0
  40. package/dist/types/collector/bucket-boundaries.d.ts +35 -0
  41. package/dist/types/collector/interaction-group.d.ts +25 -0
  42. package/dist/types/collector/interaction-observer.d.ts +39 -0
  43. package/dist/types/collector/interaction-tracker.d.ts +56 -0
  44. package/dist/types/collector/interactivity-collector.d.ts +75 -0
  45. package/dist/types/collector/interactivity-session.d.ts +39 -0
  46. package/dist/types/collector/lifecycle-observer.d.ts +25 -0
  47. package/dist/types/collector/snapshot-scheduler.d.ts +15 -0
  48. package/dist/types/interactivityPlugin.d.ts +6 -4
  49. package/dist/types/interactivityPluginType.d.ts +11 -2
  50. package/docs/0-intro.tsx +25 -7
  51. package/package.json +8 -3
@@ -4,15 +4,90 @@ Object.defineProperty(exports, "__esModule", {
4
4
  value: true
5
5
  });
6
6
  exports.interactivityPlugin = void 0;
7
+ var _react = require("react");
8
+ var _monitoring = require("@atlaskit/editor-common/monitoring");
9
+ var _fireInteractivityEvent = require("./analytics/fire-interactivity-event");
10
+ var _interactivityCollector = require("./collector/interactivity-collector");
7
11
  /**
8
- * Reports interaction latency distributions for full page editor sessions as the
9
- * `editor interactivity` operational event.
12
+ * Reports session-to-date interaction latency distributions for full page editor sessions
13
+ * as the `editor interactivity` operational event.
10
14
  *
11
- * A stub for now: the plugin only registers itself. The collector and the event land in
12
- * EDITOR-8553.
15
+ * The session runs for as long as the plugin's hook stays mounted, which is one editor
16
+ * mount: `MountPluginHooks` in editor-core keys hook fibers by plugin name, so a preset
17
+ * reconfigure — which destroys and recreates ProseMirror plugin views — leaves this hook,
18
+ * and the session, in place.
13
19
  */
14
- var interactivityPlugin = exports.interactivityPlugin = function interactivityPlugin() {
20
+ var interactivityPlugin = exports.interactivityPlugin = function interactivityPlugin(_ref) {
21
+ var api = _ref.api;
15
22
  return {
16
- name: 'interactivity'
23
+ name: 'interactivity',
24
+ usePluginHook: function usePluginHook(_ref2) {
25
+ var editorView = _ref2.editorView;
26
+ (0, _react.useEffect)(function () {
27
+ // Nothing measured here is worth an editor. This effect runs inside the plugin slot's
28
+ // error boundary, which also renders every plugin's content components, so a throw
29
+ // from instrumentation would take that UI down with it.
30
+ try {
31
+ var _api$contextIdentifie2, _api$editorViewMode2;
32
+ var collector = new _interactivityCollector.InteractivityCollector({
33
+ emit: function emit(snapshot) {
34
+ var _api$analytics;
35
+ return (0, _fireInteractivityEvent.fireInteractivityEvent)(api === null || api === void 0 || (_api$analytics = api.analytics) === null || _api$analytics === void 0 ? void 0 : _api$analytics.actions, snapshot);
36
+ },
37
+ getObjectId: function getObjectId() {
38
+ var _api$contextIdentifie;
39
+ return api === null || api === void 0 || (_api$contextIdentifie = api.contextIdentifier) === null || _api$contextIdentifie === void 0 || (_api$contextIdentifie = _api$contextIdentifie.sharedState.currentState()) === null || _api$contextIdentifie === void 0 || (_api$contextIdentifie = _api$contextIdentifie.contextIdentifierProvider) === null || _api$contextIdentifie === void 0 ? void 0 : _api$contextIdentifie.objectId;
40
+ },
41
+ getSessionMode: function getSessionMode() {
42
+ var _api$editorViewMode;
43
+ var mode = api === null || api === void 0 || (_api$editorViewMode = api.editorViewMode) === null || _api$editorViewMode === void 0 || (_api$editorViewMode = _api$editorViewMode.sharedState.currentState()) === null || _api$editorViewMode === void 0 ? void 0 : _api$editorViewMode.mode;
44
+ if (mode === undefined) {
45
+ return undefined;
46
+ }
47
+ return mode === 'view' ? 'reading' : 'editing';
48
+ },
49
+ // A destroyed view still answers, with the document it was destroyed with, so
50
+ // both report nothing rather than a size the editor no longer has.
51
+ getNodeSize: function getNodeSize() {
52
+ return editorView.isDestroyed ? undefined : editorView.state.doc.nodeSize;
53
+ },
54
+ // `getElementsByTagName` counts descendants in the browser engine, so this
55
+ // cannot overflow the stack on very large documents.
56
+ getEditorDomSize: function getEditorDomSize() {
57
+ return editorView.isDestroyed ? undefined : editorView.dom.getElementsByTagName('*').length;
58
+ }
59
+ });
60
+ if (!collector.start()) {
61
+ return;
62
+ }
63
+
64
+ // Confluence live pages navigate and switch between reading and editing without
65
+ // remounting the editor. A session covers one document in one mode, so either
66
+ // change ends it and starts the next.
67
+ var unsubscribeFromObjectId = api === null || api === void 0 || (_api$contextIdentifie2 = api.contextIdentifier) === null || _api$contextIdentifie2 === void 0 ? void 0 : _api$contextIdentifie2.sharedState.onChange(function () {
68
+ return collector.onObjectIdChanged();
69
+ });
70
+ var unsubscribeFromViewMode = api === null || api === void 0 || (_api$editorViewMode2 = api.editorViewMode) === null || _api$editorViewMode2 === void 0 ? void 0 : _api$editorViewMode2.sharedState.onChange(function () {
71
+ return collector.onViewModeChanged();
72
+ });
73
+ return function () {
74
+ unsubscribeFromObjectId === null || unsubscribeFromObjectId === void 0 || unsubscribeFromObjectId();
75
+ unsubscribeFromViewMode === null || unsubscribeFromViewMode === void 0 || unsubscribeFromViewMode();
76
+ collector.stop();
77
+ };
78
+ } catch (error) {
79
+ // Instrumentation must not fail the editor around it, but a failure that hits every
80
+ // browser of one engine would be invisible without this.
81
+ void (0, _monitoring.logException)(error, {
82
+ location: 'editor-plugin-interactivity/start'
83
+ });
84
+ return;
85
+ }
86
+ // `api` is deliberately not a dependency: a preset reconfigure hands out a new
87
+ // proxy object, but the one captured here keeps resolving plugins from the same
88
+ // live registry, and restarting the session on a reconfigure would split one
89
+ // editor session in two.
90
+ }, [editorView]);
91
+ }
17
92
  };
18
93
  };
@@ -0,0 +1,33 @@
1
+ import { ACTION_SUBJECT, EVENT_TYPE } from '@atlaskit/editor-common/analytics/types/enums';
2
+ /** The reasons that mean the page may not be around long enough to deliver a queued event. */
3
+ const REASONS_THE_PAGE_MAY_NOT_OUTLIVE = ['hidden', 'pagehide', 'unmount'];
4
+
5
+ /**
6
+ * The `editor interactivity` operational event.
7
+ *
8
+ * `editorSessionId` arrives without being set here: `@atlaskit/analytics-listeners` merges
9
+ * the editor analytics context into the attributes of every event on the editor channel.
10
+ * `objectId` comes both from that context and from the snapshot, which carries the value
11
+ * the session was collected against.
12
+ */
13
+
14
+ /**
15
+ * Sends a snapshot as the `editor interactivity` event.
16
+ *
17
+ * Does nothing without the analytics plugin: an editor without it has nowhere to send events,
18
+ * and the collector keeps measuring either way.
19
+ */
20
+ export function fireInteractivityEvent(analytics, snapshot) {
21
+ analytics === null || analytics === void 0 ? void 0 : analytics.fireAnalyticsEvent({
22
+ action: 'interactivity',
23
+ actionSubject: ACTION_SUBJECT.EDITOR,
24
+ eventType: EVENT_TYPE.OPERATIONAL,
25
+ attributes: snapshot
26
+ }, undefined,
27
+ // Events are queued into an idle callback by default, which a page being unloaded never
28
+ // runs and a backgrounded tab throttles. The snapshots taken because the page is going
29
+ // away go immediately; the rest can wait their turn.
30
+ {
31
+ immediate: REASONS_THE_PAGE_MAY_NOT_OUTLIVE.includes(snapshot.reason)
32
+ });
33
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Bucket boundaries for the interaction latency buckets, version 1.
3
+ *
4
+ * Two ranges:
5
+ * - 16 ms to 200 ms — a boundary every 8 ms: 16, 24, 32, … 200. Event Timing rounds
6
+ * durations to 8 ms, so nothing finer is measurable.
7
+ * - above 200 ms — each boundary ~15% above the previous one, five of them per doubling:
8
+ * 222, 256, 294, 337, 388, 445, 512, … A 40 ms difference matters at 100 ms and is
9
+ * noise at 4 seconds, so buckets grow with the latency instead of staying 8 ms wide.
10
+ *
11
+ * 500 ms — the Google INP "poor" threshold — falls inside the 445…512 bucket, so that one
12
+ * bucket is split at 500 to count the threshold instead of interpolating it. 200 ms, the
13
+ * "good" threshold, is already a boundary.
14
+ *
15
+ * Bump SCHEMA_VERSION whenever any boundary moves; queries group by it.
16
+ */
17
+ export const SCHEMA_VERSION = 1;
18
+
19
+ /**
20
+ * Event Timing reporting threshold. Faster interactions are never delivered to the
21
+ * observer, so they reach no bucket at all — `performance.interactionCount` is what counts
22
+ * them, as `totalCount - observedCount`. This bucket holds the interactions reported at
23
+ * exactly the threshold.
24
+ */
25
+ export const REPORTING_THRESHOLD_MS = 16;
26
+
27
+ /**
28
+ * Exceptions to the grid: latencies that must be a boundary of their own so that they are
29
+ * counted exactly rather than read off a bucket that spans them. Each one splits the bucket it
30
+ * falls inside. 500 ms is the Google INP "poor" threshold; the "good" one, 200 ms, needs no
31
+ * exception because the evenly spaced range already ends there.
32
+ *
33
+ * Every entry has to sit above that range, and adding one changes the reported keys, so bump
34
+ * SCHEMA_VERSION with it.
35
+ */
36
+ const EXACT_THRESHOLDS_MS = [500];
37
+ const EVENLY_SPACED_MAX_MS = 200;
38
+ /**
39
+ * Keep this a multiple of 8 (8, 16, 24, …). Event Timing reports durations in 8 ms steps, so a
40
+ * step that is not a multiple of 8 leaves buckets no interaction can ever land in.
41
+ */
42
+ const EVENLY_SPACED_STEP_MS = 8;
43
+ const BOUNDARIES_PER_DOUBLING = 5;
44
+
45
+ /**
46
+ * The first boundary at or above `latencyMs`, for the range above 200 ms.
47
+ *
48
+ * Five boundaries per doubling is the same as saying the nth boundary sits at `2^(n/5)` ms —
49
+ * boundary 40 at 256 ms, 45 at 512 ms, 50 at 1024 ms. So this turns the latency into a
50
+ * boundary number, rounds that up, and turns it back into milliseconds.
51
+ *
52
+ * Boundaries are floored to whole milliseconds, which keeps each one at or below the exact
53
+ * value it stands for. That is what makes `bucketKeyForMs(boundary) === boundary` hold.
54
+ */
55
+ function firstBoundaryAtOrAbove(latencyMs) {
56
+ // `Math.log2(latencyMs) * 5` is the boundary number: log2 answers how many doublings of
57
+ // 1 ms reach this latency, and five boundaries cover each doubling.
58
+ //
59
+ // A latency sitting on a boundary makes that a whole number, which `Math.ceil` has to
60
+ // keep. ECMA-262 only requires `Math.log2` to be approximate, so 9.0000000000000002 for
61
+ // `log2(512)` would round up to boundary 46 and report 512 ms as 588 ms. EPSILON is
62
+ // larger than such imprecision and far smaller than the gap between two boundaries.
63
+ const EPSILON = 1e-9;
64
+ const boundaryNumber = Math.ceil(BOUNDARIES_PER_DOUBLING * Math.log2(latencyMs) - EPSILON);
65
+
66
+ // One `Math.pow` over the whole exponent, so the result is rounded once. Multiplying the
67
+ // ratio between neighbouring boundaries (2^(1/5) ≈ 1.1487) by itself instead accumulates
68
+ // the rounding of every step, reaching 512.0000000000018 by boundary 45.
69
+ return Math.floor(Math.pow(2, boundaryNumber / BOUNDARIES_PER_DOUBLING));
70
+ }
71
+
72
+ /** The bucket each exact threshold splits — 500 ms splits the one ending at 512 ms. */
73
+ const EXACT_THRESHOLD_BUCKETS = EXACT_THRESHOLDS_MS.map(thresholdMs => ({
74
+ thresholdMs,
75
+ bucketMs: firstBoundaryAtOrAbove(thresholdMs)
76
+ }));
77
+
78
+ /**
79
+ * The bucket a latency belongs to, identified by the bucket's upper boundary in whole
80
+ * milliseconds — which is also its key in the reported buckets.
81
+ *
82
+ * Every latency gets a bucket, however slow: the boundaries continue upwards, so there is no
83
+ * overflow bucket. Expects the whole-millisecond durations Event Timing reports; boundaries
84
+ * are floored, so a fractional latency can land in a bucket whose key reads up to a
85
+ * millisecond below it. Non-finite latencies never reach here — `InteractionTracker` drops
86
+ * them as it reads the entry.
87
+ */
88
+ export function bucketKeyForMs(latencyMs) {
89
+ if (latencyMs <= REPORTING_THRESHOLD_MS) {
90
+ return REPORTING_THRESHOLD_MS;
91
+ }
92
+ if (latencyMs <= EVENLY_SPACED_MAX_MS) {
93
+ // How many 8 ms steps above the threshold the latency is, rounded up: 17 ms is 0.125
94
+ // steps up and lands on the boundary one step up, 24 ms. Rounding up is what keeps the
95
+ // set of keys fixed when a latency is not a multiple of 8 ms.
96
+ //
97
+ // No EPSILON here, unlike the branch below: subtracting whole numbers gives a whole
98
+ // number, and dividing by a power of two shifts a binary float's exponent without
99
+ // touching its digits, so a latency on a boundary cannot come out just above a whole
100
+ // number of steps.
101
+ const steps = Math.ceil((latencyMs - REPORTING_THRESHOLD_MS) / EVENLY_SPACED_STEP_MS);
102
+ return REPORTING_THRESHOLD_MS + steps * EVENLY_SPACED_STEP_MS;
103
+ }
104
+ const boundary = firstBoundaryAtOrAbove(latencyMs);
105
+
106
+ // A latency in the lower part of a split bucket is reported as the threshold itself, so the
107
+ // threshold is counted exactly.
108
+ for (const {
109
+ thresholdMs,
110
+ bucketMs
111
+ } of EXACT_THRESHOLD_BUCKETS) {
112
+ if (boundary === bucketMs && latencyMs <= thresholdMs) {
113
+ return thresholdMs;
114
+ }
115
+ }
116
+ return boundary;
117
+ }
@@ -0,0 +1,110 @@
1
+ import _defineProperty from "@babel/runtime/helpers/defineProperty";
2
+ import { bucketKeyForMs } from './bucket-boundaries';
3
+
4
+ /**
5
+ * Latencies are counted per 8 ms, the resolution Event Timing reports durations at. Every
6
+ * latency is rounded up to this step on the way in, which caps the number of distinct values
7
+ * a group can hold whatever the latency was derived from.
8
+ */
9
+ const RESOLUTION_MS = 8;
10
+
11
+ /** Which percentiles are reported, as quantiles. */
12
+ const REPORTED_QUANTILES = [0.9, 0.98];
13
+
14
+ /**
15
+ * The latencies of one set of interactions — every interaction on the page, or only the ones
16
+ * inside the editor — reported as one object in the event.
17
+ *
18
+ * The whole state is a count per distinct latency, so an interaction only costs a counter
19
+ * whatever its latency was, and everything the event carries — the reported buckets, the
20
+ * count, the sum, the maximum and the percentiles — is derived from that map when a snapshot
21
+ * is taken. Nothing is computed while interactions arrive.
22
+ */
23
+ export class InteractionGroup {
24
+ constructor() {
25
+ _defineProperty(this, "countByLatency", new Map());
26
+ }
27
+ add(latencyMs) {
28
+ this.increment(latencyMs);
29
+ }
30
+ remeasure(previousLatencyMs, latencyMs) {
31
+ // Moved rather than counted again: the count belongs to the same interaction.
32
+ this.decrement(previousLatencyMs);
33
+ this.increment(latencyMs);
34
+ }
35
+
36
+ /**
37
+ * @param totalCount every interaction of the group, including those below the Event Timing
38
+ * reporting threshold, which this group never sees.
39
+ */
40
+ snapshot(totalCount) {
41
+ var _latencies;
42
+ // Ascending, so the reported buckets come out in order and the last latency is the
43
+ // maximum. Sorted once for everything below.
44
+ const latencies = Array.from(this.countByLatency.keys()).sort((a, b) => a - b);
45
+ const observedCount = this.observedCount();
46
+ const percentileRanks = REPORTED_QUANTILES.map(quantile => ({
47
+ key: String(Math.round(quantile * 100)),
48
+ rank: Math.max(1, Math.ceil(quantile * observedCount))
49
+ }));
50
+ const buckets = {};
51
+ const percentilesMs = {};
52
+ let sumMs = 0;
53
+ let counted = 0;
54
+ for (const latencyMs of latencies) {
55
+ var _this$countByLatency$, _buckets$bucket;
56
+ const count = (_this$countByLatency$ = this.countByLatency.get(latencyMs)) !== null && _this$countByLatency$ !== void 0 ? _this$countByLatency$ : 0;
57
+ sumMs += latencyMs * count;
58
+ const bucket = String(bucketKeyForMs(latencyMs));
59
+ buckets[bucket] = ((_buckets$bucket = buckets[bucket]) !== null && _buckets$bucket !== void 0 ? _buckets$bucket : 0) + count;
60
+
61
+ // A percentile is the latency the group's interactions reach counting up from the
62
+ // fastest, so it is answered as soon as this many of them have been passed.
63
+ counted += count;
64
+ for (const {
65
+ key,
66
+ rank
67
+ } of percentileRanks) {
68
+ if (percentilesMs[key] === undefined && counted >= rank) {
69
+ percentilesMs[key] = latencyMs;
70
+ }
71
+ }
72
+ }
73
+ return {
74
+ // `performance.interactionCount` can lag the entries the observer has delivered.
75
+ totalCount: Math.max(totalCount, observedCount),
76
+ observedCount,
77
+ sumMs,
78
+ maxMs: (_latencies = latencies[latencies.length - 1]) !== null && _latencies !== void 0 ? _latencies : 0,
79
+ buckets,
80
+ percentilesMs
81
+ };
82
+ }
83
+ observedCount() {
84
+ let total = 0;
85
+ for (const count of this.countByLatency.values()) {
86
+ total += count;
87
+ }
88
+ return total;
89
+ }
90
+
91
+ /** Rounding lives here so that every count goes through the same step, in or out. */
92
+ roundLatencyUp(latencyMs) {
93
+ return Math.ceil(latencyMs / RESOLUTION_MS) * RESOLUTION_MS;
94
+ }
95
+ increment(latencyMs) {
96
+ var _this$countByLatency$2;
97
+ const step = this.roundLatencyUp(latencyMs);
98
+ this.countByLatency.set(step, ((_this$countByLatency$2 = this.countByLatency.get(step)) !== null && _this$countByLatency$2 !== void 0 ? _this$countByLatency$2 : 0) + 1);
99
+ }
100
+ decrement(latencyMs) {
101
+ var _this$countByLatency$3;
102
+ const step = this.roundLatencyUp(latencyMs);
103
+ const next = ((_this$countByLatency$3 = this.countByLatency.get(step)) !== null && _this$countByLatency$3 !== void 0 ? _this$countByLatency$3 : 0) - 1;
104
+ if (next > 0) {
105
+ this.countByLatency.set(step, next);
106
+ } else {
107
+ this.countByLatency.delete(step);
108
+ }
109
+ }
110
+ }
@@ -0,0 +1,90 @@
1
+ import { REPORTING_THRESHOLD_MS } from './bucket-boundaries';
2
+ /** `performance.interactionCount` is Chromium-only and absent from the DOM typings. */
3
+ const interactionCount = () => performance.interactionCount;
4
+
5
+ /** `durationThreshold` is absent from the DOM typings for `PerformanceObserverInit`. */
6
+
7
+ /**
8
+ * Reports the interactions the browser observes to `onEntries`.
9
+ *
10
+ * `drain` and `stop` are safe to call before `start` and after each other, so a session
11
+ * that never started collecting needs no special handling.
12
+ */
13
+ export class InteractionObserver {
14
+ /**
15
+ * Whether the browser reports both things a session needs: `interactionId`, which groups
16
+ * entries into interactions, and `performance.interactionCount`, which counts the ones
17
+ * below the reporting threshold. Both are Chromium-only, and without the count
18
+ * `totalCount` would be indistinguishable from `observedCount`.
19
+ */
20
+ static isSupported() {
21
+ if (typeof window === 'undefined' || typeof PerformanceObserver === 'undefined') {
22
+ return false;
23
+ }
24
+ if (!('PerformanceEventTiming' in window) || !('interactionId' in PerformanceEventTiming.prototype)) {
25
+ return false;
26
+ }
27
+ if (!PerformanceObserver.supportedEntryTypes.includes('event')) {
28
+ return false;
29
+ }
30
+ return typeof interactionCount() === 'number';
31
+ }
32
+
33
+ /**
34
+ * Total interactions on the page since page load, including those below the reporting
35
+ * threshold that no observer ever sees. A property of the page, not of an observer.
36
+ */
37
+ static readPageInteractionCount() {
38
+ var _interactionCount;
39
+ return (_interactionCount = interactionCount()) !== null && _interactionCount !== void 0 ? _interactionCount : 0;
40
+ }
41
+ constructor(onEntries) {
42
+ this.onEntries = onEntries;
43
+ }
44
+
45
+ /**
46
+ * Starts reporting interactions from this point on. Does nothing when already started, so
47
+ * a second call cannot leave an observer running with nobody to disconnect it.
48
+ *
49
+ * `buffered` is `false`: the entries the browser collected earlier are interactions with
50
+ * the page while the editor was still loading, and they belong to no session of ours.
51
+ */
52
+ start() {
53
+ if (this.observer) {
54
+ return;
55
+ }
56
+ this.observer = new PerformanceObserver(list => {
57
+ // Delay by a microtask to work around a Safari bug where the callback is
58
+ // invoked synchronously rather than in a separate task.
59
+ // See: https://github.com/GoogleChrome/web-vitals/issues/277
60
+ Promise.resolve().then(() => {
61
+ this.onEntries(list.getEntries());
62
+ });
63
+ });
64
+ const init = {
65
+ type: 'event',
66
+ buffered: false,
67
+ // 16 ms is also the smallest value the spec honours; lower values are clamped.
68
+ durationThreshold: REPORTING_THRESHOLD_MS
69
+ };
70
+ this.observer.observe(init);
71
+ }
72
+
73
+ /**
74
+ * Synchronously reports the entries the browser has produced but not yet dispatched to
75
+ * the callback. A snapshot taken because the page is going away has to include them,
76
+ * because there is no later chance to.
77
+ */
78
+ drain() {
79
+ var _this$observer;
80
+ const entries = (_this$observer = this.observer) === null || _this$observer === void 0 ? void 0 : _this$observer.takeRecords();
81
+ if (entries) {
82
+ this.onEntries(entries);
83
+ }
84
+ }
85
+ stop() {
86
+ var _this$observer2;
87
+ (_this$observer2 = this.observer) === null || _this$observer2 === void 0 ? void 0 : _this$observer2.disconnect();
88
+ this.observer = undefined;
89
+ }
90
+ }
@@ -0,0 +1,116 @@
1
+ import _defineProperty from "@babel/runtime/helpers/defineProperty";
2
+ /**
3
+ * The Event Timing fields this package reads. `interactionId` is missing from the DOM
4
+ * typings' `PerformanceEventTiming`, so it is declared here as optional, which also makes
5
+ * a `PerformanceEntry` from `getEntries()` assignable without a cast.
6
+ */
7
+
8
+ /**
9
+ * What an entry did to the interaction it belongs to: either it is the first entry of a new
10
+ * interaction, or it measured an interaction that was already counted as slower than it was
11
+ * known to be.
12
+ */
13
+
14
+ /**
15
+ * How many interactions are remembered, so their growth can still be applied. Entries of one
16
+ * interaction arrive within the interaction itself, so anything older is not needed.
17
+ */
18
+ const MAX_TRACKED = 256;
19
+ /**
20
+ * How many are dropped per cleanup. Dropping one at a time would run a cleanup on every new
21
+ * interaction once the limit is reached; a batch makes it one cleanup per 64 of them.
22
+ */
23
+ const PRUNE_BATCH = 64;
24
+
25
+ /**
26
+ * Groups Event Timing entries into interactions.
27
+ *
28
+ * Entries sharing a non-zero `interactionId` are one interaction whose latency is the
29
+ * maximum `duration` among them. Entries arrive incrementally, so an interaction's latency
30
+ * can grow after it was first reported — callers apply that to what they already counted
31
+ * rather than counting the interaction twice.
32
+ *
33
+ * A non-zero `interactionId` is the browser's own definition of an interaction, which is
34
+ * also what INP filters on: it is assigned to the pointer and keyboard events that make one
35
+ * up, and never to scrolling or pointer movement.
36
+ */
37
+ export class InteractionTracker {
38
+ /**
39
+ * @param startsAfterInteractionId interactions up to and including this one belong to the
40
+ * previous tracker and are ignored. `interactionId` counts up over the life of the page, so
41
+ * a session opening mid-page passes the highest id the one before it saw; without that, an
42
+ * entry still arriving for an interaction from the previous session would look new here and
43
+ * be counted in both.
44
+ */
45
+ constructor(startsAfterInteractionId = 0) {
46
+ _defineProperty(this, "latencies", new Map());
47
+ _defineProperty(this, "highestInteractionId", 0);
48
+ this.startsAfterInteractionId = startsAfterInteractionId;
49
+ }
50
+
51
+ /** The highest `interactionId` this tracker has seen. */
52
+ get lastInteractionId() {
53
+ return Math.max(this.highestInteractionId, this.startsAfterInteractionId);
54
+ }
55
+
56
+ /**
57
+ * Merges an entry into the interaction it belongs to.
58
+ *
59
+ * @returns what that did to the interaction's latency, or nothing when the entry is not
60
+ * part of an interaction, belongs to a previous tracker, or does not change one.
61
+ */
62
+ merge(entry) {
63
+ const {
64
+ interactionId
65
+ } = entry;
66
+ // `first-input` and non-interaction events report `interactionId` 0.
67
+ if (!interactionId) {
68
+ return undefined;
69
+ }
70
+ if (interactionId <= this.startsAfterInteractionId) {
71
+ return undefined;
72
+ }
73
+ this.highestInteractionId = Math.max(this.highestInteractionId, interactionId);
74
+
75
+ // Every latency is counted through here, so this is where one that cannot be measured
76
+ // is rejected: a `NaN` getting through becomes a `NaN` bucket key and a `NaN` `sumMs`
77
+ // for the rest of the session.
78
+ if (!Number.isFinite(entry.duration) || entry.duration < 0) {
79
+ return undefined;
80
+ }
81
+ const previousLatencyMs = this.latencies.get(interactionId);
82
+ if (previousLatencyMs === undefined) {
83
+ this.latencies.set(interactionId, entry.duration);
84
+ this.prune();
85
+ return {
86
+ type: 'new',
87
+ latencyMs: entry.duration
88
+ };
89
+ }
90
+ if (entry.duration <= previousLatencyMs) {
91
+ return undefined;
92
+ }
93
+ this.latencies.set(interactionId, entry.duration);
94
+ return {
95
+ type: 'remeasured',
96
+ fromMs: previousLatencyMs,
97
+ toMs: entry.duration
98
+ };
99
+ }
100
+ prune() {
101
+ if (this.latencies.size <= MAX_TRACKED) {
102
+ return;
103
+ }
104
+
105
+ // `Map` preserves insertion order and `interactionId` increases monotonically,
106
+ // so the entries inserted first are the least likely to see another entry.
107
+ let remaining = PRUNE_BATCH;
108
+ for (const interactionId of this.latencies.keys()) {
109
+ this.latencies.delete(interactionId);
110
+ remaining -= 1;
111
+ if (remaining === 0) {
112
+ return;
113
+ }
114
+ }
115
+ }
116
+ }