UNPKG

@atlaskit/editor-plugin-interactivity

Version:

Interactivity plugin for @atlaskit/editor-core

342 lines (325 loc) 16.6 kB
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; } }