UNPKG

@atlaskit/editor-plugin-interactivity

Version:

Interactivity plugin for @atlaskit/editor-core

413 lines (396 loc) 21 kB
"use strict"; var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault"); Object.defineProperty(exports, "__esModule", { value: true }); exports.SlowInteractionList = void 0; var _toConsumableArray2 = _interopRequireDefault(require("@babel/runtime/helpers/toConsumableArray")); var _classCallCheck2 = _interopRequireDefault(require("@babel/runtime/helpers/classCallCheck")); var _createClass2 = _interopRequireDefault(require("@babel/runtime/helpers/createClass")); var _defineProperty2 = _interopRequireDefault(require("@babel/runtime/helpers/defineProperty")); var _boundedList = require("../collections/bounded-list"); 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; } } }; } 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; } } 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; } /** Fixed by the schema, so the event stays a bounded size. */ var MAX_RECORDS = 5; /** * The latency an interaction has to beat to be recorded. 200 ms is the Google INP "good" threshold, * so anything below it is an interaction the user was not waiting for. */ var MIN_LATENCY_MS = 200; /** * How many frames are kept to attribute records from. An interaction spans one paint, so a few dozen * cover even a second of a janky page. */ var MAX_FRAMES = 64; /** * The attributes a target may be named by, all of them ours: `data-vc` for visual completion, the * test ids for tests. Ids, roles, class names, text and accessibility labels are left out because * they can carry what the user wrote. */ var ALLOWED_TARGET_ATTRIBUTES = ['data-vc', 'data-testid', 'data-test-id']; var MAX_TARGET_ELEMENTS = 4; var MAX_ATTRIBUTE_VALUE_LENGTH = 32; var MAX_TARGET_LENGTH = 120; /** How long a reported script or function name may be. */ var MAX_NAME_LENGTH = 64; var QUERY_OR_HASH = /[#\?]/; /** The part of a record that only the frames the interaction ran in can fill in. */ /** * Whether two attributions say the same thing. Shallow, because every field of one is a number or a * string; by the field names of both, so that a field going missing counts as a change rather than * as nothing to see. */ function sameAttribution(one, other) { var fields = Object.keys(one); return fields.length === Object.keys(other).length && fields.every(function (field) { return one[field] === other[field]; }); } /** * One recorded interaction. The reported phases are not among its fields: they are the gaps between * the boundaries, worked out when the record is reported, which is also where the latency is * rounded into the `durationMs` the event carries. * * The attribution is kept whole rather than spread across the record, so that a record can never * hold half of what one set of frames said and half of what another did. * * `interactionId` identifies the interaction across the entries measuring it, and `boundaries` is * also what its frames are matched to it by. Neither is reported. */ /** * The slowest interactions of one session, which is what the event's `slowest` records are. * * Interactions arrive from the tracker and frames from the Long Animation Frame observer, and this * is where the two meet: a record says both how long the user waited and where that time went. * * A record is built from the entry that measured the interaction, as that entry arrives: * `entry.target` is `null` once the element has left the document. */ var SlowInteractionList = exports.SlowInteractionList = /*#__PURE__*/function () { function SlowInteractionList() { (0, _classCallCheck2.default)(this, SlowInteractionList); /** Slowest first. */ (0, _defineProperty2.default)(this, "records", []); (0, _defineProperty2.default)(this, "frames", new _boundedList.BoundedList(MAX_FRAMES)); } return (0, _createClass2.default)(SlowInteractionList, [{ key: "trackInteractionUpdate", value: /** * Takes in what the tracker now says about an interaction, keeping it when it is one of the * slowest of the session. * * @returns whether that changed what a snapshot would carry. */ function trackInteractionUpdate(entry, update) { var boundaries = update.boundaries, interactionId = update.interactionId, latencyMs = update.latencyMs; var index = this.records.findIndex(function (record) { return record.interactionId === interactionId; }); var knownRecord = index === -1 ? undefined : this.records[index]; if (knownRecord && latencyMs <= knownRecord.latencyMs) { var _knownRecord$boundari, _knownRecord$boundari2; // The interaction at the latency it already had, so its name and target still come from // the entry that measured it at its slowest — and so do `startedAt` and `presentedAt`, // which leaves the processing as the only pair that can have moved. if (((_knownRecord$boundari = knownRecord.boundaries) === null || _knownRecord$boundari === void 0 ? void 0 : _knownRecord$boundari.processingStartedAt) === (boundaries === null || boundaries === void 0 ? void 0 : boundaries.processingStartedAt) && ((_knownRecord$boundari2 = knownRecord.boundaries) === null || _knownRecord$boundari2 === void 0 ? void 0 : _knownRecord$boundari2.processingEndedAt) === (boundaries === null || boundaries === void 0 ? void 0 : boundaries.processingEndedAt)) { return false; } knownRecord.boundaries = boundaries; this.attribute(knownRecord); return true; } // Everything below builds a record out of `entry`, so it has to be an entry of this // interaction. The tracker also reports an interaction whose boundaries moved because of an // event that is no interaction of its own, and that event names something else entirely. if (entry.interactionId !== interactionId) { return false; } if (knownRecord) { // An interaction measured as slower replaces itself rather than taking a second place. this.records[index] = this.toRecord(entry, update); } else { var toBeatMs = this.records.length === MAX_RECORDS ? this.records[MAX_RECORDS - 1].latencyMs : MIN_LATENCY_MS; if (latencyMs <= toBeatMs) { return false; } this.records.push(this.toRecord(entry, update)); } this.records.sort(function (a, b) { return b.latencyMs - a.latencyMs; }); this.records.splice(MAX_RECORDS); return true; } /** * Takes in the frames the browser has just reported and works out again what the frames say about * every record — again, because the frames of one interaction can be reported in several batches * and the first of them may hold neither its longest script nor all of its style and layout. * * @returns whether that changed what a snapshot would carry. */ }, { key: "trackLongAnimationFrames", value: function trackLongAnimationFrames(frames) { var _this$frames; (_this$frames = this.frames).push.apply(_this$frames, (0, _toConsumableArray2.default)(frames)); var changed = false; var _iterator = _createForOfIteratorHelper(this.records), _step; try { for (_iterator.s(); !(_step = _iterator.n()).done;) { var record = _step.value; changed = this.attribute(record) || changed; } } catch (err) { _iterator.e(err); } finally { _iterator.f(); } return changed; } }, { key: "snapshot", value: function snapshot() { if (this.records.length === 0) { return undefined; } return this.records.map(function (record) { var _record$attribution, _record$attribution2, _record$attribution3, _record$attribution4, _record$attribution5, _record$attribution6, _record$attribution7, _record$attribution8, _record$attribution9; var boundaries = record.boundaries; return { group: record.group, name: record.name, durationMs: Math.round(record.latencyMs), inputDelayMs: boundaries && Math.round(boundaries.processingStartedAt - boundaries.startedAt), processingMs: boundaries && Math.round(boundaries.processingEndedAt - boundaries.processingStartedAt), presentationDelayMs: boundaries && Math.round(boundaries.presentedAt - boundaries.processingEndedAt), target: record.target, functionName: (_record$attribution = record.attribution) === null || _record$attribution === void 0 ? void 0 : _record$attribution.functionName, invokerType: (_record$attribution2 = record.attribution) === null || _record$attribution2 === void 0 ? void 0 : _record$attribution2.invokerType, longestScriptMs: (_record$attribution3 = record.attribution) === null || _record$attribution3 === void 0 ? void 0 : _record$attribution3.longestScriptMs, scriptName: (_record$attribution4 = record.attribution) === null || _record$attribution4 === void 0 ? void 0 : _record$attribution4.scriptName, scriptSubpart: (_record$attribution5 = record.attribution) === null || _record$attribution5 === void 0 ? void 0 : _record$attribution5.scriptSubpart, totalPaintDurationMs: (_record$attribution6 = record.attribution) === null || _record$attribution6 === void 0 ? void 0 : _record$attribution6.totalPaintDurationMs, totalScriptDurationMs: (_record$attribution7 = record.attribution) === null || _record$attribution7 === void 0 ? void 0 : _record$attribution7.totalScriptDurationMs, totalStyleAndLayoutDurationMs: (_record$attribution8 = record.attribution) === null || _record$attribution8 === void 0 ? void 0 : _record$attribution8.totalStyleAndLayoutDurationMs, totalUnattributedDurationMs: (_record$attribution9 = record.attribution) === null || _record$attribution9 === void 0 ? void 0 : _record$attribution9.totalUnattributedDurationMs }; }); } }, { key: "toRecord", value: function toRecord(entry, update) { var _update$group; // The target is read only once the interaction has earned a place: naming it walks the DOM, // and this runs while the page is already slow. var record = { attribution: undefined, boundaries: update.boundaries, interactionId: update.interactionId, // An interaction the editor never reported an event for is not the editor's as far as we // know. group: (_update$group = update.group) !== null && _update$group !== void 0 ? _update$group : 'outsideEditor', name: entry.name, latencyMs: update.latencyMs, target: this.describeTarget(entry.target) }; // Frames reported before this entry already answer for it. this.attribute(record); return record; } }, { key: "attribute", value: function attribute(record) { var attribution = record.boundaries && this.attributionFor(record.boundaries); if (!attribution) { return false; } if (record.attribution && sameAttribution(record.attribution, attribution)) { return false; } record.attribution = attribution; return true; } /** * What the frames say about an interaction, attributed the way `web-vitals` attributes INP: every * frame overlapping the interaction counts, the script that counts is the one with the longest * part inside it, and style and layout is summed across those frames. * * @returns nothing when no frame overlaps the interaction — the browser reports frames above * 50 ms only. */ }, { key: "attributionFor", value: function attributionFor(boundaries) { var _longestScript, _longestScript2, _longestScript3; var overlapped = false; var lastFrameEndTime = 0; var totalScriptDurationMs = 0; var totalStyleAndLayoutDurationMs = 0; var longestScript; var longestScriptMs = 0; var _iterator2 = _createForOfIteratorHelper(this.frames), _step2; try { for (_iterator2.s(); !(_step2 = _iterator2.n()).done;) { var _frame$scripts; var frame = _step2.value; // Frames come in the order they were rendered, so once one starts after the interaction, // so does every frame after it. if (frame.startTime > boundaries.processingEndedAt) { break; } var frameEndTime = frame.startTime + frame.duration; if (frameEndTime < boundaries.startedAt) { continue; } overlapped = true; lastFrameEndTime = frameEndTime; totalStyleAndLayoutDurationMs += this.styleAndLayoutOf(frame); var _iterator3 = _createForOfIteratorHelper((_frame$scripts = frame.scripts) !== null && _frame$scripts !== void 0 ? _frame$scripts : []), _step3; try { for (_iterator3.s(); !(_step3 = _iterator3.n()).done;) { var _script$forcedStyleAn; var script = _step3.value; var scriptEndTime = script.startTime + script.duration; if (scriptEndTime < boundaries.startedAt) { continue; } var insideInteractionMs = scriptEndTime - Math.max(boundaries.startedAt, script.startTime); // `forcedStyleAndLayoutDuration` carries no timestamps, so the part of it inside the // interaction is apportioned. It counts as style and layout rather than script time, // the same split DevTools shows. var forcedInsideMs = script.duration ? insideInteractionMs / script.duration * ((_script$forcedStyleAn = script.forcedStyleAndLayoutDuration) !== null && _script$forcedStyleAn !== void 0 ? _script$forcedStyleAn : 0) : 0; totalScriptDurationMs += insideInteractionMs - forcedInsideMs; totalStyleAndLayoutDurationMs += forcedInsideMs; if (insideInteractionMs > longestScriptMs) { longestScript = script; longestScriptMs = insideInteractionMs; } } } catch (err) { _iterator3.e(err); } finally { _iterator3.f(); } } } catch (err) { _iterator2.e(err); } finally { _iterator2.f(); } if (!overlapped) { return undefined; } // What the browser did after the last frame of the interaction, so it only counts when that // frame ended no earlier than the handlers did. var totalPaintDurationMs = lastFrameEndTime >= boundaries.processingEndedAt ? Math.max(0, boundaries.presentedAt - lastFrameEndTime) : 0; // Every total is brought to what it is reported as before this subtraction, so that the four // of them add up to the latency rather than to more than it: a frame whose render phase runs // past the interaction would otherwise leave a negative here to be counted twice. totalScriptDurationMs = Math.max(0, totalScriptDurationMs); totalStyleAndLayoutDurationMs = Math.max(0, totalStyleAndLayoutDurationMs); // Whatever is left of the latency: the thread was busy with something the frames attributed // to no script, to no style and layout, and to no paint. var totalUnattributedDurationMs = Math.max(0, boundaries.presentedAt - boundaries.startedAt - totalScriptDurationMs - totalStyleAndLayoutDurationMs - totalPaintDurationMs); return { functionName: this.truncated((_longestScript = longestScript) === null || _longestScript === void 0 ? void 0 : _longestScript.sourceFunctionName), invokerType: this.truncated((_longestScript2 = longestScript) === null || _longestScript2 === void 0 ? void 0 : _longestScript2.invokerType), longestScriptMs: longestScript && Math.round(longestScriptMs), scriptName: this.truncated(this.fileName((_longestScript3 = longestScript) === null || _longestScript3 === void 0 ? void 0 : _longestScript3.sourceURL)), scriptSubpart: longestScript && this.subpartOf(longestScript, boundaries), totalPaintDurationMs: Math.round(totalPaintDurationMs), totalScriptDurationMs: Math.round(totalScriptDurationMs), totalStyleAndLayoutDurationMs: Math.round(totalStyleAndLayoutDurationMs), totalUnattributedDurationMs: Math.round(totalUnattributedDurationMs) }; } /** * Style, layout and paint of the frame, which the browser reports as starting at 0 when the * frame did none. */ }, { key: "styleAndLayoutOf", value: function styleAndLayoutOf(frame) { var styleAndLayoutStart = frame.styleAndLayoutStart; if (typeof styleAndLayoutStart !== 'number' || styleAndLayoutStart === 0) { return 0; } var frameEndTime = frame.startTime + frame.duration; return Math.max(0, frameEndTime - styleAndLayoutStart); } /** Which phase of the interaction the script ran in, by where it started. */ }, { key: "subpartOf", value: function subpartOf(script, boundaries) { if (script.startTime < boundaries.processingStartedAt) { return 'inputDelay'; } return script.startTime >= boundaries.processingEndedAt ? 'presentationDelay' : 'processing'; } }, { key: "truncated", value: function truncated(name) { return name ? name.slice(0, MAX_NAME_LENGTH) : undefined; } /** * The file as the browser named it, content hash and all: that is what identifies the artefact * and its source map, and a query can be grouped away downstream. */ }, { key: "fileName", value: function fileName(sourceURL) { if (!sourceURL) { return undefined; } var path = sourceURL.split(QUERY_OR_HASH)[0]; return path.slice(path.lastIndexOf('/') + 1); } /** * Names the element an interaction happened on — `div[data-vc="x"] > p > span`, outermost first. * The path climbs until an element carries an allow-listed attribute, because that is what says * which part of the page this was. */ }, { key: "describeTarget", value: function describeTarget(node) { var _node$parentElement; // An event's target can be a text node, and the element around it is the answer for it. var element = node instanceof Element ? node : (_node$parentElement = node === null || node === void 0 ? void 0 : node.parentElement) !== null && _node$parentElement !== void 0 ? _node$parentElement : null; var path = []; for (var climbed = 0; element && climbed < MAX_TARGET_ELEMENTS; climbed += 1) { var attribute = this.identifyingAttribute(element); path.unshift("".concat(element.localName).concat(attribute !== null && attribute !== void 0 ? attribute : '')); if (attribute) { break; } element = element.parentElement; } if (path.length === 0) { return undefined; } return path.join(' > ').slice(0, MAX_TARGET_LENGTH); } }, { key: "identifyingAttribute", value: function identifyingAttribute(element) { for (var _i = 0, _ALLOWED_TARGET_ATTRI = ALLOWED_TARGET_ATTRIBUTES; _i < _ALLOWED_TARGET_ATTRI.length; _i++) { var attribute = _ALLOWED_TARGET_ATTRI[_i]; var value = element.getAttribute(attribute); if (value) { // Encoded and cut: a value we did not write cannot bring quotes or a paragraph of // text into the event. var safeValue = encodeURIComponent(value).slice(0, MAX_ATTRIBUTE_VALUE_LENGTH); return "[".concat(attribute, "=\"").concat(safeValue, "\"]"); } } return undefined; } }]); }();