@atlaskit/editor-plugin-limited-mode
Version:
LimitedMode plugin for @atlaskit/editor-core
314 lines (297 loc) • 13.2 kB
JavaScript
"use strict";
var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault");
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.LatchPolicy = exports.DEFAULT_LATCH_POLICY_CONFIG = void 0;
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 _median = require("@atlaskit/editor-common/median");
var _shouldEnableLimitedMode = require("@atlaskit/editor-common/should-enable-limited-mode");
function ownKeys(e, r) { var t = Object.keys(e); if (Object.getOwnPropertySymbols) { var o = Object.getOwnPropertySymbols(e); r && (o = o.filter(function (r) { return Object.getOwnPropertyDescriptor(e, r).enumerable; })), t.push.apply(t, o); } return t; }
function _objectSpread(e) { for (var r = 1; r < arguments.length; r++) { var t = null != arguments[r] ? arguments[r] : {}; r % 2 ? ownKeys(Object(t), !0).forEach(function (r) { (0, _defineProperty2.default)(e, r, t[r]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t)) : ownKeys(Object(t)).forEach(function (r) { Object.defineProperty(e, r, Object.getOwnPropertyDescriptor(t, r)); }); } return e; }
/**
* The shipped policy.
*
* These values add up to: nothing counts for the first 10s; then a window qualifies on either 6 of
* 12 keystrokes slower than 100ms with that window's median also over 100ms, or 3 long tasks over
* 600ms within 30s corroborated by a slow keystroke in that same 30s. Two qualifying windows at
* least 30s apart latch limited mode.
*
* `freezeTaskMs` matches `DEFAULT_FREEZE_THRESHOLD` in
* `editor-plugin-base/src/pm-plugins/frozen-editor.ts`, which backs the existing
* `ACTION.BROWSER_FREEZE` telemetry, so production dashboards can be used to calibrate it.
* `slowInputMs` is deliberately tighter than that file's `DEFAULT_SLOW_THRESHOLD` of 300 — this
* needs to notice a degraded experience, not just an unusable one.
*
* `requiredConfirmations` and `confirmationGapMs` are the values that matter most — see the comment
* on the former.
*/
var DEFAULT_LATCH_POLICY_CONFIG = exports.DEFAULT_LATCH_POLICY_CONFIG = {
warmUpMs: 10000,
slowInputMs: 100,
latencyWindowSize: 12,
latencySlowSamplesRequired: 6,
freezeTaskMs: 600,
freezeTasksRequired: 3,
freezeWindowMs: 30000,
requiredConfirmations: 2,
confirmationGapMs: 30000,
bulkChangeNodeSize: 100,
bulkChangeSuppressionMs: 2000,
docSizeThreshold: 750000,
nodeCountThreshold: 5000
};
/**
* Decides whether limited mode should be on.
*
* The single authority for that question, covering both reasons:
*
* - **The document** — too large, too many nodes, or containing a legacy content macro. Evaluated on
* load and on document replacement, so it can turn back off (a `replaceDocument` onto a smaller
* page) without costing a full-document walk per transaction.
* - **The device** — sustained slow keystrokes or repeated long tasks. **One-way**: once the runtime
* bar is met the policy stops evaluating, so the editor can never oscillate between modes.
*
* `isBreached()` is the combined verdict. Everything tunable is in `config`, so the whole high bar is
* unit-testable without needing to make a real browser slow, and a caller can substitute a
* differently configured policy. `latch-detector.ts` owns the browser plumbing that feeds the runtime
* criteria, and takes a policy instance rather than constructing one.
*/
var LatchPolicy = exports.LatchPolicy = /*#__PURE__*/function () {
function LatchPolicy(_ref) {
var now = _ref.now,
config = _ref.config;
(0, _classCallCheck2.default)(this, LatchPolicy);
/** Public so the detector can read the tunables it needs rather than duplicating them. */
/** Public so the detector shares one clock with the policy. */
(0, _defineProperty2.default)(this, "latencySamples", []);
(0, _defineProperty2.default)(this, "freezeTimes", []);
(0, _defineProperty2.default)(this, "qualifiedWindows", 0);
(0, _defineProperty2.default)(this, "suppressedUntil", 0);
(0, _defineProperty2.default)(this, "latched", false);
(0, _defineProperty2.default)(this, "documentBreached", false);
/** Cumulative for the session and never cleared, unlike the evidence buffers. */
(0, _defineProperty2.default)(this, "totalInputSamples", 0);
(0, _defineProperty2.default)(this, "totalSlowInputs", 0);
(0, _defineProperty2.default)(this, "totalFreezes", 0);
this.now = now;
this.config = _objectSpread(_objectSpread({}, DEFAULT_LATCH_POLICY_CONFIG), config);
this.startedAt = now();
}
/**
* Whether limited mode should be on, for either reason. This is the verdict consumers act on.
*/
return (0, _createClass2.default)(LatchPolicy, [{
key: "isBreached",
value: function isBreached() {
return this.documentBreached || this.latched;
}
/**
* What the latch was based on, or `undefined` while un-latched. Intended for telemetry — nothing in
* the decision reads it back.
*/
}, {
key: "getLatchDetails",
value: function getLatchDetails() {
return this.latchDetails;
}
/** Whether the runtime (device) criteria have latched. One-way, and never cleared. */
}, {
key: "isLatched",
value: function isLatched() {
return this.latched;
}
/**
* Latch the runtime reason directly, without accumulating evidence for it.
*
* The policy latches itself when its own criteria are met, so this exists for callers that have
* already decided: the plugin replaying the detector's latch transaction, and dev tooling forcing
* the state by hand. Idempotent, and one-way like every other route to `latched`.
*/
}, {
key: "latch",
value: function latch() {
if (this.latched) {
return;
}
this.latched = true;
this.latchDetails = this.buildDetails('forced', undefined);
}
/** Whether the document currently breaches the thresholds. Can go back to false. */
}, {
key: "isDocumentBreached",
value: function isDocumentBreached() {
return this.documentBreached;
}
/**
* Evaluate the document reason against the size / node-count / legacy-content-macro thresholds.
*
* Walks the whole document, so the caller decides when it is worth paying for: `pm-plugins/main.ts`
* calls this on load and on `replaceDocument` (e.g. live-to-live page navigation) only, never per
* transaction. Editing therefore cannot turn the document reason on — a page that grows past the
* thresholds mid-session is only re-judged the next time it loads — but replacement can still turn
* it back off.
*/
}, {
key: "evaluateDocument",
value: function evaluateDocument(doc) {
this.documentBreached = (0, _shouldEnableLimitedMode.shouldEnableLimitedModeForDocument)(doc, {
docSizeThreshold: this.config.docSizeThreshold,
nodeCountThreshold: this.config.nodeCountThreshold
});
}
/**
* Whether a `doc.nodeSize` delta is large enough to be bulk work rather than typing. A keystroke
* moves this by 1; a paste, a bulk replace or a document load moves it far more.
*/
}, {
key: "isBulkChange",
value: function isBulkChange(nodeSizeDelta) {
return Math.abs(nodeSizeDelta) >= this.config.bulkChangeNodeSize;
}
/**
* Discard signals for a window. Called for bulk work, which is expensive but transient and
* self-limiting, so its cost must not be attributed to the device struggling.
*/
}, {
key: "suppress",
value: function suppress() {
if (this.latched) {
return;
}
this.suppressedUntil = this.now() + this.config.bulkChangeSuppressionMs;
}
/**
* Feed one keystroke's input latency (dispatch through to the next animation frame).
*/
}, {
key: "recordInputLatency",
value: function recordInputLatency(durationMs) {
if (!this.canRecord()) {
return 'ignored';
}
var _this$config = this.config,
slowInputMs = _this$config.slowInputMs,
latencyWindowSize = _this$config.latencyWindowSize,
latencySlowSamplesRequired = _this$config.latencySlowSamplesRequired;
this.totalInputSamples += 1;
if (durationMs > slowInputMs) {
this.totalSlowInputs += 1;
// Remembered even once the window rolls over, so the freeze criterion below can check that
// the jank actually coincided with editing.
this.lastSlowInputAt = this.now();
}
this.latencySamples.push(durationMs);
if (this.latencySamples.length > latencyWindowSize) {
this.latencySamples.shift();
}
if (this.latencySamples.length < latencyWindowSize) {
return 'recorded';
}
var slowSamples = this.latencySamples.filter(function (sample) {
return sample > slowInputMs;
}).length;
if (slowSamples < latencySlowSamplesRequired) {
return 'recorded';
}
// Median rather than mean: a mean is dragged over the threshold by one or two outliers, which
// is exactly the transient jank this policy is meant to ignore.
var windowMedian = (0, _median.median)(this.latencySamples);
if (windowMedian <= slowInputMs) {
return 'recorded';
}
return this.qualify('inputLatency', windowMedian);
}
/**
* Feed one `longtask` PerformanceObserver entry.
*/
}, {
key: "recordLongTask",
value: function recordLongTask(durationMs) {
if (!this.canRecord()) {
return 'ignored';
}
var _this$config2 = this.config,
freezeTaskMs = _this$config2.freezeTaskMs,
freezeWindowMs = _this$config2.freezeWindowMs,
freezeTasksRequired = _this$config2.freezeTasksRequired;
if (durationMs <= freezeTaskMs) {
return 'recorded';
}
var now = this.now();
this.totalFreezes += 1;
this.freezeTimes.push(now);
this.freezeTimes = this.freezeTimes.filter(function (time) {
return now - time <= freezeWindowMs;
});
if (this.freezeTimes.length < freezeTasksRequired) {
return 'recorded';
}
// Corroboration. `longtask` is process-wide, so without this a busy background tab or an
// unrelated app could latch an editor the user is typing in perfectly happily.
if (this.lastSlowInputAt === undefined || now - this.lastSlowInputAt > freezeWindowMs) {
return 'recorded';
}
return this.qualify('freeze');
}
}, {
key: "canRecord",
value: function canRecord() {
if (this.latched) {
return false;
}
var now = this.now();
if (now - this.startedAt < this.config.warmUpMs) {
return false;
}
return now >= this.suppressedUntil;
}
/** Snapshot of what the latch was based on. Called before the evidence buffers are cleared. */
}, {
key: "buildDetails",
value: function buildDetails(reason, latencyMedianMs) {
var _this$firstWindowReas;
var now = this.now();
return {
reason: reason,
firstWindowReason: (_this$firstWindowReas = this.firstWindowReason) !== null && _this$firstWindowReas !== void 0 ? _this$firstWindowReas : reason,
requiredConfirmations: this.config.requiredConfirmations,
documentAlreadyBreached: this.documentBreached,
msFromFirstWindow: this.firstQualifiedAt === undefined ? undefined : Math.round(now - this.firstQualifiedAt),
latencyMedianMs: latencyMedianMs === undefined ? undefined : Math.round(latencyMedianMs),
timeToLatchMs: Math.round(now - this.startedAt),
totalInputSamples: this.totalInputSamples,
totalSlowInputs: this.totalSlowInputs,
totalFreezes: this.totalFreezes
};
}
}, {
key: "qualify",
value: function qualify(reason, latencyMedianMs) {
var now = this.now();
// Each qualifying window must be independent evidence, so the buffers are cleared rather than
// left to re-trigger off the same samples on the very next keystroke.
this.latencySamples = [];
this.freezeTimes = [];
// Too soon after the last counted window to be independent of it, so it earns no credit. The
// buffers above are still cleared, which is what makes the run rebuild from scratch.
if (this.lastQualifiedAt !== undefined && now - this.lastQualifiedAt < this.config.confirmationGapMs) {
return 'qualified';
}
this.qualifiedWindows += 1;
this.lastQualifiedAt = now;
if (this.firstQualifiedAt === undefined) {
this.firstQualifiedAt = now;
this.firstWindowReason = reason;
}
if (this.qualifiedWindows < this.config.requiredConfirmations) {
return 'qualified';
}
this.latched = true;
this.latchDetails = this.buildDetails(reason, latencyMedianMs);
return 'latched';
}
}]);
}();