UNPKG

@atlaskit/editor-plugin-limited-mode

Version:

LimitedMode plugin for @atlaskit/editor-core

277 lines (261 loc) • 10.4 kB
import _defineProperty from "@babel/runtime/helpers/defineProperty"; import { median } from '@atlaskit/editor-common/median'; import { shouldEnableLimitedModeForDocument } from '@atlaskit/editor-common/should-enable-limited-mode'; /** * 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. */ export const DEFAULT_LATCH_POLICY_CONFIG = { warmUpMs: 10_000, slowInputMs: 100, latencyWindowSize: 12, latencySlowSamplesRequired: 6, freezeTaskMs: 600, freezeTasksRequired: 3, freezeWindowMs: 30_000, requiredConfirmations: 2, confirmationGapMs: 30_000, bulkChangeNodeSize: 100, bulkChangeSuppressionMs: 2_000, docSizeThreshold: 750_000, nodeCountThreshold: 5_000 }; /** * 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. */ export class LatchPolicy { constructor({ now, config }) { /** Public so the detector can read the tunables it needs rather than duplicating them. */ /** Public so the detector shares one clock with the policy. */ _defineProperty(this, "latencySamples", []); _defineProperty(this, "freezeTimes", []); _defineProperty(this, "qualifiedWindows", 0); _defineProperty(this, "suppressedUntil", 0); _defineProperty(this, "latched", false); _defineProperty(this, "documentBreached", false); /** Cumulative for the session and never cleared, unlike the evidence buffers. */ _defineProperty(this, "totalInputSamples", 0); _defineProperty(this, "totalSlowInputs", 0); _defineProperty(this, "totalFreezes", 0); this.now = now; this.config = { ...DEFAULT_LATCH_POLICY_CONFIG, ...config }; this.startedAt = now(); } /** * Whether limited mode should be on, for either reason. This is the verdict consumers act on. */ 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. */ getLatchDetails() { return this.latchDetails; } /** Whether the runtime (device) criteria have latched. One-way, and never cleared. */ 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`. */ 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. */ 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. */ evaluateDocument(doc) { this.documentBreached = 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. */ 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. */ 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). */ recordInputLatency(durationMs) { if (!this.canRecord()) { return 'ignored'; } const { slowInputMs, latencyWindowSize, latencySlowSamplesRequired } = this.config; 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'; } const slowSamples = this.latencySamples.filter(sample => 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. const windowMedian = median(this.latencySamples); if (windowMedian <= slowInputMs) { return 'recorded'; } return this.qualify('inputLatency', windowMedian); } /** * Feed one `longtask` PerformanceObserver entry. */ recordLongTask(durationMs) { if (!this.canRecord()) { return 'ignored'; } const { freezeTaskMs, freezeWindowMs, freezeTasksRequired } = this.config; if (durationMs <= freezeTaskMs) { return 'recorded'; } const now = this.now(); this.totalFreezes += 1; this.freezeTimes.push(now); this.freezeTimes = this.freezeTimes.filter(time => 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'); } canRecord() { if (this.latched) { return false; } const 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. */ buildDetails(reason, latencyMedianMs) { var _this$firstWindowReas; const now = this.now(); return { 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 }; } qualify(reason, latencyMedianMs) { const 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'; } }