@atlaskit/editor-plugin-interactivity 1.0.0 → 1.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 (43) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +79 -0
  3. package/afm-cc/tsconfig.json +3 -0
  4. package/afm-products/tsconfig.json +3 -0
  5. package/dist/cjs/collections/bounded-list.js +42 -0
  6. package/dist/cjs/collections/bounded-map.js +42 -0
  7. package/dist/cjs/collector/interaction-events.js +0 -7
  8. package/dist/cjs/collector/interaction-group.js +31 -4
  9. package/dist/cjs/collector/interaction-tracker.js +210 -68
  10. package/dist/cjs/collector/interactivity-collector.js +52 -22
  11. package/dist/cjs/collector/interactivity-session.js +3 -0
  12. package/dist/cjs/collector/long-animation-frame-observer.js +62 -0
  13. package/dist/cjs/collector/slow-interaction-list.js +413 -0
  14. package/dist/es2019/collections/bounded-list.js +24 -0
  15. package/dist/es2019/collections/bounded-map.js +25 -0
  16. package/dist/es2019/collector/interaction-events.js +0 -7
  17. package/dist/es2019/collector/interaction-group.js +28 -5
  18. package/dist/es2019/collector/interaction-tracker.js +201 -55
  19. package/dist/es2019/collector/interactivity-collector.js +35 -20
  20. package/dist/es2019/collector/interactivity-session.js +3 -0
  21. package/dist/es2019/collector/long-animation-frame-observer.js +42 -0
  22. package/dist/es2019/collector/slow-interaction-list.js +342 -0
  23. package/dist/esm/collections/bounded-list.js +35 -0
  24. package/dist/esm/collections/bounded-map.js +35 -0
  25. package/dist/esm/collector/interaction-events.js +0 -7
  26. package/dist/esm/collector/interaction-group.js +31 -5
  27. package/dist/esm/collector/interaction-tracker.js +210 -68
  28. package/dist/esm/collector/interactivity-collector.js +52 -22
  29. package/dist/esm/collector/interactivity-session.js +3 -0
  30. package/dist/esm/collector/long-animation-frame-observer.js +55 -0
  31. package/dist/esm/collector/slow-interaction-list.js +407 -0
  32. package/dist/types/analytics/interactivity-snapshot.d.ts +65 -0
  33. package/dist/types/collections/bounded-list.d.ts +9 -0
  34. package/dist/types/collections/bounded-map.d.ts +9 -0
  35. package/dist/types/collector/interaction-events.d.ts +1 -7
  36. package/dist/types/collector/interaction-group.d.ts +18 -5
  37. package/dist/types/collector/interaction-tracker.d.ts +67 -13
  38. package/dist/types/collector/interactivity-collector.d.ts +3 -0
  39. package/dist/types/collector/interactivity-session.d.ts +2 -0
  40. package/dist/types/collector/long-animation-frame-observer.d.ts +28 -0
  41. package/dist/types/collector/slow-interaction-list.d.ts +64 -0
  42. package/docs/0-intro.tsx +2 -1
  43. package/package.json +4 -3
package/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
1
1
  # @atlaskit/editor-plugin-interactivity
2
2
 
