@atlaskit/editor-plugin-limited-mode 14.0.0 → 14.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +36 -0
- package/afm-cc/tsconfig.json +9 -0
- package/afm-products/tsconfig.json +0 -9
- package/dist/cjs/limitedModePlugin.js +7 -4
- package/dist/cjs/pm-plugins/main.js +201 -18
- package/dist/cjs/pm-plugins/plugin-key.js +8 -0
- package/dist/cjs/pm-plugins/utils/latch-detector-types.js +5 -0
- package/dist/cjs/pm-plugins/utils/latch-detector.js +108 -0
- package/dist/cjs/pm-plugins/utils/latch-policy-types.js +1 -0
- package/dist/cjs/pm-plugins/utils/latch-policy.js +314 -0
- package/dist/es2019/limitedModePlugin.js +4 -3
- package/dist/es2019/pm-plugins/main.js +195 -16
- package/dist/es2019/pm-plugins/plugin-key.js +2 -0
- package/dist/es2019/pm-plugins/utils/latch-detector-types.js +1 -0
- package/dist/es2019/pm-plugins/utils/latch-detector.js +92 -0
- package/dist/es2019/pm-plugins/utils/latch-policy-types.js +0 -0
- package/dist/es2019/pm-plugins/utils/latch-policy.js +277 -0
- package/dist/esm/limitedModePlugin.js +6 -3
- package/dist/esm/pm-plugins/main.js +199 -16
- package/dist/esm/pm-plugins/plugin-key.js +2 -0
- package/dist/esm/pm-plugins/utils/latch-detector-types.js +1 -0
- package/dist/esm/pm-plugins/utils/latch-detector.js +102 -0
- package/dist/esm/pm-plugins/utils/latch-policy-types.js +0 -0
- package/dist/esm/pm-plugins/utils/latch-policy.js +307 -0
- package/dist/types/limitedModePluginType.d.ts +34 -1
- package/dist/types/pm-plugins/main.d.ts +12 -4
- package/dist/types/pm-plugins/plugin-key.d.ts +2 -0
- package/dist/types/pm-plugins/utils/latch-detector-types.d.ts +30 -0
- package/dist/types/pm-plugins/utils/latch-detector.d.ts +15 -0
- package/dist/types/pm-plugins/utils/latch-policy-types.d.ts +150 -0
- package/dist/types/pm-plugins/utils/latch-policy.d.ts +112 -0
- package/package.json +6 -2
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import _classCallCheck from "@babel/runtime/helpers/classCallCheck";
|
|
2
|
+
import _createClass from "@babel/runtime/helpers/createClass";
|
|
3
|
+
import _defineProperty from "@babel/runtime/helpers/defineProperty";
|
|
4
|
+
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; }
|
|
5
|
+
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) { _defineProperty(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; }
|
|
6
|
+
import { median } from '@atlaskit/editor-common/median';
|
|
7
|
+
import { shouldEnableLimitedModeForDocument } from '@atlaskit/editor-common/should-enable-limited-mode';
|
|
8
|
+
/**
|
|
9
|
+
* The shipped policy.
|
|
10
|
+
*
|
|
11
|
+
* These values add up to: nothing counts for the first 10s; then a window qualifies on either 6 of
|
|
12
|
+
* 12 keystrokes slower than 100ms with that window's median also over 100ms, or 3 long tasks over
|
|
13
|
+
* 600ms within 30s corroborated by a slow keystroke in that same 30s. Two qualifying windows at
|
|
14
|
+
* least 30s apart latch limited mode.
|
|
15
|
+
*
|
|
16
|
+
* `freezeTaskMs` matches `DEFAULT_FREEZE_THRESHOLD` in
|
|
17
|
+
* `editor-plugin-base/src/pm-plugins/frozen-editor.ts`, which backs the existing
|
|
18
|
+
* `ACTION.BROWSER_FREEZE` telemetry, so production dashboards can be used to calibrate it.
|
|
19
|
+
* `slowInputMs` is deliberately tighter than that file's `DEFAULT_SLOW_THRESHOLD` of 300 — this
|
|
20
|
+
* needs to notice a degraded experience, not just an unusable one.
|
|
21
|
+
*
|
|
22
|
+
* `requiredConfirmations` and `confirmationGapMs` are the values that matter most — see the comment
|
|
23
|
+
* on the former.
|
|
24
|
+
*/
|
|
25
|
+
export var DEFAULT_LATCH_POLICY_CONFIG = {
|
|
26
|
+
warmUpMs: 10000,
|
|
27
|
+
slowInputMs: 100,
|
|
28
|
+
latencyWindowSize: 12,
|
|
29
|
+
latencySlowSamplesRequired: 6,
|
|
30
|
+
freezeTaskMs: 600,
|
|
31
|
+
freezeTasksRequired: 3,
|
|
32
|
+
freezeWindowMs: 30000,
|
|
33
|
+
requiredConfirmations: 2,
|
|
34
|
+
confirmationGapMs: 30000,
|
|
35
|
+
bulkChangeNodeSize: 100,
|
|
36
|
+
bulkChangeSuppressionMs: 2000,
|
|
37
|
+
docSizeThreshold: 750000,
|
|
38
|
+
nodeCountThreshold: 5000
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Decides whether limited mode should be on.
|
|
43
|
+
*
|
|
44
|
+
* The single authority for that question, covering both reasons:
|
|
45
|
+
*
|
|
46
|
+
* - **The document** — too large, too many nodes, or containing a legacy content macro. Evaluated on
|
|
47
|
+
* load and on document replacement, so it can turn back off (a `replaceDocument` onto a smaller
|
|
48
|
+
* page) without costing a full-document walk per transaction.
|
|
49
|
+
* - **The device** — sustained slow keystrokes or repeated long tasks. **One-way**: once the runtime
|
|
50
|
+
* bar is met the policy stops evaluating, so the editor can never oscillate between modes.
|
|
51
|
+
*
|
|
52
|
+
* `isBreached()` is the combined verdict. Everything tunable is in `config`, so the whole high bar is
|
|
53
|
+
* unit-testable without needing to make a real browser slow, and a caller can substitute a
|
|
54
|
+
* differently configured policy. `latch-detector.ts` owns the browser plumbing that feeds the runtime
|
|
55
|
+
* criteria, and takes a policy instance rather than constructing one.
|
|
56
|
+
*/
|
|
57
|
+
export var LatchPolicy = /*#__PURE__*/function () {
|
|
58
|
+
function LatchPolicy(_ref) {
|
|
59
|
+
var now = _ref.now,
|
|
60
|
+
config = _ref.config;
|
|
61
|
+
_classCallCheck(this, LatchPolicy);
|
|
62
|
+
/** Public so the detector can read the tunables it needs rather than duplicating them. */
|
|
63
|
+
/** Public so the detector shares one clock with the policy. */
|
|
64
|
+
_defineProperty(this, "latencySamples", []);
|
|
65
|
+
_defineProperty(this, "freezeTimes", []);
|
|
66
|
+
_defineProperty(this, "qualifiedWindows", 0);
|
|
67
|
+
_defineProperty(this, "suppressedUntil", 0);
|
|
68
|
+
_defineProperty(this, "latched", false);
|
|
69
|
+
_defineProperty(this, "documentBreached", false);
|
|
70
|
+
/** Cumulative for the session and never cleared, unlike the evidence buffers. */
|
|
71
|
+
_defineProperty(this, "totalInputSamples", 0);
|
|
72
|
+
_defineProperty(this, "totalSlowInputs", 0);
|
|
73
|
+
_defineProperty(this, "totalFreezes", 0);
|
|
74
|
+
this.now = now;
|
|
75
|
+
this.config = _objectSpread(_objectSpread({}, DEFAULT_LATCH_POLICY_CONFIG), config);
|
|
76
|
+
this.startedAt = now();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Whether limited mode should be on, for either reason. This is the verdict consumers act on.
|
|
81
|
+
*/
|
|
82
|
+
return _createClass(LatchPolicy, [{
|
|
83
|
+
key: "isBreached",
|
|
84
|
+
value: function isBreached() {
|
|
85
|
+
return this.documentBreached || this.latched;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* What the latch was based on, or `undefined` while un-latched. Intended for telemetry — nothing in
|
|
90
|
+
* the decision reads it back.
|
|
91
|
+
*/
|
|
92
|
+
}, {
|
|
93
|
+
key: "getLatchDetails",
|
|
94
|
+
value: function getLatchDetails() {
|
|
95
|
+
return this.latchDetails;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Whether the runtime (device) criteria have latched. One-way, and never cleared. */
|
|
99
|
+
}, {
|
|
100
|
+
key: "isLatched",
|
|
101
|
+
value: function isLatched() {
|
|
102
|
+
return this.latched;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Latch the runtime reason directly, without accumulating evidence for it.
|
|
107
|
+
*
|
|
108
|
+
* The policy latches itself when its own criteria are met, so this exists for callers that have
|
|
109
|
+
* already decided: the plugin replaying the detector's latch transaction, and dev tooling forcing
|
|
110
|
+
* the state by hand. Idempotent, and one-way like every other route to `latched`.
|
|
111
|
+
*/
|
|
112
|
+
}, {
|
|
113
|
+
key: "latch",
|
|
114
|
+
value: function latch() {
|
|
115
|
+
if (this.latched) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
this.latched = true;
|
|
119
|
+
this.latchDetails = this.buildDetails('forced', undefined);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Whether the document currently breaches the thresholds. Can go back to false. */
|
|
123
|
+
}, {
|
|
124
|
+
key: "isDocumentBreached",
|
|
125
|
+
value: function isDocumentBreached() {
|
|
126
|
+
return this.documentBreached;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Evaluate the document reason against the size / node-count / legacy-content-macro thresholds.
|
|
131
|
+
*
|
|
132
|
+
* Walks the whole document, so the caller decides when it is worth paying for: `pm-plugins/main.ts`
|
|
133
|
+
* calls this on load and on `replaceDocument` (e.g. live-to-live page navigation) only, never per
|
|
134
|
+
* transaction. Editing therefore cannot turn the document reason on — a page that grows past the
|
|
135
|
+
* thresholds mid-session is only re-judged the next time it loads — but replacement can still turn
|
|
136
|
+
* it back off.
|
|
137
|
+
*/
|
|
138
|
+
}, {
|
|
139
|
+
key: "evaluateDocument",
|
|
140
|
+
value: function evaluateDocument(doc) {
|
|
141
|
+
this.documentBreached = shouldEnableLimitedModeForDocument(doc, {
|
|
142
|
+
docSizeThreshold: this.config.docSizeThreshold,
|
|
143
|
+
nodeCountThreshold: this.config.nodeCountThreshold
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Whether a `doc.nodeSize` delta is large enough to be bulk work rather than typing. A keystroke
|
|
149
|
+
* moves this by 1; a paste, a bulk replace or a document load moves it far more.
|
|
150
|
+
*/
|
|
151
|
+
}, {
|
|
152
|
+
key: "isBulkChange",
|
|
153
|
+
value: function isBulkChange(nodeSizeDelta) {
|
|
154
|
+
return Math.abs(nodeSizeDelta) >= this.config.bulkChangeNodeSize;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Discard signals for a window. Called for bulk work, which is expensive but transient and
|
|
159
|
+
* self-limiting, so its cost must not be attributed to the device struggling.
|
|
160
|
+
*/
|
|
161
|
+
}, {
|
|
162
|
+
key: "suppress",
|
|
163
|
+
value: function suppress() {
|
|
164
|
+
if (this.latched) {
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
this.suppressedUntil = this.now() + this.config.bulkChangeSuppressionMs;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Feed one keystroke's input latency (dispatch through to the next animation frame).
|
|
172
|
+
*/
|
|
173
|
+
}, {
|
|
174
|
+
key: "recordInputLatency",
|
|
175
|
+
value: function recordInputLatency(durationMs) {
|
|
176
|
+
if (!this.canRecord()) {
|
|
177
|
+
return 'ignored';
|
|
178
|
+
}
|
|
179
|
+
var _this$config = this.config,
|
|
180
|
+
slowInputMs = _this$config.slowInputMs,
|
|
181
|
+
latencyWindowSize = _this$config.latencyWindowSize,
|
|
182
|
+
latencySlowSamplesRequired = _this$config.latencySlowSamplesRequired;
|
|
183
|
+
this.totalInputSamples += 1;
|
|
184
|
+
if (durationMs > slowInputMs) {
|
|
185
|
+
this.totalSlowInputs += 1;
|
|
186
|
+
// Remembered even once the window rolls over, so the freeze criterion below can check that
|
|
187
|
+
// the jank actually coincided with editing.
|
|
188
|
+
this.lastSlowInputAt = this.now();
|
|
189
|
+
}
|
|
190
|
+
this.latencySamples.push(durationMs);
|
|
191
|
+
if (this.latencySamples.length > latencyWindowSize) {
|
|
192
|
+
this.latencySamples.shift();
|
|
193
|
+
}
|
|
194
|
+
if (this.latencySamples.length < latencyWindowSize) {
|
|
195
|
+
return 'recorded';
|
|
196
|
+
}
|
|
197
|
+
var slowSamples = this.latencySamples.filter(function (sample) {
|
|
198
|
+
return sample > slowInputMs;
|
|
199
|
+
}).length;
|
|
200
|
+
if (slowSamples < latencySlowSamplesRequired) {
|
|
201
|
+
return 'recorded';
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Median rather than mean: a mean is dragged over the threshold by one or two outliers, which
|
|
205
|
+
// is exactly the transient jank this policy is meant to ignore.
|
|
206
|
+
var windowMedian = median(this.latencySamples);
|
|
207
|
+
if (windowMedian <= slowInputMs) {
|
|
208
|
+
return 'recorded';
|
|
209
|
+
}
|
|
210
|
+
return this.qualify('inputLatency', windowMedian);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Feed one `longtask` PerformanceObserver entry.
|
|
215
|
+
*/
|
|
216
|
+
}, {
|
|
217
|
+
key: "recordLongTask",
|
|
218
|
+
value: function recordLongTask(durationMs) {
|
|
219
|
+
if (!this.canRecord()) {
|
|
220
|
+
return 'ignored';
|
|
221
|
+
}
|
|
222
|
+
var _this$config2 = this.config,
|
|
223
|
+
freezeTaskMs = _this$config2.freezeTaskMs,
|
|
224
|
+
freezeWindowMs = _this$config2.freezeWindowMs,
|
|
225
|
+
freezeTasksRequired = _this$config2.freezeTasksRequired;
|
|
226
|
+
if (durationMs <= freezeTaskMs) {
|
|
227
|
+
return 'recorded';
|
|
228
|
+
}
|
|
229
|
+
var now = this.now();
|
|
230
|
+
this.totalFreezes += 1;
|
|
231
|
+
this.freezeTimes.push(now);
|
|
232
|
+
this.freezeTimes = this.freezeTimes.filter(function (time) {
|
|
233
|
+
return now - time <= freezeWindowMs;
|
|
234
|
+
});
|
|
235
|
+
if (this.freezeTimes.length < freezeTasksRequired) {
|
|
236
|
+
return 'recorded';
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Corroboration. `longtask` is process-wide, so without this a busy background tab or an
|
|
240
|
+
// unrelated app could latch an editor the user is typing in perfectly happily.
|
|
241
|
+
if (this.lastSlowInputAt === undefined || now - this.lastSlowInputAt > freezeWindowMs) {
|
|
242
|
+
return 'recorded';
|
|
243
|
+
}
|
|
244
|
+
return this.qualify('freeze');
|
|
245
|
+
}
|
|
246
|
+
}, {
|
|
247
|
+
key: "canRecord",
|
|
248
|
+
value: function canRecord() {
|
|
249
|
+
if (this.latched) {
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
var now = this.now();
|
|
253
|
+
if (now - this.startedAt < this.config.warmUpMs) {
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
return now >= this.suppressedUntil;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Snapshot of what the latch was based on. Called before the evidence buffers are cleared. */
|
|
260
|
+
}, {
|
|
261
|
+
key: "buildDetails",
|
|
262
|
+
value: function buildDetails(reason, latencyMedianMs) {
|
|
263
|
+
var _this$firstWindowReas;
|
|
264
|
+
var now = this.now();
|
|
265
|
+
return {
|
|
266
|
+
reason: reason,
|
|
267
|
+
firstWindowReason: (_this$firstWindowReas = this.firstWindowReason) !== null && _this$firstWindowReas !== void 0 ? _this$firstWindowReas : reason,
|
|
268
|
+
requiredConfirmations: this.config.requiredConfirmations,
|
|
269
|
+
documentAlreadyBreached: this.documentBreached,
|
|
270
|
+
msFromFirstWindow: this.firstQualifiedAt === undefined ? undefined : Math.round(now - this.firstQualifiedAt),
|
|
271
|
+
latencyMedianMs: latencyMedianMs === undefined ? undefined : Math.round(latencyMedianMs),
|
|
272
|
+
timeToLatchMs: Math.round(now - this.startedAt),
|
|
273
|
+
totalInputSamples: this.totalInputSamples,
|
|
274
|
+
totalSlowInputs: this.totalSlowInputs,
|
|
275
|
+
totalFreezes: this.totalFreezes
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
}, {
|
|
279
|
+
key: "qualify",
|
|
280
|
+
value: function qualify(reason, latencyMedianMs) {
|
|
281
|
+
var now = this.now();
|
|
282
|
+
|
|
283
|
+
// Each qualifying window must be independent evidence, so the buffers are cleared rather than
|
|
284
|
+
// left to re-trigger off the same samples on the very next keystroke.
|
|
285
|
+
this.latencySamples = [];
|
|
286
|
+
this.freezeTimes = [];
|
|
287
|
+
|
|
288
|
+
// Too soon after the last counted window to be independent of it, so it earns no credit. The
|
|
289
|
+
// buffers above are still cleared, which is what makes the run rebuild from scratch.
|
|
290
|
+
if (this.lastQualifiedAt !== undefined && now - this.lastQualifiedAt < this.config.confirmationGapMs) {
|
|
291
|
+
return 'qualified';
|
|
292
|
+
}
|
|
293
|
+
this.qualifiedWindows += 1;
|
|
294
|
+
this.lastQualifiedAt = now;
|
|
295
|
+
if (this.firstQualifiedAt === undefined) {
|
|
296
|
+
this.firstQualifiedAt = now;
|
|
297
|
+
this.firstWindowReason = reason;
|
|
298
|
+
}
|
|
299
|
+
if (this.qualifiedWindows < this.config.requiredConfirmations) {
|
|
300
|
+
return 'qualified';
|
|
301
|
+
}
|
|
302
|
+
this.latched = true;
|
|
303
|
+
this.latchDetails = this.buildDetails(reason, latencyMedianMs);
|
|
304
|
+
return 'latched';
|
|
305
|
+
}
|
|
306
|
+
}]);
|
|
307
|
+
}();
|
|
@@ -1,10 +1,43 @@
|
|
|
1
1
|
import type React from 'react';
|
|
2
|
-
import type { NextEditorPlugin } from '@atlaskit/editor-common/types';
|
|
2
|
+
import type { NextEditorPlugin, OptionalPlugin } from '@atlaskit/editor-common/types';
|
|
3
|
+
import type { AnalyticsPlugin } from '@atlaskit/editor-plugin-analytics';
|
|
3
4
|
import type { PluginKey } from '@atlaskit/editor-prosemirror/state';
|
|
4
5
|
export type LimitedModePluginState = {
|
|
6
|
+
/**
|
|
7
|
+
* The document itself breaches the size / node-count / legacy-content thresholds.
|
|
8
|
+
*
|
|
9
|
+
* Recomputed from the document, and deliberately re-evaluated when the document is replaced
|
|
10
|
+
* (live-to-live page navigation), so navigating from a huge page to a small one restores the full
|
|
11
|
+
* feature set.
|
|
12
|
+
*/
|
|
5
13
|
documentSizeBreachesThreshold: boolean;
|
|
14
|
+
/**
|
|
15
|
+
* Whether limited mode is in force — the single flag every consumer should branch on.
|
|
16
|
+
*
|
|
17
|
+
* Derived from the reasons below and kept in state deliberately, so that consumers do not have to
|
|
18
|
+
* know what the reasons are or how they combine. Adding a future reason then needs no change
|
|
19
|
+
* outside this plugin.
|
|
20
|
+
*/
|
|
21
|
+
enabled: boolean;
|
|
22
|
+
/**
|
|
23
|
+
* The latch policy says limited mode should be on.
|
|
24
|
+
*
|
|
25
|
+
* Only meaningful under `platform_editor_dynamic_limited_mode`; `false` in the control arm, where
|
|
26
|
+
* `documentSizeBreachesThreshold` alone decides. When the experiment is on this is the verdict
|
|
27
|
+
* `enabled` is taken from, and it covers both of the policy's reasons — the document thresholds
|
|
28
|
+
* and the runtime device criteria.
|
|
29
|
+
*
|
|
30
|
+
* The device half is **one-way**: once the runtime bar is met it is never cleared for the lifetime
|
|
31
|
+
* of the editor view, including across document replacement, because navigating between pages does
|
|
32
|
+
* not change the device. That is deliberate — entering or leaving limited mode is itself expensive
|
|
33
|
+
* (tearing down observers, rebuilding decoration sets), so a recoverable mode would risk
|
|
34
|
+
* oscillating on borderline devices and being worse than either steady state. A user whose machine
|
|
35
|
+
* recovers gets the full editor back by reloading the page.
|
|
36
|
+
*/
|
|
37
|
+
latchPolicyBreached: boolean;
|
|
6
38
|
};
|
|
7
39
|
export type LimitedModePlugin = NextEditorPlugin<'limitedMode', {
|
|
40
|
+
dependencies: [OptionalPlugin<AnalyticsPlugin>];
|
|
8
41
|
pluginConfiguration: LimitedModePluginOptions | undefined;
|
|
9
42
|
sharedState: {
|
|
10
43
|
enabled: boolean;
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { SafePlugin } from '@atlaskit/editor-common/safe-plugin';
|
|
2
|
-
import {
|
|
3
|
-
import type { LimitedModePluginState } from '../limitedModePluginType';
|
|
4
|
-
|
|
5
|
-
export declare const createPlugin: (
|
|
2
|
+
import type { ExtractInjectionAPI } from '@atlaskit/editor-common/types';
|
|
3
|
+
import type { LimitedModePlugin, LimitedModePluginState } from '../limitedModePluginType';
|
|
4
|
+
import { LatchPolicy } from './utils/latch-policy';
|
|
5
|
+
export declare const createPlugin: (api?: ExtractInjectionAPI<LimitedModePlugin>,
|
|
6
|
+
/**
|
|
7
|
+
* Replaces the policy this plugin would build for itself.
|
|
8
|
+
*
|
|
9
|
+
* Internal seam for tests that need to drive the criteria against a controlled clock and
|
|
10
|
+
* thresholds. Production goes through `limitedModePlugin`, which never passes one, so the
|
|
11
|
+
* experiment still decides whether a policy exists at all.
|
|
12
|
+
*/
|
|
13
|
+
injectedPolicy?: LatchPolicy) => SafePlugin<LimitedModePluginState>;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { LatchDetails } from './latch-policy-types';
|
|
2
|
+
import type { LatchPolicy } from './latch-policy';
|
|
3
|
+
export type LatchDetector = {
|
|
4
|
+
destroy: () => void;
|
|
5
|
+
/**
|
|
6
|
+
* Call once per text input. Measures the time from the input reaching ProseMirror to the next
|
|
7
|
+
* animation frame — i.e. dispatch plus all plugin work, before the browser paints.
|
|
8
|
+
*/
|
|
9
|
+
measureInput: () => void;
|
|
10
|
+
/**
|
|
11
|
+
* Report a document change so bulk work can be discounted. The policy decides what counts as
|
|
12
|
+
* bulk; the caller only supplies the facts.
|
|
13
|
+
*/
|
|
14
|
+
noteDocumentChange: (change: {
|
|
15
|
+
isDocumentReplaced: boolean;
|
|
16
|
+
nodeSizeDelta: number;
|
|
17
|
+
}) => void;
|
|
18
|
+
};
|
|
19
|
+
export type CreateLatchDetectorOptions = {
|
|
20
|
+
/**
|
|
21
|
+
* Invoked at most once, when the high bar is met, with what the latch was based on. The policy
|
|
22
|
+
* builds that snapshot before clearing its evidence buffers.
|
|
23
|
+
*/
|
|
24
|
+
onLatchCriteriaMet: (details: LatchDetails) => void;
|
|
25
|
+
/**
|
|
26
|
+
* The decision itself. Injected rather than constructed here so a caller can supply a differently
|
|
27
|
+
* configured policy — and so the detector holds no thresholds of its own.
|
|
28
|
+
*/
|
|
29
|
+
policy: LatchPolicy;
|
|
30
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { CreateLatchDetectorOptions, LatchDetector } from './latch-detector-types';
|
|
2
|
+
/**
|
|
3
|
+
* Wires browser performance signals into the supplied {@link LatchPolicy}.
|
|
4
|
+
*
|
|
5
|
+
* Holds no configuration and makes no decisions: it measures, and forwards. Everything tunable lives
|
|
6
|
+
* on the policy, including the clock it reads.
|
|
7
|
+
*
|
|
8
|
+
* The measurement mirrors `editor-plugin-base/src/pm-plugins/frozen-editor.ts`, which already runs an
|
|
9
|
+
* identical `longtask` observer and per-keystroke rAF measurement for every session to feed
|
|
10
|
+
* `ACTION.SLOW_INPUT` / `ACTION.BROWSER_FREEZE`. It is duplicated rather than shared so that this
|
|
11
|
+
* experiment stays self-contained inside the limited-mode plugin — no new cross-plugin dependency,
|
|
12
|
+
* and deleting it is a single-directory revert if the experiment does not ship. The marginal cost is
|
|
13
|
+
* one `performance.now()` and one `requestAnimationFrame` per keystroke.
|
|
14
|
+
*/
|
|
15
|
+
export declare const createLatchDetector: ({ onLatchCriteriaMet, policy, }: CreateLatchDetectorOptions) => LatchDetector;
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every tunable the latch decision depends on.
|
|
3
|
+
*
|
|
4
|
+
* All of it lives here rather than being spread across the detector and the plugin: the numbers *are*
|
|
5
|
+
* the policy, so a different policy is a different set of these values, and having them in one place
|
|
6
|
+
* is what makes the trigger reviewable and tunable as a unit.
|
|
7
|
+
*
|
|
8
|
+
* Shape of the decision, so the fields below read in context. Two independent criteria can each
|
|
9
|
+
* close a "qualifying window": sustained slow keystrokes (`slowInputMs`, `latency*`) or repeated
|
|
10
|
+
* long tasks (`freeze*`). Latching needs `requiredConfirmations` such windows, each separated from
|
|
11
|
+
* the last by `confirmationGapMs`. `warmUpMs` and `bulkChange*` decide when signals are discarded
|
|
12
|
+
* rather than counted.
|
|
13
|
+
*/
|
|
14
|
+
export type LatchPolicyConfig = {
|
|
15
|
+
/**
|
|
16
|
+
* Smallest absolute `doc.nodeSize` delta, compared inclusively, for a transaction to count
|
|
17
|
+
* as bulk work rather than typing — a keystroke moves it by 1, a paste or document load far
|
|
18
|
+
* more. Operations that are expensive but barely change size, such as a table resize or a
|
|
19
|
+
* drag-and-drop move (delete + insert nets to roughly 0), are not caught by this.
|
|
20
|
+
*/
|
|
21
|
+
bulkChangeNodeSize: number;
|
|
22
|
+
/**
|
|
23
|
+
* How long every signal is discarded once bulk work is reported, measured forward from that
|
|
24
|
+
* moment. Each new report pushes the deadline out again, so back-to-back bulk changes hold the
|
|
25
|
+
* window open rather than each getting their own.
|
|
26
|
+
*/
|
|
27
|
+
bulkChangeSuppressionMs: number;
|
|
28
|
+
/**
|
|
29
|
+
* Minimum gap between one qualifying window and the next for the later one to count, compared
|
|
30
|
+
* inclusively. Measured from the previous *counted* window, so raising `requiredConfirmations`
|
|
31
|
+
* raises the total time to latch with it. A window arriving sooner earns no credit yet still
|
|
32
|
+
* clears the evidence buffers, so the run rebuilds from scratch.
|
|
33
|
+
*/
|
|
34
|
+
confirmationGapMs: number;
|
|
35
|
+
/**
|
|
36
|
+
* `doc.nodeSize` above which the document alone puts the editor into limited mode, compared
|
|
37
|
+
* strictly. A backstop for pathologically large documents rather than a routine trigger — see
|
|
38
|
+
* `limited-mode-document-thresholds.ts` for the production percentiles behind the shipped value.
|
|
39
|
+
*/
|
|
40
|
+
docSizeThreshold: number;
|
|
41
|
+
/** Duration a single long task must exceed to be counted as a freeze. */
|
|
42
|
+
freezeTaskMs: number;
|
|
43
|
+
/**
|
|
44
|
+
* How many freezes must sit inside `freezeWindowMs` for the freeze criterion to qualify. They
|
|
45
|
+
* only count alongside a recent slow keystroke — see `freezeWindowMs`.
|
|
46
|
+
*/
|
|
47
|
+
freezeTasksRequired: number;
|
|
48
|
+
/**
|
|
49
|
+
* Does two jobs. It is the sliding window freezes are retained in, older entries being pruned by
|
|
50
|
+
* timestamp on each new freeze; and it is the horizon within which a slow keystroke must have
|
|
51
|
+
* occurred for those freezes to count at all. `longtask` is process-wide, so without that
|
|
52
|
+
* corroboration a busy background tab could latch an editor that is typing perfectly happily.
|
|
53
|
+
*/
|
|
54
|
+
freezeWindowMs: number;
|
|
55
|
+
/**
|
|
56
|
+
* How many samples in a full window must individually exceed `slowInputMs`. This is a floor
|
|
57
|
+
* on how widespread the slowness is, while the median check described on `slowInputMs` is what
|
|
58
|
+
* stops a couple of outliers qualifying on their own. Both conditions have to hold.
|
|
59
|
+
*/
|
|
60
|
+
latencySlowSamplesRequired: number;
|
|
61
|
+
/**
|
|
62
|
+
* How many keystroke samples the latency criterion is evaluated over. The buffer slides one
|
|
63
|
+
* sample at a time, dropping the oldest, and the criterion is only checked once it is full.
|
|
64
|
+
*/
|
|
65
|
+
latencyWindowSize: number;
|
|
66
|
+
/**
|
|
67
|
+
* Node count above which the document alone puts the editor into limited mode, compared strictly.
|
|
68
|
+
* Counted by a full `doc.descendants` walk, which is why `evaluateDocument` is only ever called on
|
|
69
|
+
* load and on document replacement rather than per transaction.
|
|
70
|
+
*/
|
|
71
|
+
nodeCountThreshold: number;
|
|
72
|
+
/**
|
|
73
|
+
* How many qualifying windows must accumulate before limited mode latches.
|
|
74
|
+
*
|
|
75
|
+
* The single most important tunable, because the latch is one-way: a false positive degrades the
|
|
76
|
+
* editor for the rest of the session. Requiring the evidence to reappear across a
|
|
77
|
+
* `confirmationGapMs` gap is what distinguishes "this device is struggling" from "something
|
|
78
|
+
* happened", and stops a large undo, a misbehaving extension or a video call starting from
|
|
79
|
+
* latching on their own. `1` latches on the first window and skips that protection entirely.
|
|
80
|
+
*/
|
|
81
|
+
requiredConfirmations: number;
|
|
82
|
+
/**
|
|
83
|
+
* The bar for "slow", applied three ways: per sample when counting towards
|
|
84
|
+
* `latencySlowSamplesRequired`; as the value a full window's *median* must exceed; and to
|
|
85
|
+
* stamp the most recent slow keystroke, which is what corroborates the freeze criterion.
|
|
86
|
+
* Every comparison is strict, and the median is used rather than a mean precisely because a
|
|
87
|
+
* mean is dragged over the bar by one or two outliers.
|
|
88
|
+
*/
|
|
89
|
+
slowInputMs: number;
|
|
90
|
+
/**
|
|
91
|
+
* Grace period, measured from the policy being constructed, during which every signal is
|
|
92
|
+
* dropped. Editor load is reliably janky and self-resolving, so sampling through it would
|
|
93
|
+
* latch nearly every session.
|
|
94
|
+
*/
|
|
95
|
+
warmUpMs: number;
|
|
96
|
+
};
|
|
97
|
+
/** Which criterion closed a qualifying window. */
|
|
98
|
+
export type LatchReason =
|
|
99
|
+
/** Sustained slow keystrokes. */
|
|
100
|
+
'inputLatency'
|
|
101
|
+
/** Repeated long tasks, corroborated by a slow keystroke. */
|
|
102
|
+
| 'freeze'
|
|
103
|
+
/** Latched directly by a caller rather than by accumulated evidence — see `latch()`. */
|
|
104
|
+
| 'forced';
|
|
105
|
+
/**
|
|
106
|
+
* What the latch was based on, captured at the moment it happened.
|
|
107
|
+
*
|
|
108
|
+
* Evidence buffers are cleared on every qualifying window, so the per-window numbers here are
|
|
109
|
+
* snapshotted before that happens; the `total*` counters are cumulative for the session and are never
|
|
110
|
+
* cleared, which is what makes them comparable across sessions.
|
|
111
|
+
*/
|
|
112
|
+
export type LatchDetails = {
|
|
113
|
+
/** Whether the document was already breaching when the runtime criteria latched. */
|
|
114
|
+
documentAlreadyBreached: boolean;
|
|
115
|
+
/** Which criterion closed the *first* qualifying window. */
|
|
116
|
+
firstWindowReason: LatchReason;
|
|
117
|
+
/** Median of the closing latency window, when `reason` is `inputLatency`. */
|
|
118
|
+
latencyMedianMs: number | undefined;
|
|
119
|
+
/**
|
|
120
|
+
* Elapsed time from the first qualifying window to the latch, so it spans every gap that was
|
|
121
|
+
* waited out. `0` when a single window latched, and undefined for a forced latch, which has no
|
|
122
|
+
* qualifying window behind it.
|
|
123
|
+
*/
|
|
124
|
+
msFromFirstWindow: number | undefined;
|
|
125
|
+
/** Which criterion closed the window that latched. */
|
|
126
|
+
reason: LatchReason;
|
|
127
|
+
/** The `requiredConfirmations` in force for this session, so the bar that was met is known. */
|
|
128
|
+
requiredConfirmations: number;
|
|
129
|
+
/** Milliseconds from the policy being constructed to the latch. */
|
|
130
|
+
timeToLatchMs: number;
|
|
131
|
+
totalFreezes: number;
|
|
132
|
+
totalInputSamples: number;
|
|
133
|
+
totalSlowInputs: number;
|
|
134
|
+
};
|
|
135
|
+
/** Outcome of feeding a signal to the policy. Returned so callers can act on each stage. */
|
|
136
|
+
export type LatchEvaluation =
|
|
137
|
+
/** Discarded: already latched, still warming up, or suppressed. */
|
|
138
|
+
'ignored'
|
|
139
|
+
/** Counted as evidence, but the bar is not met. */
|
|
140
|
+
| 'recorded'
|
|
141
|
+
/** A qualifying window closed; still waiting for confirmation. */
|
|
142
|
+
| 'qualified'
|
|
143
|
+
/** The full bar is met. The caller should latch. */
|
|
144
|
+
| 'latched';
|
|
145
|
+
export type LatchPolicyOptions = {
|
|
146
|
+
/** Overrides for individual tunables; anything omitted falls back to the shipped default. */
|
|
147
|
+
config?: Partial<LatchPolicyConfig>;
|
|
148
|
+
/** Injectable clock — keeps the policy deterministic under test. */
|
|
149
|
+
now: () => number;
|
|
150
|
+
};
|