@atlaskit/editor-plugin-interactivity
Version:
Interactivity plugin for @atlaskit/editor-core
413 lines (396 loc) • 21 kB
JavaScript
"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;
}
}]);
}();