3
+ ## 1.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [`1b78d9e80b1bd`](https://bitbucket.org/atlassian/atlassian-frontend-monorepo/commits/1b78d9e80b1bd) -
8
+ Say where the latency of the slowest interactions went in the `editor interactivity` event, from
9
+ the Long Animation Frames they ran in and the way `web-vitals` attributes INP. `longestScriptMs`,
10
+ `functionName`, `scriptName`, `invokerType` and `scriptSubpart` describe the script that ran the
11
+ longest while the user waited, and `totalScriptDurationMs`, `totalStyleAndLayoutDurationMs`,
12
+ `totalPaintDurationMs` and `totalUnattributedDurationMs` divide the latency between script, style
13
+ and layout, paint, and what the frames explain nothing about. Behind the
14
+ platform_editor_editor_interactivity_slowest experiment.
15
+
16
+ ### Patch Changes
17
+
18
+ - Updated dependencies
19
+
20
+ ## 1.1.0
21
+
22
+ ### Minor Changes
23
+
24
+ - [`ccd1b52723728`](https://bitbucket.org/atlassian/atlassian-frontend-monorepo/commits/ccd1b52723728) -
25
+ Report the slowest interactions of the session in the `editor interactivity` event — their group,
26
+ event type, latency, timing phases and a sanitised target — so a regression the histograms show
27
+ can be traced to what was interacted with. Behind the platform_editor_editor_interactivity_slowest
28
+ experiment.
29
+
3
30
  ## 1.0.0
4
31
 
5
32
  ### Patch Changes
package/README.md CHANGED
@@ -31,6 +31,85 @@ interactions were slow, so this plugin keeps bucketed counts instead.
31
31
  bucket means zero. The boundaries are versioned by `schema`.
32
32
  - Snapshots are session-to-date, so a cohort query takes the highest `seq` per
33
33
  `interactivitySessionId` and then sums bucket counts.
34
+ - `slowest` — up to five of the slowest interactions of the session, slowest first. See below.
35
+
36
+ ## The slowest interactions
37
+
38
+ The histograms count how many interactions were slow. They cannot say what was interacted with, so
39
+ a regression they show cannot be diagnosed from telemetry alone. Each record carries the group and
40
+ the event type, the latency, the phases it divides into — input delay, processing, presentation
41
+ delay — a name for the target, and what the frame it ran in says about it.
42
+
43
+ Only interactions above 200 ms, the Google INP "good" threshold, are candidates, and `slowest` is
44
+ left out of the event when none crossed it, which is the common case. That is also what keeps the
45
+ cost down: a target is named only for the few interactions slow enough to be recorded, never for
46
+ every one the browser reports.
47
+
48
+ Records are kept per interaction, because an interaction's latency grows as later entries arrive:
49
+ one that is already recorded is replaced by its slower self instead of taking a second place. Its
50
+ attribution comes from the entry that measured it at its slowest, read as that entry arrives —
51
+ `entry.target` is `null` once the element has left the document, so there is no reading it later.
52
+
53
+ The target is a short path of tag names plus the first allow-listed attribute above it, such as
54
+ `div[data-vc="editor"] > p > span`. Nothing in it is document content: a tag name comes from the
55
+ schema, and the only attributes read are ones we put there ourselves. Ids, roles, class names, text
56
+ and accessibility labels are all left out, and the path is bounded in both depth and length.
57
+
58
+ The rest of a record comes from the Long Animation Frames of the interaction, attributed the way
59
+ [`web-vitals` attributes INP](https://github.com/GoogleChrome/web-vitals/blob/main/src/attribution/onINP.ts):
60
+ every frame overlapping the interaction counts, and the script that counts is the one with the
61
+ longest part inside it.
62
+
63
+ - `longestScriptMs`, `functionName`, `scriptName`, `invokerType` and `scriptSubpart` describe that one
64
+ script — how much of it fell inside the interaction, the function it ran in, the file it came from
65
+ as the browser named it without its origin or query, what ran it, and which phase of the
66
+ interaction it ran in. The phase is what keeps a script named for an interaction that waited on the
67
+ main thread from being mistaken for one its handlers ran.
68
+ - `totalScriptDurationMs`, `totalStyleAndLayoutDurationMs`, `totalPaintDurationMs` and
69
+ `totalUnattributedDurationMs` divide the latency itself across every frame of the interaction. The
70
+ time a script forced into style and layout counts as style and layout rather than as script, the
71
+ same split DevTools shows. `totalUnattributedDurationMs` is what the frames explain nothing about,
72
+ and it is not idle time: the browser reports no frame under 50 ms, so the work of those lands here
73
+ as well.
74
+
75
+ A record is attributed as soon as a frame of it is known, and worked out again on every batch of
76
+ frames after that. The browser reports a frame in no fixed order — it can deliver one after the entry
77
+ measuring the interaction that ran in it, and the frames of one interaction in several batches — so a
78
+ record can go out without these fields, or with what one frame said, and be corrected in a later
79
+ snapshot of the same session. That is another reason a query takes the highest `seq`. All of them are
80
+ absent when the browser reported no frame at all, which it does for frames under 50 ms and outside
81
+ Chromium, and the fields describing one script are absent when no script of those frames overlapped
82
+ the interaction.
83
+
84
+ The phases are the gaps between four moments: the user acted, the interaction's handlers started
85
+ running, they finished, the screen updated. The first and the last come from the entry that measured
86
+ the interaction at its slowest. The two in between come from the paint that presented that entry,
87
+ which is every event whose `startTime + duration` agrees within 8 ms — the rounding Event Timing
88
+ applies to `duration`, and the only thing it says about which paint presented an event. So a
89
+ `pointerover` handler that was still running when the user clicked reads as processing of the click
90
+ rather than as time the click waited for nothing, which is also the split `web-vitals` reports. The
91
+ four are clamped as it clamps them, so they stay in order whatever the browser reported.
92
+
93
+ A paint keeps growing while the browser reports the rest of the events it presented, so an
94
+ interaction is reported again whenever its paint grew — including when what grew it was another
95
+ interaction presented by the same paint, or an event that is no interaction at all. The last handlers
96
+ of a paint are usually reported after the event that measured the interaction, so the phases of a
97
+ record are corrected in a later snapshot of the same session.
98
+
99
+ One thing we do not copy: their style and layout total takes the frame's render phase unguarded; we
100
+ report zero for a frame the browser said did none, which their formula would otherwise turn into an
101
+ absolute timestamp.
102
+
103
+ An interaction that is not the editor's is recorded as `group: 'outsideEditor'`, so the records also
104
+ answer whether the slow interactions of a session were the editor's at all. That group is not the
105
+ `page` histogram: `page` counts the editor's interactions as well, `outsideEditor` counts only the
106
+ ones that are not.
107
+
108
+ Collecting them is its own experiment, `platform_editor_editor_interactivity_slowest`, read where a
109
+ session decides whether to keep them rather than where the plugin is added to a preset: the plugin
110
+ only exists when the experiment reporting the histograms is on, so the exposure lands on the
111
+ sessions that already send the event, and the cost of the records stays separable from the cost of
112
+ the event carrying them. Nothing is collected, named or sent when it is off.
34
113
 
35
114
  ## The editor groups
36
115
 
@@ -30,6 +30,9 @@
30
30
  {
31
31
  "path": "../../editor-plugin-editor-viewmode/afm-cc/tsconfig.json"
32
32
  },
33
+ {
34
+ "path": "../../../platform/feature-experiments/afm-cc/tsconfig.json"
35
+ },
33
36
  {
34
37
  "path": "../../editor-common/afm-cc/tsconfig.json"
35
38
  }
@@ -30,6 +30,9 @@
30
30
  {
31
31
  "path": "../../editor-plugin-editor-viewmode/afm-products/tsconfig.json"
32
32
  },
33
+ {
34
+ "path": "../../../platform/feature-experiments/afm-products/tsconfig.json"
35
+ },
33
36
  {
34
37
  "path": "../../editor-common/afm-products/tsconfig.json"
35
38
  }
@@ -0,0 +1,42 @@
1
+ "use strict";
2
+
3
+ var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault");
4
+ Object.defineProperty(exports, "__esModule", {
5
+ value: true
6
+ });
7
+ exports.BoundedList = void 0;
8
+ var _classCallCheck2 = _interopRequireDefault(require("@babel/runtime/helpers/classCallCheck"));
9
+ var _createClass2 = _interopRequireDefault(require("@babel/runtime/helpers/createClass"));
10
+ var _defineProperty2 = _interopRequireDefault(require("@babel/runtime/helpers/defineProperty"));
11
+ /** An array that forgets its oldest item once it holds more than `limit`. */
12
+ var BoundedList = exports.BoundedList = /*#__PURE__*/function () {
13
+ function BoundedList(limit) {
14
+ (0, _classCallCheck2.default)(this, BoundedList);
15
+ (0, _defineProperty2.default)(this, "items", []);
16
+ this.limit = limit;
17
+ }
18
+ return (0, _createClass2.default)(BoundedList, [{
19
+ key: "push",
20
+ value: function push() {
21
+ var _this$items;
22
+ (_this$items = this.items).push.apply(_this$items, arguments);
23
+ // Negative when there is still room, and `splice` then removes nothing.
24
+ this.items.splice(0, this.items.length - this.limit);
25
+ }
26
+ }, {
27
+ key: Symbol.iterator,
28
+ value: function value() {
29
+ return this.items[Symbol.iterator]();
30
+ }
31
+ }, {
32
+ key: "findLast",
33
+ value: function findLast(matches) {
34
+ for (var index = this.items.length - 1; index >= 0; index -= 1) {
35
+ if (matches(this.items[index])) {
36
+ return this.items[index];
37
+ }
38
+ }
39
+ return undefined;
40
+ }
41
+ }]);
42
+ }();
@@ -0,0 +1,42 @@
1
+ "use strict";
2
+
3
+ var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault");
4
+ Object.defineProperty(exports, "__esModule", {
5
+ value: true
6
+ });
7
+ exports.BoundedMap = void 0;
8
+ var _classCallCheck2 = _interopRequireDefault(require("@babel/runtime/helpers/classCallCheck"));
9
+ var _createClass2 = _interopRequireDefault(require("@babel/runtime/helpers/createClass"));
10
+ var _defineProperty2 = _interopRequireDefault(require("@babel/runtime/helpers/defineProperty"));
11
+ /** A `Map` that forgets its oldest entry once it holds more than `limit`. */
12
+ var BoundedMap = exports.BoundedMap = /*#__PURE__*/function () {
13
+ function BoundedMap(limit) {
14
+ (0, _classCallCheck2.default)(this, BoundedMap);
15
+ (0, _defineProperty2.default)(this, "entries", new Map());
16
+ this.limit = limit;
17
+ }
18
+ return (0, _createClass2.default)(BoundedMap, [{
19
+ key: "get",
20
+ value: function get(key) {
21
+ return this.entries.get(key);
22
+ }
23
+ }, {
24
+ key: "forEach",
25
+ value: function forEach(visit) {
26
+ this.entries.forEach(visit);
27
+ }
28
+ }, {
29
+ key: "set",
30
+ value: function set(key, value) {
31
+ this.entries.delete(key);
32
+ this.entries.set(key, value);
33
+ if (this.entries.size <= this.limit) {
34
+ return;
35
+ }
36
+ var oldest = this.entries.keys().next();
37
+ if (!oldest.done) {
38
+ this.entries.delete(oldest.value);
39
+ }
40
+ }
41
+ }]);
42
+ }();
@@ -5,13 +5,6 @@ Object.defineProperty(exports, "__esModule", {
5
5
  });
6
6
  exports.INTERACTION_EVENT_TYPES = void 0;
7
7
  exports.interactionEventKind = interactionEventKind;
8
- /**
9
- * The editor group an interaction belongs to, which is also the field the event reports it in.
10
- *
11
- * `editorOther` is the remainder — an interaction the browser counts that is neither typing nor
12
- * pointing — so the three groups together cover whatever the browser calls an interaction.
13
- */
14
-
15
8
  /**
16
9
  * The events an interaction is made of, and the group they belong to. Both sides of the collector
17
10
  * read this, so counting and grouping cannot disagree.
@@ -31,10 +31,10 @@ var REPORTED_QUANTILES = [0.9, 0.98];
31
31
  * count, the sum, the maximum and the percentiles — is derived from that map when a snapshot
32
32
  * is taken. Nothing is computed while interactions arrive.
33
33
  *
34
- * The two counters count different populations: `add` takes the interactions Event Timing
35
- * measured, `countTotal` takes all of them, including the ones below the 16 ms reporting threshold
36
- * it never delivers. So `totalCount >= observedCount`, and the difference is how many were too
37
- * fast to be measured.
34
+ * The two counters count different populations: `trackInteractionUpdate` takes the interactions
35
+ * Event Timing measured, `countTotal` takes all of them, including the ones below the 16 ms
36
+ * reporting threshold it never delivers. So `totalCount >= observedCount`, and the difference is how
37
+ * many were too fast to be measured.
38
38
  */
39
39
  var InteractionGroup = exports.InteractionGroup = /*#__PURE__*/function () {
40
40
  function InteractionGroup() {
@@ -43,6 +43,22 @@ var InteractionGroup = exports.InteractionGroup = /*#__PURE__*/function () {
43
43
  (0, _defineProperty2.default)(this, "totalCount", 0);
44
44
  }
45
45
  return (0, _createClass2.default)(InteractionGroup, [{
46
+ key: "trackInteractionUpdate",
47
+ value:
48
+ /**
49
+ * Takes in what the tracker now says about an interaction: a new one is counted, and one measured
50
+ * again moves the count it already has.
51
+ *
52
+ * @returns whether the group changed.
53
+ */
54
+ function trackInteractionUpdate(update) {
55
+ if (update.type === 'new') {
56
+ this.add(update.latencyMs);
57
+ return true;
58
+ }
59
+ return this.remeasure(update.previousLatencyMs, update.latencyMs);
60
+ }
61
+ }, {
46
62
  key: "add",
47
63
  value: function add(latencyMs) {
48
64
  this.increment(latencyMs);
@@ -57,12 +73,23 @@ var InteractionGroup = exports.InteractionGroup = /*#__PURE__*/function () {
57
73
  value: function countTotal() {
58
74
  this.totalCount += 1;
59
75
  }
76
+
77
+ /**
78
+ * @returns whether the count moved, which is `false` when both latencies fall in the step the
79
+ * interaction is already counted in — including when the interaction was measured no slower at
80
+ * all and only its boundaries moved.
81
+ */
60
82
  }, {
61
83
  key: "remeasure",
62
84
  value: function remeasure(previousLatencyMs, latencyMs) {
85
+ if (this.roundLatencyUp(previousLatencyMs) === this.roundLatencyUp(latencyMs)) {
86
+ return false;
87
+ }
88
+
63
89
  // Moved rather than counted again: the count belongs to the same interaction.
64
90
  this.decrement(previousLatencyMs);
65
91
  this.increment(latencyMs);
92
+ return true;
66
93
  }
67
94
 
68
95
  /**
@@ -5,23 +5,56 @@ Object.defineProperty(exports, "__esModule", {
5
5
  value: true
6
6
  });
7
7
  exports.InteractionTracker = void 0;
8
+ var _toConsumableArray2 = _interopRequireDefault(require("@babel/runtime/helpers/toConsumableArray"));
8
9
  var _classCallCheck2 = _interopRequireDefault(require("@babel/runtime/helpers/classCallCheck"));
9
10
  var _createClass2 = _interopRequireDefault(require("@babel/runtime/helpers/createClass"));
10
11
  var _defineProperty2 = _interopRequireDefault(require("@babel/runtime/helpers/defineProperty"));
12
+ var _boundedList = require("../collections/bounded-list");
13
+ var _boundedMap = require("../collections/bounded-map");
11
14
  var _interactionEvents = require("./interaction-events");
12
- function _createForOfIteratorHelper(r, e) { var t = "undefined" != typeof Symbol && r[Symbol.iterator] || r["@@iterator"]; if (!t) { if (Array.isArray(r) || (t = _unsupportedIterableToArray(r)) || e && r && "number" == typeof r.length) { t && (r = t); var _n = 0, F = function F() {}; return { s: F, n: function n() { return _n >= r.length ? { done: !0 } : { done: !1, value: r[_n++] }; }, e: function e(r) { throw r; }, f: F }; } throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); } var o, a = !0, u = !1; return { s: function s() { t = t.call(r); }, n: function n() { var r = t.next(); return a = r.done, r; }, e: function e(r) { u = !0, o = r; }, f: function f() { try { a || null == t.return || t.return(); } finally { if (u) throw o; } } }; }
13
- function _unsupportedIterableToArray(r, a) { if (r) { if ("string" == typeof r) return _arrayLikeToArray(r, a); var t = {}.toString.call(r).slice(8, -1); return "Object" === t && r.constructor && (t = r.constructor.name), "Map" === t || "Set" === t ? Array.from(r) : "Arguments" === t || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(t) ? _arrayLikeToArray(r, a) : void 0; } }
14
- function _arrayLikeToArray(r, a) { (null == a || a > r.length) && (a = r.length); for (var e = 0, n = Array(a); e < a; e++) n[e] = r[e]; return n; }
15
15
  /**
16
- * The Event Timing fields this package reads. `interactionId` is missing from the DOM
17
- * typings' `PerformanceEventTiming`, so it is declared here as optional, which also makes
18
- * a `PerformanceEntry` from `getEntries()` assignable without a cast.
16
+ * The Event Timing fields this package reads, declared optional so that a `PerformanceEntry` from
17
+ * `getEntries()` is assignable without a cast `interactionId` is missing from the DOM typings'
18
+ * `PerformanceEventTiming` altogether, and the rest are only on it.
19
+ */
20
+
21
+ /**
22
+ * One paint, and the processing of every event it presented.
23
+ *
24
+ * One paint can present several events, and the handlers of all of them ran before it: a
25
+ * `pointerover` handler that was still running when the user clicked held up the paint that showed
26
+ * the click. So an interaction's processing is the processing of its whole paint, not of its own
27
+ * events only — otherwise a handler that is not its own reads as time the user waited for nothing.
28
+ *
29
+ * Event Timing gives a paint no identity. The only thing an entry says about it is
30
+ * `startTime + duration`, the moment it happened, so that is what identifies it.
31
+ */
32
+
33
+ /**
34
+ * The paint an entry was presented by, and whether the entry moved the processing that paint covers.
35
+ * An entry whose handlers ran inside what the paint already covered changes nothing for any
36
+ * interaction reading its boundaries from it.
37
+ */
38
+
39
+ /**
40
+ * The four moments an interaction's latency divides at, in order: the user acted, its handlers
41
+ * started running, they finished, the screen updated.
19
42
  */
20
43
 
21
44
  /**
22
45
  * What an entry did to the interaction it belongs to: either it is the first entry of a new
23
- * interaction, or it measured an interaction that was already counted as slower than it was
24
- * known to be. Both carry the editor group of the interaction, if it is one of the editor's.
46
+ * interaction, or it changed an interaction already known. Both carry the editor group of the
47
+ * interaction, if it is one of the editor's, and the boundaries it now has.
48
+ *
49
+ * A `remeasured` where `previousLatencyMs` equals `latencyMs` is an interaction whose latency stayed
50
+ * as it was and whose boundaries moved: the entry ran in the same paint without being the slowest
51
+ * of them.
52
+ */
53
+
54
+ /**
55
+ * What is kept per interaction. The boundaries are not among these: they are derived from the paint
56
+ * whenever the interaction is reported, because the paint keeps growing as the browser reports the
57
+ * remaining events it presented.
25
58
  */
26
59
 
27
60
  /**
@@ -30,11 +63,17 @@ function _arrayLikeToArray(r, a) { (null == a || a > r.length) && (a = r.length)
30
63
  * anything older than the last few hundred is not needed.
31
64
  */
32
65
  var MAX_TRACKED = 256;
66
+
67
+ /**
68
+ * How many paints entries can still be placed in. The entries of a paint arrive within a batch or
69
+ * two of each other, so this only has to cover the paints in flight; it is what `web-vitals` keeps.
70
+ */
71
+ var MAX_RECENT_PAINTS = 10;
33
72
  /**
34
- * How many are dropped per cleanup. Dropping one at a time would run a cleanup on every new
35
- * interaction once the limit is reached; a batch makes it one cleanup per 64 of them.
73
+ * Event Timing rounds `duration` down to 8 ms, so two events presented by one paint report that
74
+ * paint up to this far apart and nothing else in Event Timing says they share it.
36
75
  */
37
- var PRUNE_BATCH = 64;
76
+ var PRESENTATION_ROUNDING_MS = 8;
38
77
 
39
78
  /**
40
79
  * Identifies the event an entry measured: an entry's `startTime` is that event's timestamp and its
@@ -60,6 +99,9 @@ function eventKey(type, timeStamp) {
60
99
  *
61
100
  * The editor's events answer what an entry cannot: which interactions were with the editor, and
62
101
  * how many there were, including the ones below the Event Timing reporting threshold.
102
+ *
103
+ * Every entry is also placed in the paint that presented it, which is what says how an
104
+ * interaction's latency divides into waiting, processing and presentation. See `Paint`.
63
105
  */
64
106
  var InteractionTracker = exports.InteractionTracker = /*#__PURE__*/function () {
65
107
  /**
@@ -72,8 +114,9 @@ var InteractionTracker = exports.InteractionTracker = /*#__PURE__*/function () {
72
114
  function InteractionTracker() {
73
115
  var startsAfterInteractionId = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 0;
74
116
  (0, _classCallCheck2.default)(this, InteractionTracker);
75
- (0, _defineProperty2.default)(this, "interactions", new Map());
76
- (0, _defineProperty2.default)(this, "groupByEvent", new Map());
117
+ (0, _defineProperty2.default)(this, "interactions", new _boundedMap.BoundedMap(MAX_TRACKED));
118
+ (0, _defineProperty2.default)(this, "groupByEvent", new _boundedMap.BoundedMap(MAX_TRACKED));
119
+ (0, _defineProperty2.default)(this, "recentPaints", new _boundedList.BoundedList(MAX_RECENT_PAINTS));
77
120
  (0, _defineProperty2.default)(this, "highestInteractionId", 0);
78
121
  this.startsAfterInteractionId = startsAfterInteractionId;
79
122
  }
@@ -88,58 +131,67 @@ var InteractionTracker = exports.InteractionTracker = /*#__PURE__*/function () {
88
131
  /**
89
132
  * Merges an entry into the interaction it belongs to.
90
133
  *
91
- * @returns what that did to the interaction's latency, or nothing when the entry is not
92
- * part of an interaction, belongs to a previous tracker, or does not change one.
134
+ * @returns what that changed about the interactions this tracker knows, the entry's own first.
135
+ * More than one of them when the paint the entry ran in presented several.
93
136
  */
94
137
  }, {
95
138
  key: "merge",
96
139
  value: function merge(entry) {
140
+ if (!Number.isFinite(entry.duration) || entry.duration < 0) {
141
+ return [];
142
+ }
143
+ var placement = this.paintOf(entry);
144
+ var paint = placement === null || placement === void 0 ? void 0 : placement.paint;
97
145
  var interactionId = entry.interactionId;
98
- // `first-input` and non-interaction events report `interactionId` 0.
146
+
147
+ // Reported only when the entry grew the paint, because otherwise nothing an interaction reads
148
+ // from it moved. Every interaction the paint presented is here, not only the entry's own: a
149
+ // `first-input` or non-interaction event reports `interactionId` 0 and has none of its own,
150
+ // and a second press of the same paint moved where the first one spent its latency.
151
+ //
152
+ // The check below is neither reached with a `0` nor needed: the interactions reported are the
153
+ // ones this tracker holds, and the only way into that map is past the check.
154
+ var remeasuredOthers = placement !== null && placement !== void 0 && placement.grew ? this.remeasuredUpdatesIn(placement.paint, {
155
+ except: interactionId
156
+ }) : [];
99
157
  if (!interactionId) {
100
- return undefined;
158
+ return remeasuredOthers;
101
159
  }
102
160
  if (interactionId <= this.startsAfterInteractionId) {
103
- return undefined;
161
+ // The entry belongs to the session before this one, but its handlers still ran before a
162
+ // paint of this one.
163
+ return remeasuredOthers;
104
164
  }
105
165
  this.highestInteractionId = Math.max(this.highestInteractionId, interactionId);
106
-
107
- // Every latency is counted through here, so this is where one that cannot be measured
108
- // is rejected: a `NaN` getting through becomes a `NaN` bucket key and a `NaN` `sumMs`
109
- // for the rest of the session.
110
- if (!Number.isFinite(entry.duration) || entry.duration < 0) {
111
- return undefined;
112
- }
113
166
  var tracked = this.interactions.get(interactionId);
114
167
  if (tracked === undefined) {
115
168
  // Taken once: an entry that only makes the interaction slower has to move its count
116
169
  // within the group it was counted in, not into another one.
117
170
  var group = this.groupByEvent.get(eventKey(entry.name, entry.startTime));
118
- this.interactions.set(interactionId, {
171
+ var interaction = {
172
+ group: group,
119
173
  latencyMs: entry.duration,
120
- group: group
121
- });
122
- this.prune(this.interactions);
123
- return {
124
- type: 'new',
125
- latencyMs: entry.duration,
126
- group: group
174
+ presentedIn: paint,
175
+ startedAt: entry.startTime
127
176
  };
177
+ this.interactions.set(interactionId, interaction);
178
+ return [this.newUpdate(interactionId, interaction)].concat((0, _toConsumableArray2.default)(remeasuredOthers));
128
179
  }
129
- if (entry.duration <= tracked.latencyMs) {
130
- return undefined;
180
+ if (entry.duration > tracked.latencyMs) {
181
+ var previousLatencyMs = tracked.latencyMs;
182
+ tracked.latencyMs = entry.duration;
183
+ tracked.presentedIn = paint;
184
+ tracked.startedAt = entry.startTime;
185
+ return [this.remeasuredUpdate(interactionId, tracked, previousLatencyMs)].concat((0, _toConsumableArray2.default)(remeasuredOthers));
131
186
  }
132
- var fromMs = tracked.latencyMs;
133
- tracked.latencyMs = entry.duration;
134
- return {
135
- type: 'remeasured',
136
- fromMs: fromMs,
137
- toMs: entry.duration,
138
- group: tracked.group
139
- };
140
- }
141
187
 
142
- /** @returns the group of an interaction to count, when this is the event its group counts on. */
188
+ // Not the slowest entry of the interaction, so its latency stands. Its handlers still ran
189
+ // before the same paint, if this is that paint, and so moved where that latency went.
190
+ if (!(placement !== null && placement !== void 0 && placement.grew) || paint !== tracked.presentedIn) {
191
+ return remeasuredOthers;
192
+ }
193
+ return [this.remeasuredUpdate(interactionId, tracked, tracked.latencyMs)].concat((0, _toConsumableArray2.default)(remeasuredOthers));
194
+ }
143
195
  }, {
144
196
  key: "recordEditorEvent",
145
197
  value: function recordEditorEvent(event) {
@@ -151,35 +203,125 @@ var InteractionTracker = exports.InteractionTracker = /*#__PURE__*/function () {
151
203
  // Every event of the interaction, because any of them can be the one Event Timing reports
152
204
  // as the slowest: for a pointer press that is usually the click.
153
205
  this.groupByEvent.set(eventKey(event.type, event.timeStamp), kind.group);
154
- this.prune(this.groupByEvent);
155
206
  return kind.counts ? kind.group : undefined;
156
207
  }
208
+
209
+ /**
210
+ * Every interaction whose boundaries are read from this paint, reported as measured again at the
211
+ * latency it already had.
212
+ *
213
+ * @param except the interaction the entry measured, which the caller reports itself. `0` or
214
+ * nothing when the entry measured none, and then no interaction is left out.
215
+ */
216
+ }, {
217
+ key: "remeasuredUpdatesIn",
218
+ value: function remeasuredUpdatesIn(paint, _ref) {
219
+ var _this = this;
220
+ var except = _ref.except;
221
+ var remeasured = [];
222
+ this.interactions.forEach(function (interaction, interactionId) {
223
+ if (interaction.presentedIn === paint && interactionId !== except) {
224
+ remeasured.push(_this.remeasuredUpdate(interactionId, interaction, interaction.latencyMs));
225
+ }
226
+ });
227
+ return remeasured;
228
+ }
229
+ }, {
230
+ key: "newUpdate",
231
+ value: function newUpdate(interactionId, tracked) {
232
+ return {
233
+ type: 'new',
234
+ interactionId: interactionId,
235
+ latencyMs: tracked.latencyMs,
236
+ group: tracked.group,
237
+ boundaries: this.boundariesOf(tracked)
238
+ };
239
+ }
157
240
  }, {
158
- key: "prune",
159
- value: function prune(entries) {
160
- if (entries.size <= MAX_TRACKED) {
161
- return;
241
+ key: "remeasuredUpdate",
242
+ value: function remeasuredUpdate(interactionId, tracked, previousLatencyMs) {
243
+ return {
244
+ type: 'remeasured',
245
+ interactionId: interactionId,
246
+ previousLatencyMs: previousLatencyMs,
247
+ latencyMs: tracked.latencyMs,
248
+ group: tracked.group,
249
+ boundaries: this.boundariesOf(tracked)
250
+ };
251
+ }
252
+
253
+ /**
254
+ * The four moments of an interaction, read from the paint as it stands now.
255
+ *
256
+ * Limited the way `web-vitals` limits its INP attribution, so the four stay in order: the paint's
257
+ * handlers can have started before the event arrived, and can have finished after the paint the
258
+ * event's rounded-down `duration` points at.
259
+ *
260
+ * @returns nothing when the browser reported no processing timestamps for the interaction, which
261
+ * leaves it in no paint.
262
+ */
263
+ }, {
264
+ key: "boundariesOf",
265
+ value: function boundariesOf(_ref2) {
266
+ var latencyMs = _ref2.latencyMs,
267
+ presentedIn = _ref2.presentedIn,
268
+ startedAt = _ref2.startedAt;
269
+ if (!presentedIn) {
270
+ return undefined;
162
271
  }
272
+ var processingStartedAt = Math.max(presentedIn.processingStartedAt, startedAt);
273
+ var presentedAt = Math.max(startedAt + latencyMs, processingStartedAt);
274
+ var processingEndedAt = Math.min(presentedIn.processingEndedAt, presentedAt);
275
+ return {
276
+ startedAt: startedAt,
277
+ processingStartedAt: processingStartedAt,
278
+ processingEndedAt: processingEndedAt,
279
+ presentedAt: presentedAt
280
+ };
281
+ }
163
282
 
164
- // `Map` preserves insertion order, so the entries inserted first are the least likely to
165
- // see another entry or another event.
166
- var remaining = PRUNE_BATCH;
167
- var _iterator = _createForOfIteratorHelper(entries.keys()),
168
- _step;
169
- try {
170
- for (_iterator.s(); !(_step = _iterator.n()).done;) {
171
- var key = _step.value;
172
- entries.delete(key);
173
- remaining -= 1;
174
- if (remaining === 0) {
175
- return;
176
- }
177
- }
178
- } catch (err) {
179
- _iterator.e(err);
180
- } finally {
181
- _iterator.f();
283
+ /**
284
+ * The paint that presented this entry, grown to cover this entry's own processing.
285
+ *
286
+ * The moment being matched is always the one the first entry of the paint reported, so that a
287
+ * run of entries 8 ms apart cannot walk one paint across the next.
288
+ *
289
+ * @returns nothing when the browser reported no processing timestamps for the entry, which
290
+ * leaves nothing to place it by.
291
+ */
292
+ }, {
293
+ key: "paintOf",
294
+ value: function paintOf(entry) {
295
+ var startTime = entry.startTime,
296
+ duration = entry.duration,
297
+ processingStart = entry.processingStart,
298
+ processingEnd = entry.processingEnd;
299
+ if (typeof processingStart !== 'number' || typeof processingEnd !== 'number') {
300
+ return undefined;
182
301
  }
302
+ var presentedAt = startTime + duration;
303
+ var knownPaint = this.recentPaints.findLast(function (paint) {
304
+ return Math.abs(presentedAt - paint.presentedAt) <= PRESENTATION_ROUNDING_MS;
305
+ });
306
+ if (knownPaint) {
307
+ var grew = processingStart < knownPaint.processingStartedAt || processingEnd > knownPaint.processingEndedAt;
308
+ knownPaint.processingStartedAt = Math.min(processingStart, knownPaint.processingStartedAt);
309
+ knownPaint.processingEndedAt = Math.max(processingEnd, knownPaint.processingEndedAt);
310
+ return {
311
+ grew: grew,
312
+ paint: knownPaint
313
+ };
314
+ }
315
+ var newPaint = {
316
+ presentedAt: presentedAt,
317
+ processingStartedAt: processingStart,
318
+ processingEndedAt: processingEnd
319
+ };
320
+ this.recentPaints.push(newPaint);
321
+ return {
322
+ grew: true,
323
+ paint: newPaint
324
+ };
183
325
  }
184
326
  }]);
185
327
  }();