@atlaskit/editor-plugin-interactivity
Version:
Interactivity plugin for @atlaskit/editor-core
342 lines (325 loc) • 16.6 kB
JavaScript
import _defineProperty from "@babel/runtime/helpers/defineProperty";
import { BoundedList } from '../collections/bounded-list';
/** Fixed by the schema, so the event stays a bounded size. */
const 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.
*/
const 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.
*/
const 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.
*/
const ALLOWED_TARGET_ATTRIBUTES = ['data-vc', 'data-testid', 'data-test-id'];
const MAX_TARGET_ELEMENTS = 4;
const MAX_ATTRIBUTE_VALUE_LENGTH = 32;
const MAX_TARGET_LENGTH = 120;
/** How long a reported script or function name may be. */
const MAX_NAME_LENGTH = 64;
const QUERY_OR_HASH = /[?#]/u;
/** 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) {
const fields = Object.keys(one);
return fields.length === Object.keys(other).length && fields.every(field => 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.
*/
export class SlowInteractionList {
constructor() {
/** Slowest first. */
_defineProperty(this, "records", []);
_defineProperty(this, "frames", new BoundedList(MAX_FRAMES));
}
/**
* 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.
*/
trackInteractionUpdate(entry, update) {
const {
boundaries,
interactionId,
latencyMs
} = update;
const index = this.records.findIndex(record => record.interactionId === interactionId);
const 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 {
const 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((a, b) => 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.
*/
trackLongAnimationFrames(frames) {
this.frames.push(...frames);
let changed = false;
for (const record of this.records) {
changed = this.attribute(record) || changed;
}
return changed;
}
snapshot() {
if (this.records.length === 0) {
return undefined;
}
return this.records.map(record => {
var _record$attribution, _record$attribution2, _record$attribution3, _record$attribution4, _record$attribution5, _record$attribution6, _record$attribution7, _record$attribution8, _record$attribution9;
const 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
};
});
}
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.
const 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;
}
attribute(record) {
const 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.
*/
attributionFor(boundaries) {
var _longestScript, _longestScript2, _longestScript3;
let overlapped = false;
let lastFrameEndTime = 0;
let totalScriptDurationMs = 0;
let totalStyleAndLayoutDurationMs = 0;
let longestScript;
let longestScriptMs = 0;
for (const frame of this.frames) {
// 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;
}
const frameEndTime = frame.startTime + frame.duration;
if (frameEndTime < boundaries.startedAt) {
continue;
}
overlapped = true;
lastFrameEndTime = frameEndTime;
totalStyleAndLayoutDurationMs += this.styleAndLayoutOf(frame);
for (const script of (_frame$scripts = frame.scripts) !== null && _frame$scripts !== void 0 ? _frame$scripts : []) {
var _frame$scripts, _script$forcedStyleAn;
const scriptEndTime = script.startTime + script.duration;
if (scriptEndTime < boundaries.startedAt) {
continue;
}
const 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.
const 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;
}
}
}
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.
const 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.
const 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.
*/
styleAndLayoutOf(frame) {
const {
styleAndLayoutStart
} = frame;
if (typeof styleAndLayoutStart !== 'number' || styleAndLayoutStart === 0) {
return 0;
}
const frameEndTime = frame.startTime + frame.duration;
return Math.max(0, frameEndTime - styleAndLayoutStart);
}
/** Which phase of the interaction the script ran in, by where it started. */
subpartOf(script, boundaries) {
if (script.startTime < boundaries.processingStartedAt) {
return 'inputDelay';
}
return script.startTime >= boundaries.processingEndedAt ? 'presentationDelay' : 'processing';
}
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.
*/
fileName(sourceURL) {
if (!sourceURL) {
return undefined;
}
const 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.
*/
describeTarget(node) {
var _node$parentElement;
// An event's target can be a text node, and the element around it is the answer for it.
let element = node instanceof Element ? node : (_node$parentElement = node === null || node === void 0 ? void 0 : node.parentElement) !== null && _node$parentElement !== void 0 ? _node$parentElement : null;
const path = [];
for (let climbed = 0; element && climbed < MAX_TARGET_ELEMENTS; climbed += 1) {
const attribute = this.identifyingAttribute(element);
path.unshift(`${element.localName}${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);
}
identifyingAttribute(element) {
for (const attribute of ALLOWED_TARGET_ATTRIBUTES) {
const 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.
const safeValue = encodeURIComponent(value).slice(0, MAX_ATTRIBUTE_VALUE_LENGTH);
return `[${attribute}="${safeValue}"]`;
}
}
return undefined;
}
}